12 KiB
Agent Notify Inbox Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Collect agent notifications from local and remote machines into a local pending list that can be viewed first via CLI and later via a Bubble Tea TUI.
Architecture: Add an internal/inbox package for records, JSONL storage, local receiver server, remote uploader, and SSH config setup. Hooks will keep sending Ghostty notifications, then attempt to upload an inbox record to the configured local receiver; if upload fails, they write a local fallback JSONL record. CLI commands operate on the local JSONL store, while inbox serve receives remote records over Unix socket or TCP through SSH RemoteForward.
Tech Stack: Go stdlib (net/http, net, JSONL files, flag), existing config, context, hook, logx, tmux; Bubble Tea added only when implementing the TUI phase.
Task 1: Config Model For Inbox
Files:
- Modify:
internal/config/config.go - Modify:
internal/config/config_test.go
Step 1: Add failing config tests
Add tests that assert:
config.Default().Inbox.Enabled == true- default socket path is non-empty and prefers
$XDG_RUNTIME_DIR/agent-notify.sockwhen available - default TCP address is
127.0.0.1:17777 - TOML can override
[inbox] enabled,socket,addr,fallback_local, andtimeout_ms
Step 2: Run the focused test
Run: go test ./internal/config
Expected: FAIL because Inbox config does not exist yet.
Step 3: Implement inbox config
Add:
type Inbox struct {
Enabled bool `toml:"enabled"`
Socket string `toml:"socket"`
Addr string `toml:"addr"`
FallbackLocal bool `toml:"fallback_local"`
TimeoutMS int `toml:"timeout_ms"`
}
Update Config and Default():
Enabled: trueSocket: DefaultInboxSocket()Addr: "127.0.0.1:17777"FallbackLocal: trueTimeoutMS: 500
Add DefaultInboxSocket() in config:
- if
$XDG_RUNTIME_DIRis set, return$XDG_RUNTIME_DIR/agent-notify.sock - otherwise return
$HOME/.local/state/agent-notify/agent-notify.sock
Step 4: Run focused test again
Run: go test ./internal/config
Expected: PASS.
Task 2: Inbox Record And JSONL Store
Files:
- Create:
internal/inbox/record.go - Create:
internal/inbox/store.go - Create:
internal/inbox/store_test.go
Step 1: Add failing store tests
Cover:
- appending records creates
~/.local/state/agent-notify/inbox.jsonl Listreturns records in file orderPendingfiltersstatus == "pending"MarkDone(ids...)rewrites only matching pending records todone- invalid JSONL lines are skipped, not fatal
Step 2: Run focused test
Run: go test ./internal/inbox
Expected: FAIL because package does not exist yet.
Step 3: Implement record model
Record fields:
idtimehostagenteventcwdtitlebodystatussourcetmux
Use a nested TmuxContext with session, window, pane.
Generate IDs with timestamp + random suffix from stdlib, for example 20260526-165001-a1b2c3.
Step 4: Implement JSONL store
Store path:
- default
~/.local/state/agent-notify/inbox.jsonl - injectable path for tests
Operations:
Append(record Record) errorList() ([]Record, error)Pending() ([]Record, error)MarkDone(ids []string) (int, error)Remove(ids []string) (int, error)ClearDone() (int, error)ClearAll() (int, error)
Rewrite operations should write to a temp file and rename.
Step 5: Run tests
Run: go test ./internal/inbox
Expected: PASS.
Task 3: Inbox HTTP Receiver And Uploader
Files:
- Create:
internal/inbox/server.go - Create:
internal/inbox/client.go - Create:
internal/inbox/server_test.go
Step 1: Add failing server/client tests
Cover:
POST /inboxappends a valid record- invalid method returns 405
- invalid JSON returns 400
- client can POST to an HTTP test server
- client timeout is respected by using a small timeout
Step 2: Run focused test
Run: go test ./internal/inbox
Expected: FAIL for missing server/client.
Step 3: Implement receiver
Implement an HTTP handler:
POST /inbox- decode
Record - fill missing
id,time, andstatus=pending - append to store
- return JSON
{ "ok": true, "id": "..." }
Step 4: Implement uploader
Uploader behavior:
- prefer Unix socket when configured
- support TCP address fallback through
http://127.0.0.1:17777/inbox - timeout from config
- no retries in hook path
For Unix socket, use an http.Client with custom Transport.DialContext.
Step 5: Run focused tests
Run: go test ./internal/inbox
Expected: PASS.
Task 4: Build Records From Hook Context
Files:
- Create:
internal/inbox/build.go - Create:
internal/inbox/build_test.go - Modify:
internal/tmuxonly if existing APIs do not expose session/window/pane cleanly
Step 1: Add failing build tests
Cover:
- host is populated from
os.Hostname - cwd/title/body/agent/event are copied
- tmux fields are empty outside tmux
- source can be
localorremote
Step 2: Run focused test
Run: go test ./internal/inbox
Expected: FAIL.
Step 3: Implement builder
Add a builder function that takes agent, event, cwd, title, body and returns a complete pending Record.
If tmux env vars are enough, capture:
TMUX_PANEas pane- session/window best effort from
tmux display-messageif current tmux helpers already support shelling out
Do not block hook completion if tmux metadata cannot be collected.
Step 4: Run test
Run: go test ./internal/inbox
Expected: PASS.
Task 5: Hook Integration With Upload And Local Fallback
Files:
- Modify:
internal/hook/cursor.go - Modify:
internal/hook/claude.go - Modify:
internal/hook/hook_test.go
Step 1: Add failing hook tests
Cover:
- enabled cursor hook calls the inbox uploader once after notification path
- disabled hook does not record
- upload failure with
FallbackLocal=trueappends local fallback - upload failure with
FallbackLocal=falsedoes not append fallback - Claude hook records after writing
terminalSequence
Use package-level function variables for uploader/store append just like existing sendForHook stubs.
Step 2: Run focused tests
Run: go test ./internal/hook
Expected: FAIL.
Step 3: Implement integration
After notification is successfully generated/sent:
- build inbox record
- if
cfg.Inbox.Enabled, try upload - on upload error, log
inbox upload failed - if fallback enabled, append local record with
source="fallback"
Avoid returning inbox upload/fallback errors from hook unless local fallback write itself is unexpectedly fatal and needs surfacing. Notification behavior should remain primary.
Step 4: Run focused tests
Run: go test ./internal/hook
Expected: PASS.
Task 6: CLI Inbox Commands
Files:
- Modify:
cmd/agent-notify/main.go - Create:
cmd/agent-notify/inbox.go - Create:
cmd/agent-notify/inbox_test.go
Step 1: Add failing command tests
Cover:
inbox listprints pending recordsinbox list --allincludes done recordsinbox show <id>prints detailsinbox done <id...>marks records done and prints countinbox rm <id...>removes recordsinbox clear --doneclears done records
Step 2: Run command tests
Run: go test ./cmd/agent-notify
Expected: FAIL.
Step 3: Implement command routing
Add inbox to top-level run.
Subcommands:
agent-notify inbox list [--all|--done|--pending]agent-notify inbox show <id>agent-notify inbox done <id...>agent-notify inbox rm <id...>agent-notify inbox clear [--done]
Keep output simple and scriptable:
- one line per list item
- short ID, local time, status, host, agent/event, cwd, title
Step 4: Run focused tests
Run: go test ./cmd/agent-notify
Expected: PASS.
Task 7: Inbox Serve Command
Files:
- Modify:
cmd/agent-notify/inbox.go - Create or modify:
cmd/agent-notify/inbox_test.go
Step 1: Add failing tests
Cover parsing and listener selection:
serve --socket <path>uses Unix socket modeserve --addr 127.0.0.1:17777uses TCP mode- missing flags default to config socket
Do not write an infinite server test through main; test smaller functions.
Step 2: Run focused test
Run: go test ./cmd/agent-notify
Expected: FAIL.
Step 3: Implement serve
Add:
agent-notify inbox serve [--socket PATH] [--addr ADDR]
Behavior:
- default to Unix socket from config
- remove stale socket before listen
- chmod socket
0600 - print listening location to stderr
- serve until interrupted
Step 4: Run focused tests
Run: go test ./cmd/agent-notify
Expected: PASS.
Task 8: SSH Config Auto Writer
Files:
- Create:
internal/inbox/sshconfig.go - Create:
internal/inbox/sshconfig_test.go - Modify:
cmd/agent-notify/inbox.go - Modify:
cmd/agent-notify/inbox_test.go
Step 1: Add failing SSH config tests
Cover:
- empty file gets a managed block
- existing managed block is replaced, not duplicated
- unrelated user config is preserved
- generated block uses the configured socket path
- command returns the exact block so CLI can print it after writing
Step 2: Run focused tests
Run: go test ./internal/inbox ./cmd/agent-notify
Expected: FAIL.
Step 3: Implement managed block
Use markers:
# BEGIN agent-notify inbox
Host *
RemoteForward <socket> <socket>
ExitOnForwardFailure no
ServerAliveInterval 30
# END agent-notify inbox
Write to ~/.ssh/config by default.
Rules:
- create
~/.sshas0700 - create config as
0600 - preserve existing content
- replace old managed block idempotently
- do not parse or modify other
Hostsections
Step 4: Add CLI command
Add:
agent-notify inbox ssh-config install [--path PATH] [--socket PATH]
After writing, print:
- target file path
- the exact managed block that was written
- one reminder that existing SSH sessions must reconnect for
RemoteForwardto take effect
Step 5: Run focused tests
Run: go test ./internal/inbox ./cmd/agent-notify
Expected: PASS.
Task 9: Documentation For CLI Inbox
Files:
- Modify:
README.md
Step 1: Document local receiver setup
Add:
agent-notify inbox serve
Step 2: Document SSH config auto setup
Add:
agent-notify inbox ssh-config install
Explain that the command writes a managed Host * block and prints it after writing.
Step 3: Document list workflow
Add:
agent-notify inbox list
agent-notify inbox show <id>
agent-notify inbox done <id>
Step 4: Run docs-adjacent checks
Run: go test ./...
Expected: PASS.
Task 10: Bubble Tea TUI
Files:
- Modify:
go.mod - Create:
internal/inbox/tui.go - Modify:
cmd/agent-notify/inbox.go
Step 1: Add dependency
Run:
go get github.com/charmbracelet/bubbletea
Step 2: Implement initial TUI
Add:
agent-notify inbox tui
Features:
- list pending items
j/kor arrows moveenteropens details in-placedmarks selected item donerreloads fileqquits
No Ghostty/tmux jump behavior in this phase.
Step 3: Run full verification
Run:
go test ./...
make build
Expected: PASS.
Task 11: Final Verification
Files:
- All touched files
Step 1: Run full test suite
Run:
go test ./...
Expected: PASS.
Step 2: Build binary
Run:
make build
Expected: PASS.
Step 3: Manual smoke test
Run:
tmpdir="$(mktemp -d)"
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox ssh-config install
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox serve --socket "$tmpdir/agent-notify.sock"
In another shell, send a test POST or run a hook simulation and confirm:
XDG_RUNTIME_DIR="$tmpdir" HOME="$tmpdir/home" ./bin/agent-notify inbox list
Expected: the test record appears as pending.