feat(messenger): ao send CLI + live zellij pane ping (aa-43)#74
Conversation
Closes the agent-nudge loop: the inbox messenger writes a durable file
under <workspace>/.ao/inbox/ but the agent in its zellij pane has no
way to know a file appeared. This PR adds three pieces that finish the
chain end-to-end, plus an `ao send` CLI that drives prateek's existing
POST /api/v1/sessions/{id}/send route.
1. inbox.FilenameFor — exported helper so both the inbox messenger
(writes the file) and the panep messenger (points at the file)
agree on the same name for the same (timestamp, message) input.
2. panep.Messenger — new ports.AgentMessenger implementation that
types a short pointer into the agent's pane via the zellij
write-chars action: "📥 ao: new message at .ao/inbox/<file>
— please read it" + newline. Pointer over full-body typing because
multi-line messages (CI tails, code blocks) fight with the agent's
input handler.
3. composite.Messenger — fans Send out to a primary (must succeed) +
N best-effort secondaries (errors logged at WARN, swallowed).
Daemon wires inbox as primary, panep as secondary: if the durable
file write fails we MUST NOT tell the agent about a file that
doesn't exist; if the file is written but the ping fails the
agent will see it on its next inbox check.
4. ao send CLI — mirrors `ao spawn`'s shape: --session and --message
required, POSTs to the existing send route, exit 0/1/2 with the
same semantics as spawn. The HTTP route itself is unchanged.
zellij runtime gains WriteChars(ctx, handle, s) as the narrow seam panep
calls; it is NOT added to ports.Runtime (panep depends on a local
RuntimePaneWriter interface), keeping the runtime port narrow.
TDD throughout; full suite passes with -race (one acceptable pre-existing
macOS socket-length failure in TestSessionStreamsRealZellijPane).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses self-review findings on the prior commit: 1. (Critical) panep filename now matches the inbox filename on disk. Each messenger previously had its own time.Now clock — they fired at different nanoseconds, so the ping pointed at a file the inbox messenger never wrote. Fix: composite.Messenger captures one timestamp per Send and stashes it on ctx via inbox.WithTime; both inbox and panep read it back via inbox.TimeFromContext, falling back to their own clock when called stand-alone. New integration test in composite proves the on-disk filename equals the filename inside the ping body. 2. (Important) `ao send` was sending the untrimmed --message body over the wire even though it validated the trimmed version. Now sends the trimmed message; new test pins the contract. 3. (Important) composite test now uses a slog.Handler that records full Records, so we assert the secondary failure is logged at WARN specifically (not just that the text appears in a level-filtered buffer). 4. (Important) `ao send` 5xx test now asserts the server's error kind propagates to the operator instead of being swallowed for an "HTTP 500"-shaped message. 5. (Minor) Renamed `cap` local to `capture` in send_test.go to avoid shadowing the cap builtin. panep.SetClock dropped — context injection makes it unnecessary. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Greptile SummaryThis PR closes two gaps in the agent-nudge loop: it adds an
Confidence Score: 4/5Safe to merge for functionality; the inbox write (durable delivery) is correct in all paths. The outstanding concern is that The composite/inbox/panep logic is sound and well-tested, including the end-to-end filename-consistency proof. The backend/internal/cli/send.go — HTTP client timeout mismatch with the handler's synchronous workload. Important Files Changed
Sequence DiagramsequenceDiagram
participant CLI as ao send (CLI)
participant HTTP as HTTP Handler
participant Comp as composite.Messenger
participant Inbox as inbox.Messenger
participant Panep as panep.Messenger
participant FS as Filesystem
participant Zellij as zellij (write-chars)
CLI->>HTTP: "POST /api/v1/sessions/{id}/send"
HTTP->>Comp: Send(ctx, id, message)
Comp->>Comp: "ctx = inbox.WithTime(ctx, clock())"
Comp->>Inbox: Send(ctx, id, message) [primary]
Inbox->>FS: WriteFile(.ao/inbox/ts_hash.md)
FS-->>Inbox: ok
Inbox-->>Comp: nil
Comp->>Panep: Send(ctx, id, message) [secondary, best-effort]
Panep->>Panep: "filename = FilenameFor(TimeFromContext(ctx), message)"
Panep->>Zellij: WriteChars(handle, ping body)
Zellij-->>Panep: ok / err (swallowed)
Panep->>Zellij: WriteChars(handle, newline)
Zellij-->>Panep: ok / err (swallowed)
Panep-->>Comp: nil / err (logged+swallowed)
Comp-->>HTTP: nil
HTTP-->>CLI: 200 OK
Reviews (2): Last reviewed commit: "fix(cli): repair `cap` → `capture` over-..." | Re-trigger Greptile |
The earlier replace-all turned every occurrence of `cap` into `capture`, including substrings inside `captures` and `captured` — producing `capturetures` (comment), `capturetured` (test message), and a stray `captureture` local in TestSend_TrimsLeadingAndTrailingWhitespace. Restore the originals so the names match `sendCapture` and the rest of this file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(messenger): ao send + live zellij pane ping (live agent nudges)
Replace the daemon's noopMessenger stub with a composite AgentMessenger
that writes a durable inbox file (primary) and types a live pointer into
the running zellij pane (best-effort secondary), plus the `ao send` CLI
that drives the existing POST /api/v1/sessions/{id}/send route.
- composite: fans Send to inbox then panep, pinning one timestamp so both
derive the same filename; a secondary failure is logged at WARN and
swallowed (the file is on disk), a primary failure aborts the call.
- inbox: writes <workspace>/.ao/inbox/<rfc3339nano>_<hash>.md.
- panep: types "new message at .ao/inbox/<file>" + Enter via a new narrow
zellij WriteChars seam (RuntimePaneWriter), kept off ports.Runtime.
- wiring: newSessionMessenger composes inbox+panep over the shared store;
startSession takes the messenger instead of the noop stub.
Carries across @Aa-43's work from PR #74 (staging), adapted to main's
post-#65/#77 daemon wiring shape.
Closes #79
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(inbox): use O_EXCL so a filename collision errors instead of clobbering
os.WriteFile opens with O_CREATE|O_WRONLY|O_TRUNC, which silently overwrites
an existing file. The doc comment already stated the intent ("we do not retry
on EEXIST"), but O_TRUNC never yields EEXIST — two identical messages sent on
the same composite-pinned nanosecond would produce the same filename and the
second Send would silently lose the first message. Switch to
O_CREATE|O_EXCL|O_WRONLY so a collision surfaces as an error; O_EXCL also
refuses to follow a symlink at the final path component. Add a regression test.
Addresses greptile review on PR #83.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(inbox): remove the freshly-created file when write or close fails
The O_EXCL switch creates the inbox file before writing its body; if
WriteString or Close then fails, the empty/partial .md was left on disk and
the agent's next inbox scan would pick up a truncated ghost message. Remove
the file on those error paths. O_EXCL guarantees the file did not exist before
this call, so the cleanup can only delete our own partial write, never a
legitimate earlier message.
Addresses greptile review on PR #83.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(messenger): reduce ao send to live pane delivery
* fix(send): preserve messages and map lookup errors
* fix(send): reject terminated sessions
Summary
Closes the agent-nudge loop: the inbox messenger writes a durable file under
<workspace>/.ao/inbox/, but the agent in its zellij pane has had no way to know a new file appeared, and there has been noao sendCLI (only an HTTP route). This PR closes both — the e2e demo can now drive an agent end-to-end throughao send.inbox.FilenameFor— exported helper so the inbox messenger (writes) and the new panep messenger (points) agree on a single filename per Send.inbox.WithTime/TimeFromContext— the composite messenger pins one timestamp per Send and stashes it on ctx, so multiple inner messengers that derive a filename from(t, message)see the samet. Without this, inbox's clock and panep's clock fire at different nanoseconds and the ping points at a file that does not exist.panep.Messenger(new package) — implementsports.AgentMessengerby typing a pointer into the agent pane viazellij action write-chars:📥 ao: new message at .ao/inbox/<file> — please read itthen a newline. Pointer over full-body typing because multi-line messages (CI tails, code blocks) fight with the agent's input handler.composite.Messenger(new package) — primary (must succeed) + best-effort secondaries (logged at WARN, swallowed). Daemon wires[inbox, panep]: if the durable file write fails we MUST NOT tell the agent about a file that doesn't exist; if the file is written but the ping fails the agent still sees it on its next inbox check.ao sendCLI — mirrorsao spawn:--sessionand--messagerequired, POSTs to the existing/api/v1/sessions/{id}/sendroute (HTTP route itself is unchanged on staging). Exits 0/1/2 per CLI convention.zellij.Runtime.WriteChars— the narrow seam panep depends on (via a localRuntimePaneWriterinterface in the panep package). NOT added toports.Runtime; the runtime port stays narrow.Test plan
go test -race ./...— 434 pass, 1 acceptable pre-existing failure (TestSessionStreamsRealZellijPane, macOS socket-length issue on staging)go vet ./...cleangofmt -l backend/cleango build ./...cleancomposite_test.go::TestSend_PingFilenameMatchesOnDiskFilenamewires real inbox + real panep over a tempdir and asserts the on-disk filename equals the filename in the ping body — the contract the brief implicitly required🤖 Generated with Claude Code