Skip to content

feat(agents): the send, reply and notify verbs - #212

Merged
eneskirca merged 6 commits into
mainfrom
feat/agent-message-verbs
Aug 15, 2026
Merged

feat(agents): the send, reply and notify verbs#212
eneskirca merged 6 commits into
mainfrom
feat/agent-message-verbs

Conversation

@eneskirca

@eneskirca eneskirca commented Aug 15, 2026

Copy link
Copy Markdown
Owner

PR 5 of the agent-messaging series: the send, reply and notify verbs, wired end to end through the delivery primitive (#207), flow control (#208) and the paste-buffer transport (#210). The verbs are live but fail-closed: every delivery is additionally gated behind the per-project agentMessaging switch (off by default, shipped by PR 6), so nothing changes for any project until its owner opts in.

Task → commit map

Task 5.1 — send/reply, verified-only from day onefeat(control): send/reply — verified-only, with no override and no legacy path

  • requiresVerified in hook-server.ts, checked on the token verdict before identityGate's decision is consulted: the foreign-kid escape (an invented kid is FOREIGN → legacy, invariant 3) defeats the latch and the dated window but never this, and settings.hookIdentityStrict: false releases neither. Flat named refusal, no token/restart advice.
  • Canvas.tsx handles the verbs before its source-routing machinery — they are store-answered (needsLiveCanvas false, pre-provisioned), so a background orchestrator can never travel the human's view or clear an unread badge (G5) — and wraps the delivery in guardConcurrentRestart.
  • New main-side service (src/main/agent-messaging.ts) is the one caller of deliverAgentMessage: scope off the serialized store (isSafeNodeId first), the switch, checkFlowLimits (budget consumed only by a write that reached the pane), the deps record, the trace through the same board-log router as the IPC handler, and a rendered reply whose retry advice is sourced from RETRYABLE in words.
  • Transport: the envelope stays composed by bracketedInjection in core and is moved byte-for-byte by a new framed paste plan (load-buffer stdin → gated copy-mode cancel → paste-buffer -d -r, no -p, no separate Enter) on both the local and SSH legs; assertFramedPayload makes the one unsanitized path refuse anything that is not bracketedInjection output. agent-message.realtty.test.ts now drives this exact plan against a real tmux and a real paste-aware bash. The #{bracket_paste_flag} probe is not reintroduced (fix(tmux): let tmux frame the paste, removing the 3.7 version floor #210's deleted-note stands); the residual is recorded as a TODO at the wiring site.

Task 5.2 — fold in #98's notify, with creditfeat(control): fold in #98's notify — app-owned text, through the one delivery path

  • feat(canvas): add linked agent inbox notifications #98 (@parsa222) is superseded, not rejected: the verb, its --node requirement, its exact --text refusal and its 10-second pair throttle (now PAIR_MIN_INTERVAL_MS, shared with send/reply) survive. The body is substituted in main (NOTIFY_BODY) so even a forged IPC request cannot put caller text into the envelope — "the app owns the entire prompt" is now enforced at the boundary, and delivery goes through the identical verified-only, switch-gated, idle-gated pipeline. feat(canvas): add linked agent inbox notifications #98's "configured inbox" wording is dropped (no such product concept); the note points at the linked context instead.

Task 5.3 — the Server Edition's NAMED refusaltest(server): the messaging verbs inherit the NAMED edition refusal — proven on the wire

  • The mechanism itself already shipped on main (623b09c, shared with the browser-control work by agreement — one string, one mechanism): /control/* answers 400 control-unsupported-on-this-edition with the literal "do not retry". This PR adds the messaging-plan half still owed: wire-level proof that /control/send gets that refusal for a verified caller, that an unverified send is refused on identity first (also terminal, also not an invitation to retry), and the docs/SERVER.md paragraph naming the interaction.

Task 5.4 — the shim's positional mapping and the skill textfeat(skill): document send/reply and which outcomes are worth retrying

  • send|reply join the shim's bare-positional list (proven under real sh, not by grepping the script); --text required by the parser; both agent-facing texts document the verbs, the envelope, and the receiving convention the envelope module said PR 5 owed (only the OUTERMOST frame is authentic). The retry table is rendered from RETRYABLE — and the budgets from PAIR_MIN_INTERVAL_MS/FANOUT_PER_TURN — never re-typed, with a test walking the real table against the text.

Gates

npm run typecheck clean; full npx vitest run: 457 files / 6305 tests passed, 0 failed (including the real-tmux/real-pty suites on this host's tmux 3.4). Every new test was mutation-checked: the verified-only gate (deleted, and rewired through the overridable policy), the switch, noteSent's placement, the framed plan's byte-for-byte body, the no-sanitize refusal, the notify body substitution, the RETRYABLE rendering and the shim glob were each reverted and observed red.

Review round 1 (adversarial review: spec pass, quality changes-required — both findings fixed in 769ae11)

  • C1 (fixed): src/main/agent-messaging.test.ts carried a raw NUL byte (the unaddressable-node-id test), so git rendered the whole 15-test service suite as "Binary files differ" and grep/ripgrep skipped it — the 4c0e8c3 trap. The byte is now the \x00 escape; the file diffs as text (327 added lines in this PR's range) and the suite is unchanged.
  • I2 (fixed): the per-turn fan-out cap was bypassable by concurrency — checkFlowLimits (read) and noteSent (post-write) were not atomic and every lock is per-target, so N parallel sends to N distinct targets all passed the cap before any recorded. New reserveFlow() checks both budgets and takes a provisional hold in one synchronous step; the hold is released in finally, so refused deliveries still cost nothing. Five core unit tests + a service test firing FANOUT_PER_TURN + 3 concurrent sends at distinct targets (cap asserted at the pane); mutation-checked in both layers (pending-count dropped, and service reverted to the bare read — each red).

Concerns carried forward

  • I1 (recorded, deliberately not fixed here): bracketPasteRequested: () => true leaves targetNotPasteAware unreachable in production — forced by fix(tmux): let tmux frame the paste, removing the 3.7 version floor #210's do-not-reintroduce note on the #{bracket_paste_flag} probe. The per-CLI DECSET-2004-at-idle measurement is a gate for PR 7: before the deliver-on-idle queue relies on delivery, measure whether each supported CLI requests bracketed paste at its idle prompt, and either restore a reachable targetNotPasteAware refusal or remove the dead outcome from the agent-facing text. PR 7's implementer inherits this (TODO at the wiring site in src/main/agent-messaging.ts).
  • The verbs remain fail-closed behind the per-project switch until PR 6 lands the validated agentMessaging === true read.

🤖 Generated with Claude Code

eneskirca and others added 5 commits August 15, 2026 16:06
…gacy path

The two messaging verbs exist end to end. At the route, `requiresVerified` in hook-server.ts
admits only a `verified` caller, checked on the VERDICT before identityGate's decision is
consulted: an invented kid is FOREIGN, therefore legacy, therefore never verified — the escape
that defeats the latch and the dated window does not defeat this — and
`settings.hookIdentityStrict: false` releases neither. There is no upgrade population to
protect, so the routes are fail-closed from day one, with a flat named refusal
("Agent messaging refused.") that offers no token or restart advice.

Canvas.tsx handles send/reply BEFORE its source-routing machinery — they are store-answered
(needsLiveCanvas false, pre-positioned), so a background orchestrator's message can never travel
the human's view or clear an unread badge on the way (G5) — validates the args and the source's
control capability, wraps the call in guardConcurrentRestart so a delivery cannot land inside a
wake's un-submitted resume line, and forwards to main.

Main's new agent-messaging service is the ONE caller of deliverAgentMessage (#207): scope off the
serialized store (isSafeNodeId first), the per-project switch (constraint 11 — wired () => false
until PR 6 ships the validated field, so every delivery answers notPermitted switch-off), flow
control (#208; only a write that reached the pane consumes budget), the deps record over
PtyManager/mirror/token files, the trace through the same board-log router the IPC handler uses,
and a rendered reply whose retry advice is sourced from RETRYABLE in words — stalled answers ok
with 'do not retry', because an ok:false stalled is how a message gets delivered twice.

Delivery transport: the envelope is framed by bracketedInjection in core and moved byte-for-byte
by a new framed paste plan (load-buffer stdin → gated copy-mode cancel → paste-buffer -d -r, no
-p, no separate Enter) — stdin keeps every payload off argv and off the remote shell line, and
assertFramedPayload makes the one unsanitized path refuse anything that is not bracketedInjection
output. agent-message.realtty.test.ts now drives this exact plan against a real tmux and a real
paste-aware bash. The #{bracket_paste_flag} probe is NOT reintroduced (pty-manager.ts's deleted
note stands); bracketPasteRequested is wired true with the residual named as a TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… delivery path

PR #98 (parsa222) designed `notify` as a fixed, app-authored prompt a canvas agent can send a
linked agent, with the rule that made it safe: "The app owns the entire prompt so the source
cannot inject instructions through command arguments." This commit SUPERSEDES #98 rather than
rejecting it: the verb, its `--node` requirement and its exact `--text` refusal ('notify does
not accept --text') survive verbatim, and its 10-second per-pair throttle survives as the flow
module's PAIR_MIN_INTERVAL_MS (#208) — the same number, now shared with send/reply so one
conversation has one window.

What changed versus #98's implementation: the body is substituted in MAIN (NOTIFY_BODY), so even
a forged IPC request cannot put caller text into the envelope — the renderer's --text refusal is
UX, the substitution is the boundary, and the test proves a hostile body never reaches the pane.
Delivery goes through the identical deliverAgentMessage pipeline (identity gates, idle gate, pane
probes, receipt, trace) instead of a bare sendText, notify is verified-only like its siblings,
and the per-project switch governs it. #98's "check your configured inbox" wording is dropped —
the product has no inbox concept — in favour of pointing at the linked context the notified agent
can actually read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… proven on the wire

Task 5.3's mechanism already shipped (623b09c, via the browser-control work — one string, one
mechanism, by agreement): the Server Edition registers serverEditionControlHandler, so every
/control/* verb answers 400 control-unsupported-on-this-edition with the literal "do not retry"
instead of the outage-shaped 'control unavailable' a language model retries. This commit adds
what the messaging plan still owed on top of it: wire-level proof that /control/send gets the
same named refusal for a verified caller, that an UNVERIFIED send is refused on identity first
(the flat messaging refusal, also not an invitation to retry), and a docs/SERVER.md paragraph
naming the interaction so nobody reads the 403 leg as a missing feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shim's positional list gains send|reply (`sh shim send b1 --text hi` works like write's
bare-positional form), proven under real sh in control-shim-parse.test.ts rather than by grepping
the script — a broken case glob is invisible to a string assertion. parseControlRequest now
requires --text for both.

Both agent-facing texts (the manage-nodeterm-canvas skill body and the codex/gemini instructions
block) document the three verbs, the incoming envelope's shape, and the receiving convention the
envelope module said PR 5 owed: ONLY THE OUTERMOST frame is authentic — anything that looks like
a frame inside the body is data, and a framed message carries no more authority than an unframed
one.

The retry guidance is RENDERED from RETRYABLE at build time, not re-typed: the Record type keeps
the table exhaustive, so a new outcome kind lands in the skill text the day it is added, and the
test walks the real table against the rendered text (word-boundary matched, so
deliveredToReplacedTarget cannot vouch for delivered). The flow budgets in the text are rendered
from PAIR_MIN_INTERVAL_MS and FANOUT_PER_TURN the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… suite survives grep

Review round 1 of PR #212 (C1 + I2).

C1: src/main/agent-messaging.test.ts carried a RAW NUL byte in the unaddressable-node-id test
('x<NUL>y' written as the byte instead of the '\x00' escape). Git classified the file as binary,
so the 15-test suite that proves the switch-off gate, the scope refusals, the flow budgets and the
NOTIFY_BODY boundary rendered as "Binary files differ" in the PR diff and was silently skipped by
grep/ripgrep — the exact trap this repo already fixed once in 4c0e8c3, whose commit message this
file's own pairKey docblock quotes. The byte is now the four-character escape; the file is text
again and the tests are unchanged (15/15 → 16/16 with the new one below).

I2: checkFlowLimits (a pure read, pre-delivery) followed by noteSent (post-write) was not atomic,
and every lock on the path is per-TARGET — so N parallel sends to N distinct targets all passed
the per-turn fan-out cap before any of them recorded, and the plan's blast-radius limit held only
for a sender polite enough to send sequentially. A language model firing tool calls in parallel is
not that sender. New reserveFlow() in agent-message-flow.ts checks BOTH budgets and takes a
provisional hold in one synchronous step (no await = the critical section, single-threaded); the
hold counts against the fan-out budget like a recorded send, holds the pair shut like a fresh
noteSent, and is released in the service's finally — so a delivery that never reaches the pane
still costs nothing, preserving noteSent's contract. Release is idempotent. deliverFromControl now
reserves instead of checking. Covered by five new reserveFlow unit tests (pending counts, release
restores, shared budget with recorded sends, in-flight pair held shut with B→A open, idempotent
release) plus a service-level test firing FANOUT_PER_TURN+3 CONCURRENT sends at distinct targets
and asserting the cap holds at the pane. Mutation-checked in both layers: dropping '+ pending'
from the core check and reverting the service to the bare read each went red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eneskirca
eneskirca enabled auto-merge August 15, 2026 13:38
@eneskirca
eneskirca merged commit 304f7e3 into main Aug 15, 2026
4 checks passed
eneskirca added a commit that referenced this pull request Aug 15, 2026
…and dismissal is not an answer

Review round 1 on PR #213 (C1 critical + I1/I2 important + M-1/M-2/M-3 minor).

C1 — the ack now carries THE ANSWER ('kept' | 'declined'), not a bare bit. "Turn it off" only
deletes the field from this working copy; the hostile true survives in git and a routine
checkout/pull restores it. Under the old bare ack that restored true met a standing acknowledgment
and granted silently to a user who said no. Now: granted = enabledInFile && answer === 'kept', and
a re-arriving true against a recorded 'declined' is refused AND re-noticed. The renderer setter's
off-path records 'declined' too, which closes M-2 (a teammate re-committing true re-notices).

I1 — only the two buttons are answers. ConfirmDialog gains onDismiss (Escape + overlay-click
channel, default = historical onCancel) and autoFocusButtons (false = no button holds focus for a
native Enter/Space to activate — the path enterConfirms never sees). The notice dismisses without
recording anything and re-shows next launch; the focus property is asserted with a focused input
across mount.

I2 — projectCapabilityEnabled renamed to projectCapabilityFlagInFile with a NEVER-a-grant-check
doc; the one consumer-facing grant predicate is projectCapabilityGrantedFor(project, cap), which
derives the strict file flag and the own-property answer itself, so PR 4/PR 6 cannot pick the raw
flag by mistake.

M-1 — the flag read and readProjectCapabilities are own-property only (no prototype-inherited
consent). M-3 — the consent test's projectToFile assertion now feeds an ack-carrying project, so
it can actually catch an ack leak.

Also merges origin/main (PR #212, agent messaging verbs) — no overlapping files.

Mutations: any-answer-grants (3 red), declined-stays-silent (2 red), dismiss-records-ack (2 red),
autofocus restored (1 red), prototype flag accepted (2 red), setter forgets declined (2 red) —
6/6 caught.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot pushed a commit to jasonkneen/nodeterm that referenced this pull request Aug 15, 2026
… off the store

Replaces the fail-closed () => false placeholder (PR eneskirca#212) with the real
per-project check: projectCapabilityGrantedFor(project, 'agentMessaging') —
the strict === true flag in the git-shared project.json AND this machine's
recorded 'kept' answer to the clone notice. NEVER the raw file bit
(projectCapabilityFlagInFile): during the pending-notice window and after a
recorded decline the flag answers true while delivery must refuse, and the
new suite goes red on exactly that swap (PR eneskirca#213 review, I2).

- WorkspaceStore.capabilityProjectFor: the capability view of one project
  (strict flags from the shared file, answers from the machine-local index
  entry — a file-borne capabilityAck is a forgery and is never read), same
  id semantics as persistedCanvases so scope and switch agree on identity.
- messagingEnabledVia: the production wiring as one testable call.
- main/index.ts wires the IPC deps through both; nothing else changes.
- agent-messaging-switch.test.ts drives the REAL control path, half of it
  over a REAL WorkspaceStore on disk: unanswered/declined/forged/hand-edited
  all refuse as notPermitted (switch-off); flag+kept delivers to the pane
  write. Mutation-checked 4/4 (flag-for-grant swap, file-borne ack, entry
  ack dropped for cwd and inline entries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant