feat(agents): the send, reply and notify verbs - #212
Merged
Conversation
…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
enabled auto-merge
August 15, 2026 13:38
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 5 of the agent-messaging series: the
send,replyandnotifyverbs, 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-projectagentMessagingswitch (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 one →feat(control): send/reply — verified-only, with no override and no legacy pathrequiresVerifiedinhook-server.ts, checked on the token verdict beforeidentityGate'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, andsettings.hookIdentityStrict: falsereleases neither. Flat named refusal, no token/restart advice.needsLiveCanvasfalse, pre-provisioned), so a background orchestrator can never travel the human's view or clear an unread badge (G5) — and wraps the delivery inguardConcurrentRestart.src/main/agent-messaging.ts) is the one caller ofdeliverAgentMessage: scope off the serialized store (isSafeNodeIdfirst), 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 fromRETRYABLEin words.bracketedInjectionin core and is moved byte-for-byte by a new framed paste plan (load-bufferstdin → gated copy-mode cancel →paste-buffer -d -r, no-p, no separate Enter) on both the local and SSH legs;assertFramedPayloadmakes the one unsanitized path refuse anything that is notbracketedInjectionoutput.agent-message.realtty.test.tsnow 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 credit →feat(control): fold in #98's notify — app-owned text, through the one delivery path--noderequirement, its exact--textrefusal and its 10-second pair throttle (nowPAIR_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 refusal →
test(server): the messaging verbs inherit the NAMED edition refusal — proven on the wire/control/*answers 400control-unsupported-on-this-editionwith the literal "do not retry". This PR adds the messaging-plan half still owed: wire-level proof that/control/sendgets that refusal for a verified caller, that an unverifiedsendis refused on identity first (also terminal, also not an invitation to retry), and thedocs/SERVER.mdparagraph naming the interaction.Task 5.4 — the shim's positional mapping and the skill text →
feat(skill): document send/reply and which outcomes are worth retryingsend|replyjoin the shim's bare-positional list (proven under realsh, not by grepping the script);--textrequired 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 fromRETRYABLE— and the budgets fromPAIR_MIN_INTERVAL_MS/FANOUT_PER_TURN— never re-typed, with a test walking the real table against the text.Gates
npm run typecheckclean; fullnpx 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)
src/main/agent-messaging.test.tscarried 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\x00escape; the file diffs as text (327 added lines in this PR's range) and the suite is unchanged.checkFlowLimits(read) andnoteSent(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. NewreserveFlow()checks both budgets and takes a provisional hold in one synchronous step; the hold is released infinally, so refused deliveries still cost nothing. Five core unit tests + a service test firingFANOUT_PER_TURN + 3concurrent 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
bracketPasteRequested: () => trueleavestargetNotPasteAwareunreachable 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 reachabletargetNotPasteAwarerefusal or remove the dead outcome from the agent-facing text. PR 7's implementer inherits this (TODO at the wiring site insrc/main/agent-messaging.ts).agentMessaging === trueread.🤖 Generated with Claude Code