Compare commits

...
Author SHA1 Message Date
dev-driver 93b72acd57 fix(daemon): a draft-aware seat keeps its immediate result when the send waits for idle
main's lease-free idle wait (#1084) runs before the seat's lease for any send with wait-for-idle. The
guard's preference reports 'on' only in hold mode, so a draft-aware seat took that wait, and with no
authoritative idle hook the wait could refuse a draft as needs-input, or wait out its budget on an
unreadable screen, before the draft-aware check ran. A draft-aware seat now skips the lease-free wait,
as a hold-mode seat does: its input is read inside the lease first, so a draft or an unreadable input
is retained at once with its reason, and an empty input waits for idle there, as #1072 was reviewed.
Seats with the guard off keep the lease-free wait.
2026-10-18 20:28:26 +00:00
dev-driver dd500bc5cd test(daemon): #1481 round 2: a draft-aware Claude seat with no idle hook keeps its immediate result with wait-for-idle
The existing case uses a terminal seat with an idle hook, so main's lease-free wait passes before the
draft-aware check. With actual Claude readiness and no idle hook, a draft or an unreadable input must
still be retained at once with its exact reason and original ID, writing nothing and not waiting; an
empty input box takes the send once.
2026-10-18 20:27:52 +00:00
dev-driver b4dc9f1a6f test(daemon): drive #1072's composer snapshot in main's tmux argv test
The test requires every this.run() call site in tmux.ts to be reached. #1072's captureComposerSnapshot
adds one (its pane_in_mode read), reached only after a parseable cursor and a non-empty screen. A
recording adapter with a composer option now supplies those two replies, and the snapshot is driven
once; every other call keeps the existing replies.
2026-10-18 19:24:06 +00:00
dev-driver 0c1a9a567b test(daemon): give main's tmux argv test's guard fake the draft-aware hooks
main's tmux-argv-legacy-equivalence test (added after #1072's base) sets a hand-built delivery guard
that predates #1072's input hooks, so the adapter's guard setter threw. The fake now has them as
no-ops with draft checks off, so it still compares only the argv on both paths.
2026-10-18 18:38:23 +00:00
dev-driver fff202d831 fix(daemon): rename the draft reader's styled-line helper, which main also added under the same name
Both #1072 and main (#1139's faint-suggestion check) added a styledLines in session-transport.ts with
different shapes, which the merge kept side by side. The draft reader's copy, returning text with
faint flags, is now styledTextLines; main's cell version keeps its name. No behaviour change.
2026-10-18 18:02:15 +00:00
dev-driver f1fe5338c4 Merge main into the draft-aware typing guard (#1072)
Brings Nash Aschenbath's reviewed draft-aware typing guard up to current main. Their commits are kept
as they were; this merge commit holds only the conflict resolutions:

- composer-prompts.ts (both added it): main's file, plus the parts only this branch has
  (readComposerPromptLine, the composer input types, composerContainsOwnedText, ownCollapsedPaste and
  the string-width import). The shared matchers were already identical.
- startup-submission-evidence.ts: main's version; isComposerFooter is exported for the Codex footer check.
- startup-orchestrator.ts: main's version, which already passes the runtime as this branch did.
- session-transport.ts: both sides' imports; main's codex-pane-patterns import replaces the local
  turn-status pattern; this branch's draft reading of the prompt line; both probe inputs (composer
  snapshot, agent app); the composer snapshot joins the first classification and main's styled
  recheck is unchanged; constructor and methods from both; the guarded send keeps main's lease-free
  idle wait, then the draft-aware path inside the lease, and main's startup reconcile after it.
- seat-delivery-guard.ts: this branch's lease fields and draft checks with main's serial owner and
  custody refusal; main's custody lease now carries the required humanEpoch.
- tmux.ts, all-migrations.ts (100 between 099 and 101), the transport test imports, and both docs
  (each side's added text kept).
2026-10-18 17:23:07 +00:00
Nash Aschenbath 1c2bdf2273 docs: clarify single-attempt guard outcomes 2026-10-10 12:51:13 +08:00
Nash Aschenbath 716a3525c8 fix(daemon): return draft-aware outcomes without retry scheduling 2026-10-10 10:03:42 +08:00
Nash Aschenbath 7750077e15 fix(daemon): anchor draft checks to the current composer frame 2026-10-09 23:48:48 +08:00
Nash Aschenbath 5f658464c3 test(daemon): exercise persisted typing guards before compaction 2026-10-09 23:26:32 +08:00
Nash Aschenbath 6c70ac2f48 docs(cli): refresh typing guard command inventory 2026-10-09 22:55:21 +08:00
Nash Aschenbath 92a2b8f715 refactor(daemon): fold draft-aware delivery into typing guard 2026-10-09 22:53:27 +08:00
Nash Aschenbath 11cd9112c0 Merge remote-tracking branch 'refs/remotes/review/pr635' into refactor/typing-guard-modes-48 2026-10-09 20:39:55 +08:00
Nash Aschenbath e26f2ec1aa Merge branch 'fix/draft-delivery-boundaries-48' into refactor/typing-guard-modes-48
# Conflicts:
#	packages/daemon/test/composer-input.test.ts
#	packages/daemon/test/fixtures/draft-composer.mjs
2026-10-09 20:32:54 +08:00
Mr-Neutr0n 74d4876172 fix(transport): scope the collapsed-paste settling read to the seat runtime
startupOwnCollapsedPaste now takes the runtime and passes it to
composerRegion, and settleOwnPaste passes the seat's runtime, so an earlier
transcript line carrying another harness's glyph no longer reads a known
runtime's own collapsed paste as ambiguous. Unknown-runtime refusal is
unchanged.
2026-10-09 11:58:38 +00:00
Nash Aschenbath 941ee628b5 fix(daemon): bind draft exemptions to fresh panes and styled placeholders 2026-10-09 19:54:51 +08:00
Nash Aschenbath fcef3b7104 fix(daemon): handle native prompt spacing and optional verification 2026-10-09 19:33:54 +08:00
Nash Aschenbath a6af6ede4a fix(daemon): stop deferred nudges for inactive queue work 2026-10-09 18:49:42 +08:00
Nash Aschenbath 45f8c8f061 fix(daemon): preserve uncertainty after native input failures 2026-10-09 18:27:09 +08:00
Nash Aschenbath 81d5a51a55 feat(daemon): add per-seat draft-aware delivery 2026-10-09 18:01:28 +08:00
Harikrishna KP afab6befa2 fix(transport): see mixed glyphs across blank lines and recognize the » placeholder
Review round 3 on #635 (mvschwarz):

- composerGlyphKindsInBlock no longer stops at a blank line or a rule, so an
  unknown-runtime draft with an unrelated first line, a separator, and a
  '›'/'»' continuation holding the expected text still refuses. Tests cover
  blank-line and rule separators with both glyphs, in both head and
  full-match modes, with zero Enter calls.
- The Codex empty-composer placeholder now accepts '»' as well as '›', so a
  Codex 0.153 empty composer classifies as idle. Test added.
2026-10-06 17:12:46 +00:00
Harikrishna KP 86fe0a48e7 fix(transport): refuse mixed-glyph unknown-runtime blocks; pass runtime to mismatch diagnostics
Review round 2 on #635 (mvschwarz):

- Unknown-runtime composer reads keep the union but fail closed when the
  draft block holds more than one glyph kind: there is no runtime to say
  which line is the live input, so a '›'-leading continuation under an
  unrelated '❯' first line no longer passes the full-match check. Tests
  cover both the head and full-match paths plus a submit-only refusal
  with zero Enter calls.
- recordMismatch now passes the seat runtime to startupSubmissionEvidence
  so the refusal diagnostic is measured on the same line as the decision.
2026-10-06 17:12:46 +00:00
Harikrishna KP f15ad1c0d9 fix(transport): scope composer matchers by runtime and keep compact-option exclusion
Review round on #635 (mvschwarz):

- Choose the composer input line by runtime (Claude '❯', Codex '›»', union
  only when the runtime is unknown) so a Claude draft whose continuation
  line starts with a Codex glyph is not mistaken for a second composer.
  The send and startup paths know the runtime; the advisory classifier
  and the CLI detector keep the union.
- Restore the broad numbered-option prefix exclusion (number plus dot,
  space optional) in hasExpectedStagedText and the startup composer
  region, so a compact '\''❯ 1.Yes'\'' still refuses with no keys sent. A
  submit-only test locks zero Enter calls in both head and full-match
  modes, and a Claude continuation-line test locks the runtime scoping.
2026-10-06 17:12:46 +00:00
Harikrishna KP 4205323187 fix(transport): one shared composer prompt matcher set, incl. Codex »
The send-readiness classifier, the staged-input inspector, the startup
submission guard and the CLI's staged-at-prompt detector each carried
their own prompt-glyph patterns. Claude draws `❯`, Codex draws `›`, and
Codex 0.153 variants draw `»` (#79), which only some copies knew. The
send verify path and the staged-input check could therefore disagree
about what a composer looks like.

Move the glyphs and the shapes built from them (empty, draft, selection,
last-composer-line) into one domain module, exported through a narrow
daemon surface and used by every copy. A renderer prompt change now
lands once.

Tests pin `»` as idle, draft and selection evidence, staged-text
recognition for `❯`/`›`/`»`, the selection refusal, the startup body
reader, and the CLI staged report.

Signed-off-by: Mr-Neutr0n <[email protected]>
2026-10-06 17:11:46 +00:00
30 changed files with 2077 additions and 136 deletions

No files matched your search

@@ -96,7 +96,15 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
(`send`, `:1155`). With the seat's typing guard on, the message is held
instead of typed: the result is `outcome: "retained"` with HTTP 200
(`routes/transport.ts:115`); operators use `rig seat set-typing-guard` and
`rig seat held-messages`.
`rig seat held-messages`. This same typing guard has `off` (default),
`draft-aware`, and `hold` modes. The legacy enabled boolean selects hold/off.
`DraftAwareDelivery` makes one attempt for opted-in seats and immediately returns
the exact refusal reason and original held IDs when input is unavailable.
Migration `100_typing_guard_modes` extends existing guard/audit rows and adds
non-executable receipt metadata to the original outbox; it creates no tables
or retry scheduler. Combined wakes preserve every original ID/body.
Legacy storage bits fail safe to hold on rollback; newer legacy audit writes
supersede older saved modes on re-upgrade, including same-value writes.
3. Classify send readiness. Only a positive interactive-prompt reading
(`needs_input`) refuses, with `target_needs_input`
(`session-transport.ts:1482`), unless the caller passes
@@ -119,6 +127,21 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
without `-p` (`session-transport.ts:1541`) and Enter is pressed only if the
whole answer is still staged (`:1562`–`1570`). A successful paste proves
transport execution, not runtime consumption.
The transport's prompt reader uses the shared `composer-prompts.ts` matchers
from Mr-Neutr0n's #635. Its cursor-aware extension preserves capture styling
and excludes a wholly dim autocomplete suffix after the cursor. The daemon
directly depends on `string-width` to compare Unicode terminal cell widths
with cursor positions and exact staged text; a dependency hoisted from the
TUI would not declare this daemon runtime requirement. Default seats retain
their existing advisory recognition and staged-text verification behavior.
For draft-aware seats, `TmuxAdapter` checks the current composer after buffer
preparation and again immediately before keys. The delivery lease records
the pasted text and any observed collapsed-paste label; Enter requires that
owned input. Late human input, changed recipients or stopped queue wakes refuse
further input or hold the message; ambiguous writes remain indeterminate. Nothing
is retried automatically. Unicode cell widths use the daemon's direct
`string-width` dependency. See [per-seat terminal delivery](../../reference/seat-delivery.md)
for activation, persistence, inspection and lifecycle tradeoffs.
5. Optional `--verify`: capture the last 30 pane lines before the paste and,
500 ms after Enter, again, leaving out the current input box (the last
prompt line and its continuation, `verificationTranscript`). A send
+9 -6
View File
@@ -9,20 +9,20 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 92a2b8f7157661716a9c79de742cd82a96d1e6f7
last-updated: 2026-10-09
---
# CLI Reference — Registered Commands and Options
Verified against source commit `e8f0ab340db773392ec8be75b072d1c0f3068a50`.
Verified against source commit `92a2b8f7157661716a9c79de742cd82a96d1e6f7`.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
There are **87 top-level registrations**, **356 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,082 explicitly registered option objects**, including the root version
**1,084 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are source counts, not a claim about a deployed release.
@@ -396,7 +396,7 @@ the sections that were not read are `null`.
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json` |
@@ -869,7 +869,7 @@ name is not resolved within `<rig>`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig seat` | — | — |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>` **required**<br>`--reason <text>` **required**<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
| `rig seat status <seat>` | — | `--json` |
@@ -910,6 +910,9 @@ still appears as a `degraded` candidate and reports `rig_not_stopped` (exit 1)
when restore is attempted. Launch scheduling and restore eligibility remain
unchanged.
See [per-seat terminal delivery](../reference/seat-delivery.md) for
off, draft-aware and hold modes, immediate refusal results and held-message inspection.
### handover
| Invocation | Aliases | Declared options |
+4 -1
View File
@@ -903,7 +903,10 @@ after an authorized launch before claiming its actual permission behavior.
The rig-level verbs are `rig policy permissions list`, `show`, `current` and
`apply`. Existing `rig policy list/show/current/apply` remain compatibility
aliases with the same JSON and exit behavior. Pi resource trust and the per-seat
typing guard are separate controls.
typing guard are separate controls. To retain automatic messages while you have
an unfinished terminal input, opt that seat into [draft-aware delivery](seat-delivery.md).
The same typing guard offers `hold` for a manual terminal (`--enabled true`)
and `off` for ordinary automatic sends (`--enabled false`).
### Claude Code: a different launch flag
+127
View File
@@ -0,0 +1,127 @@
# Per-seat terminal delivery
The existing typing guard supports three modes. Before draft-aware mode,
automatic input either used the ordinary delivery path or was held unconditionally.
Draft-aware adds an opt-in check for unfinished input without a second control or
daemon-owned retry scheduler. Sibling seats keep their own mode.
| Mode | Terminal input | When input is unavailable |
|---|---|---|
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Immediately retain the original message with the exact refusal reason and ID |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw`, `--force` and `--dangerously-interact` do not bypass the typing guard.
Direct human input remains available.
## Configure and inspect
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
--reason "preserve my unfinished input"
rig seat status dev-impl@my-rig
rig seat held-messages dev-impl@my-rig --json
rig seat held-messages dev-impl@my-rig --id <outbox-id> --json
rig seat retire-held-message dev-impl@my-rig <outbox-id> --reason "read outside the pane"
~~~
Use exactly one of `--mode off|draft-aware|hold` or the compatible
`--enabled true|false`: true selects hold, false selects off. There are no
retry settings or deadlines. Activation waits for an already-started seat operation
to finish. The API may return HTTP 202 with `pending: true`; wait for
`pending: false` before relying on the requested mode. Status reports desired
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`,
`outboxIds` and its exact reason, such as `draft_input_busy` or
`draft_input_unknown`. No automatic retry is scheduled, including when the
caller requested wait-for-idle. The caller can inspect or route the retained work
and decide when to make a new request. Reusing the same delivery ID reads its
original result; it does not replay that message. Changing modes, clearing a
draft, or restarting the daemon also does not flush held records.
Reading never sends. Retirement frees that record's active retention quota while
preserving evidence; it does not deliver a message or close queue work.
## Input evidence and outcomes
The cursor-aware reader lives in `session-transport.ts` and shares the composer
matchers with ordinary delivery. It recognizes framed Claude input and Codex input
with a known footer. The visible input containing the cursor is checked independently
of activity hooks, again after buffer preparation, and immediately before Enter.
Copy mode, an unreadable capture and an unsupported layout cannot authorize input.
The final check preserves authored whitespace: the cursor distinguishes trailing
spaces from screen padding or omitted blank capture cells. A numbered message is
recognized as this operation's own text only within its previously empty composer;
a newly observed numbered menu is still refused. Human edits after paste stay
visible and Enter is refused.
Faint placeholders and autocomplete suffixes are display hints only when the
capture preserves their styling. Typing the same words and moving the cursor to
the start still counts as a draft. Current footer and indentation anchor the
Claude frame, so quoted prompt/frame examples inside a draft do not become proof
of empty input.
A large paste can appear as several labels plus a literal tail. The existing
`hasExpectedStagedText` check validates the tail and the labels' combined source
boundary. That complete rendering must be observed after this operation's paste
and remain unchanged before Enter. Labels alone do not prove arbitrary content.
| Delivery state | Meaning |
|---|---|
| `sending` | The original IDs have an unresolved write attempt |
| `held` | No input was written; the original message remains retained or has been retired |
| `complete` | Transport execution succeeded, including render verification if requested; native consumption is not asserted |
| `indeterminate` | Input may have occurred or requested render verification failed; no automatic replay |
Migration `100_typing_guard_modes` extends the existing guard/audit rows and
adds receipt metadata to the original outbox rows; it creates no tables, retry
indexes or executable requests. Combined wakes retain each original ID and body.
A held queue wake stays on the existing queue recovery path. Interrupted writes
become indeterminate at startup regardless of which recovery component runs first.
The existing retention limits apply: 100 records and 8 MiB per seat, with a 1 MiB
message limit. New admissions refuse at capacity; already-committed queue intent
remains durable even if concurrent activation exceeds the limit.
## Rollback and lifecycle
Persisted legacy guard bits are on for both hold and draft-aware, so an older
daemon holds messages instead of typing into a protected seat. On re-upgrade,
the most recent guard audit row and change timestamp determine whether the saved
mode is still authoritative. A later boolean write by the older daemon takes
precedence, including a same-value on-to-on write in the same clock tick.
Without a later setting change, the draft-aware mode survives re-upgrade.
Held and uncertain deliveries are never replayed by this reconciliation.
Current API `desired` and `effective` booleans continue to describe hold-all;
mode fields describe draft-aware protection. Storage's fail-safe legacy bits
are deliberately broader.
With hold, writing lifecycle operations refuse before effects. With draft-aware,
an active seat requires an explicit switch to off before writing lifecycle work.
Fresh startup is exempt only after creating a new pane or successfully respawning
a dead pane within the same lifecycle operation. Adopting or rebinding an existing
pane grants no such exemption.
Terminal capture is observational, not an atomic editor API. A direct tmux writer
can still act between the last observation and paste. The resulting changed input
refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
Regression tests exercise rollback/re-upgrade, same-value legacy writes, exact
retained outcomes, restart without replay, unchanged IDs/bodies, trailing spaces,
numbered bodies, real-menu refusals and multi-placeholder ownership. Native tests
use private real tmux panes with scripted editable composers. These verify the
transport mechanics; they are not live Claude/Codex provider sessions.
+1
View File
@@ -7111,6 +7111,7 @@
"better-sqlite3": "^13.0.3",
"hono": "^4.7.0",
"smol-toml": "^1.8.0",
"string-width": "^8.3.0",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3"
+35 -16
View File
@@ -24,7 +24,8 @@ interface SeatStatusResponse {
pod_namespace: string | null;
runtime: string | null;
current_occupant: string | null;
typingGuard?: { desired: boolean; effective: boolean; pending: boolean; heldCount: number };
typingGuard?: { desired: boolean; effective: boolean; pending: boolean; heldCount: number;
desiredMode?: string; effectiveMode?: string; };
permissions?: {
selectionState: "explicit" | "inherit" | "unknown";
desired: { mode: string } | null;
@@ -161,8 +162,10 @@ function printHuman(status: SeatStatusResponse): void {
console.log(`Current occupant: ${display(status.current_occupant)}`);
if (status.typingGuard) {
const g = status.typingGuard;
console.log(`Typing guard: ${g.effective ? "on" : "off"}${g.pending ? ` (activation pending; requested ${g.desired ? "on" : "off"})` : ""}; ${g.heldCount} retained`);
console.log("Automatic input pauses while on, including writing lifecycle. Disabling does not replay held messages.");
const mode = g.effectiveMode ?? (g.effective ? "on" : "off");
console.log(`Typing guard: ${mode === "hold" ? "on (hold)" : mode}${g.pending ? ` (activation pending; requested ${g.desiredMode ?? (g.desired ? "on" : "off")})` : ""}; ${g.heldCount} retained`);
if (mode === "draft-aware") console.log(`Draft-aware sends return immediately; inspect held messages with rig seat held-messages ${status.seat_ref}.`);
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
}
console.log(`Session: ${display(status.session_status, "unknown")}`);
const reason = sessionReason(status.session_reason);
@@ -296,19 +299,23 @@ export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promis
console.log(JSON.stringify(result.data, null, json ? undefined : 2));
if (result.status >= 400) process.exitCode = result.status >= 500 ? 2 : 1;
};
cmd.command("set-typing-guard").argument("<seat>").requiredOption("--enabled <boolean>", "true pauses all automatic terminal input; false permits new sends")
.requiredOption("--reason <text>").option("--json").description("Protect this seat's draft by retaining automatic delivery, even at an empty prompt")
cmd.command("set-typing-guard").argument("<seat>")
.option("--enabled <boolean>", "Compatibility: true selects hold; false selects off")
.option("--mode <mode>", "off, draft-aware or hold; choose this or --enabled")
.requiredOption("--reason <text>").option("--json").description("Choose how this seat's typing guard handles automatic input")
.addHelpText("after", `
This persistent, per-seat preference defaults off. On pauses ALL automatic terminal
input, even at an empty prompt; it is not a typing detector or permission mode.
Messages and wakes are retained in the existing outbox. Writing lifecycle operations
refuse before effects; raw/force/submit-only options do not bypass protection.
Direct human terminal input remains available. Other seats keep their own settings.
This one persistent guard defaults off. Hold pauses ALL automatic input, including
at an empty prompt; --enabled true keeps that existing behavior. Draft-aware holds
when the current input contains a draft or cannot be read. It returns the exact
reason and held message ID immediately, without automatic retry.
Off (--enabled false) permits new automatic sends.
Messages use the existing outbox. Uncertain writes never replay automatically.
Direct human input remains available. Other seats keep their own guard mode.
Activation can be pending while an already-started operation finishes. Read
rig seat status <seat> and wait for effective=true before relying on protection.
rig seat status <seat> and wait for pending=false before relying on the requested mode.
Inspect retained bodies with rig seat held-messages <seat> (or --id <id>).
Turning the guard off permits NEW sends; it never flushes or retries held messages.
Changing modes never flushes held messages.
Retire a reviewed record with rig seat retire-held-message <seat> <id> --reason <text>.
Retirement preserves evidence and frees quota; it does not deliver or close work.
@@ -316,11 +323,23 @@ Active retention defaults: 100 records / 8 MiB per seat, 1 MiB per message.
New admissions refuse at capacity. Already-committed queue intent is preserved even
when a concurrent activation exceeds that cap; inspect and retire it explicitly.
Protection covers OpenRig's managed input paths, not external tmux tools or another
process writing directly to the terminal. Disable deliberately before lifecycle work.
process writing directly to the terminal. Select off before writing lifecycle work
on an active pane; freshly created or respawned lifecycle shells have separate proof.
Examples:
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware --reason "preserve my draft"
rig seat set-typing-guard dev-impl@my-rig --enabled true --reason "manual terminal"
`)
.action(async (seat: string, opts: { enabled: string; reason: string; json?: boolean }) => {
if (opts.enabled !== "true" && opts.enabled !== "false") { console.error("--enabled must be true or false"); process.exitCode = 1; return; }
await guardRequest("POST", `/api/seat/set-typing-guard/${encodeURIComponent(seat)}`, { enabled: opts.enabled === "true", reason: opts.reason }, opts.json);
.action(async (seat: string, opts: { enabled?: string; mode?: string; reason: string; json?: boolean }) => {
if ((opts.enabled === undefined) === (opts.mode === undefined)
|| opts.enabled !== undefined && !["true", "false"].includes(opts.enabled)
|| opts.mode !== undefined && !["off", "draft-aware", "hold"].includes(opts.mode)) {
console.error("Choose --enabled true|false or --mode off|draft-aware|hold.");
process.exitCode = 1; return;
}
await guardRequest("POST", `/api/seat/set-typing-guard/${encodeURIComponent(seat)}`, {
...(opts.mode === undefined ? { enabled: opts.enabled === "true" } : { mode: opts.mode }), reason: opts.reason,
}, opts.json);
});
cmd.command("held-messages").argument("<seat>").option("--limit <n>", "Page size", "100").option("--offset <n>", "Page offset", "0").option("--id <id>", "Read one retained or retired record by ID").option("--json")
.description("Read retained messages outside the protected terminal; reading never delivers them")
+55
View File
@@ -93,6 +93,61 @@ const STATUS = {
restore_outcome: "n-a",
};
describe("seat typing guard commands", () => {
it("keeps the legacy on prefix when status reports hold", async () => {
const paths: string[] = [];
const deps = makeDeps({ status: 200, data: { ...STATUS, typingGuard: { desired: true, effective: true, effectiveMode: "hold", pending: false, heldCount: 0 } } }, paths);
const status = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "status", "worker@test"]).then(() => undefined));
expect(status.logs.join("\n")).toContain("Typing guard: on (hold)");
});
it("sends draft-aware mode through the existing control", async () => {
const paths: string[] = [], bodies: unknown[] = [];
const deps = makeDeps({ status: 200, data: { effectiveMode: "draft-aware", pending: false } }, paths, bodies);
const result = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "set-typing-guard", "worker@test",
"--mode", "draft-aware", "--reason", "keep my draft", "--json"]).then(() => undefined));
expect(result.exitCode).toBeUndefined();
expect(paths).toEqual(["/api/seat/set-typing-guard/worker%40test"]);
expect(bodies).toEqual([{ mode: "draft-aware", reason: "keep my draft" }]);
expect(JSON.parse(result.logs.join(""))).toMatchObject({ effectiveMode: "draft-aware" });
});
it.each([["--mode", "typo"]])("rejects invalid %s %s before requesting a change", async (flag, value) => {
const paths: string[] = [];
const deps = makeDeps({ status: 200, data: {} }, paths);
const result = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "set-typing-guard", "worker@test",
"--mode", "draft-aware", "--reason", "draft", flag, value]).then(() => undefined));
expect(result.exitCode).toBe(1); expect(paths).toEqual([]);
});
it.each(["true", "false"])("keeps --enabled %s compatible with the existing endpoint", async enabled => {
const paths: string[] = [], bodies: unknown[] = [];
const deps = makeDeps({ status: 200, data: {} }, paths, bodies);
await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "set-typing-guard", "worker@test",
"--enabled", enabled, "--reason", "existing workflow"]).then(() => undefined));
expect(paths).toEqual(["/api/seat/set-typing-guard/worker%40test"]);
expect(bodies).toEqual([{ enabled: enabled === "true", reason: "existing workflow" }]);
});
it.each([{ selectors: [] }, { selectors: ["--mode", "hold", "--enabled", "true"] }])("requires exactly one selector: $selectors", async ({ selectors }) => {
const paths: string[] = [];
const deps = makeDeps({ status: 200, data: {} }, paths);
const result = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "set-typing-guard", "worker@test",
"--reason", "draft", ...selectors]).then(() => undefined));
expect(result.exitCode).toBe(1); expect(paths).toEqual([]);
});
it("prints requested/effective activation and immediate send behavior in seat status", async () => {
const paths: string[] = [];
const typingGuard = { desired: true, effective: false, desiredMode: "hold", effectiveMode: "draft-aware", pending: true, heldCount: 1 };
const deps = makeDeps({ status: 200, data: { ...STATUS, typingGuard } }, paths);
const status = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "status", "worker@test"]).then(() => undefined));
expect(status.logs.join("\n")).toContain("Typing guard: draft-aware (activation pending; requested hold)");
expect(status.logs.join("\n")).toContain("Draft-aware sends return immediately");
expect(paths.at(-1)).toBe("/api/seat/status/worker%40test");
});
});
const HANDOVER_PLAN = {
ok: true,
dryRun: true,
@@ -513,10 +513,13 @@ version, from checking the environment to a useful report to the OpenRig team.
permission mode for its future managed launches; it relaunches nothing, and `inherit` clears the
selection. `rig seat status` reports the selection, but a status read does not prove the
running process enforces it.
- **`rig seat set-typing-guard <seat> --enabled true|false --reason <text>`** — protect a human's
draft in that seat: automatic input is retained instead of typed while the guard is on.
- **`rig seat set-typing-guard <seat> --mode off|draft-aware|hold --reason <text>`** — choose
ordinary automatic delivery, immediate retention when input is busy/unreadable, or holding all automatic
input. `--enabled true|false` remains compatible: true selects hold, false selects off.
`rig seat status` shows the requested/effective mode; wait for pending activation to finish.
**`rig seat held-messages`** lists what was retained and **`rig seat retire-held-message`**
retires one; disabling the guard does not replay held messages.
retires one; changing mode does not replay old held messages. Draft-aware returns the exact
refusal and original ID immediately; callers decide further action, with no daemon retry timer.
- **`rig compact-plan`** → **`rig compact`** — who is near the context wall, then act on it.
**Ordering matters: running `compact` without the plan is guessing which seat needed it.**
Managed compaction waits for the seat's restore map before compacting; `rig compact --state`,
+1
View File
@@ -97,6 +97,7 @@
"better-sqlite3": "^13.0.3",
"hono": "^4.7.0",
"smol-toml": "^1.8.0",
"string-width": "^8.3.0",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3"
+75 -14
View File
@@ -3,6 +3,8 @@ import { writeFile as fsWriteFile, unlink as fsUnlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join as pathJoin } from "node:path";
import { randomUUID } from "node:crypto";
import type { ComposerSnapshot } from "../domain/composer-prompts.js";
import { inspectComposerInput } from "../domain/session-transport.js";
import { parentSessionEnvKeys } from "../domain/parent-session-env.js";
import type { TmuxControlExec } from "./tmux-control.js";
@@ -341,7 +343,19 @@ function parseLines<T>(output: string, parser: (line: string) => T | null): T[]
}
export class TmuxAdapter {
deliveryGuard?: SeatDeliveryGuard;
private inputGuard?: SeatDeliveryGuard;
get deliveryGuard(): SeatDeliveryGuard | undefined { return this.inputGuard; }
set deliveryGuard(guard: SeatDeliveryGuard | undefined) {
this.inputGuard = guard;
guard?.attachInputInspection(async (target, owned) => inspectComposerInput(await this.captureComposerSnapshot(target.pane ?? target.session), null, owned), async target => {
if (!target.pane) return true;
const probe = await this.probeSession(target.session);
if (probe.state === "absent") return true;
if (probe.state !== "present") return false;
const panes = await this.listPanes(target.session);
return panes.length === 1 && panes[0]!.id === target.pane && await this.isPaneDead(target.pane);
});
}
private readonly freshProbes = new Map<string, string>();
private readonly freshManaged = new Map<string, {nodeId: string; pane: string}>();
@@ -394,7 +408,7 @@ export class TmuxAdapter {
}
}
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false): Promise<TmuxResult> {
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
const guard = this.deliveryGuard;
if (!guard) return write(target, () => {});
try {
@@ -433,6 +447,12 @@ export class TmuxAdapter {
}
const pane = fresh ? created.pane : bound.pane;
if (!pane || panes.length !== 1 || panes[0]!.id !== pane) throw new Error("Managed pane identity unavailable or changed; no input written.");
if (inputKind === "control" && guard.needsInputCheck(identity)) await guard.beforeInput(identity);
if (inputKind === "respawn" && guard.needsInputCheck(identity)) {
if (!guard.ownsLifecycle(bound.nodeId) || !await this.isPaneDead(pane)) {
throw new DeliveryGuardError("draft_aware_lifecycle", "Only a dead pane in its lifecycle operation may be respawned under draft-aware protection.");
}
}
// Revalidate registry/occupant after the asynchronous observation. Write
// to the immutable pane ID, not a session name which could be recycled.
return guard.input(identity, () => write(pane, () => guard.checkInput(identity)));
@@ -587,7 +607,10 @@ export class TmuxAdapter {
if (result.ok && this.deliveryGuard && env?.OPENRIG_NODE_ID) {
try {
const panes = await this.listPanes(name);
if (panes.length === 1) this.freshManaged.set(name, {nodeId: env.OPENRIG_NODE_ID, pane: panes[0]!.id});
if (panes.length === 1) {
this.deliveryGuard.noteFreshLifecyclePane(env.OPENRIG_NODE_ID, panes[0]!.id);
this.freshManaged.set(name, {nodeId: env.OPENRIG_NODE_ID, pane: panes[0]!.id});
}
} catch { /* no fresh pane proof: subsequent writes remain refused */ }
}
return result;
@@ -671,10 +694,19 @@ export class TmuxAdapter {
* seats from colliding.
*/
async sendText(target: string, text: string, beforeInput?: () => void, options?: { bracketed?: boolean }): Promise<TmuxResult> {
return this.guardedInput(target, (pane, beforeWrite) => this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed));
return this.guardedInput(target, (pane, beforeWrite) => {
const guard = this.deliveryGuard;
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const policy = guard?.needsInputCheck(identity) ? {
beforePaste: () => guard.beforeInput(identity),
afterPaste: () => guard.notePasted(identity, text),
} : undefined;
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
}, false, "text");
}
private async sendTextUnchecked(target: string, text: string, beforeWrite: () => void, bracketed = true): Promise<TmuxResult> {
private async sendTextUnchecked(target: string, text: string, beforeWrite: () => void, bracketed = true,
policy?: { beforePaste: () => Promise<void>; afterPaste: () => Promise<void> }): Promise<TmuxResult> {
const path = this.fileOps.tmpName();
const buffer = this.fileOps.bufferName();
let bufferLoaded = false;
@@ -685,12 +717,14 @@ export class TmuxAdapter {
await this.run(["tmux", "load-buffer", "-b", buffer, path],
`tmux load-buffer -b ${shellQuote(buffer)} ${shellQuote(path)}`);
bufferLoaded = true;
if (policy) await policy.beforePaste();
beforeWrite();
// Explicit prompt answers need key input, not bracketed-paste framing.
// Keep their bytes in the file: tmux command parsing and argv limits must
// not alter semicolons or reject long answers (#519, #602).
await this.run(["tmux", "paste-buffer", "-t", target, "-b", buffer, "-d", "-r", ...(bracketed ? ["-p"] : [])],
`tmux paste-buffer -t ${shellQuote(target)} -b ${shellQuote(buffer)} -d -r${bracketed ? " -p" : ""}`);
if (policy) await policy.afterPaste();
return { ok: true };
} catch (err) {
if (bufferLoaded) {
@@ -763,11 +797,9 @@ export class TmuxAdapter {
await this.fileOps.writeFile(path, `/bin/rm -f -- ${shellQuote(path)} 2>/dev/null || command rm -f -- ${shellQuote(path)} || true\n${options.execInScript ? "exec " : ""}${command}\n`, { mode: 0o600, flag: "wx" });
created = true;
}
const text = beforeInput ? await this.guardedInput(target, (pane, check) => this.sendTextUnchecked(pane, invocation, () => { check(); beforeInput(); }))
: await this.sendText(target, invocation);
const text = await this.sendText(target, invocation, beforeInput);
if (!text.ok) return text;
const enter = beforeInput ? await this.guardedInput(target, pane => { beforeInput(); return this.sendKeysUnchecked(pane, ["Enter"]); })
: await this.sendKeys(target, ["Enter"]);
const enter = await this.sendKeys(target, ["Enter"], beforeInput);
if (!enter.ok) {
await this.sendKeys(target, ["C-c"]);
return enter;
@@ -785,7 +817,15 @@ export class TmuxAdapter {
}
async sendKeys(target: string, keys: string[], beforeInput?: () => void): Promise<TmuxResult> {
return this.guardedInput(target, (pane, beforeWrite) => { beforeWrite(); beforeInput?.(); return this.sendKeysUnchecked(pane, keys); });
return this.guardedInput(target, async (pane, beforeWrite) => {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const submit = keys.some(key => key === "Enter" || key === "C-m");
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
beforeWrite(); beforeInput?.();
const result = await this.sendKeysUnchecked(pane, keys);
if (result.ok && submit) this.deliveryGuard?.noteSubmitted(identity);
return result;
}, false, "keys");
}
private async sendKeysUnchecked(target: string, keys: string[]): Promise<TmuxResult> {
@@ -889,7 +929,14 @@ export class TmuxAdapter {
command?: string,
opts?: { cwd?: string; env?: Record<string, string> },
): Promise<TmuxResult> {
return this.guardedInput(paneTarget, pane => this.respawnPaneUnchecked(pane, command, opts));
return this.guardedInput(paneTarget, async (pane, beforeWrite) => {
beforeWrite();
const result = await this.respawnPaneUnchecked(pane, command, opts);
const guard = this.deliveryGuard;
const target = guard?.maybeTarget(pane);
if (result.ok && target && guard!.ownsLifecycle(target.nodeId)) guard!.noteFreshLifecyclePane(target.nodeId, pane);
return result;
}, false, "respawn");
}
private async respawnPaneUnchecked(paneTarget: string, command?: string, opts?: { cwd?: string; env?: Record<string, string> }): Promise<TmuxResult> {
@@ -1303,17 +1350,31 @@ export class TmuxAdapter {
* screen, NOT `-S -<lines>` scrollback: scrollback reintroduces the row drift
* the absolute-paint seed exists to eliminate.
*/
async capturePaneScreen(paneId: string): Promise<string | null> {
async capturePaneScreen(paneId: string, escapeSequences = false): Promise<string | null> {
const target = exactTarget(paneId, "pane");
try {
const output = await this.run(["tmux", "capture-pane", "-p", "-t", target],
`tmux capture-pane -p -t ${shellQuote(target)}`);
const output = await this.run(["tmux", "capture-pane", "-p", ...(escapeSequences ? ["-e"] : []), "-t", target],
`tmux capture-pane -p${escapeSequences ? " -e" : ""} -t ${shellQuote(target)}`);
return output || null;
} catch {
return null;
}
}
/** Cursor movement during capture is an unknown input, never permission to type. */
async captureComposerSnapshot(paneId: string): Promise<ComposerSnapshot | null> {
try {
const before = await this.getPaneCursorPosition(paneId);
if (!before) return null;
const screen = await this.capturePaneScreen(paneId, true);
if (screen === null) return null;
const mode = (await this.run(["tmux", "display-message", "-p", "-t", exactTarget(paneId, "pane"), "#{pane_in_mode}"])).trim();
const after = await this.getPaneCursorPosition(paneId);
if (!after || JSON.stringify(before) !== JSON.stringify(after) || !/^\d+$/.test(mode)) return null;
return { screen, cursor: after, inMode: Number(mode) !== 0 };
} catch { return null; }
}
/**
* Get the current cursor coordinates and pane geometry. Coordinates are
* zero-based. Returns null if unavailable or if tmux yields non-finite /
+2
View File
@@ -105,6 +105,7 @@ import { rigInstallRootSchema } from "./migrations/096_rig_install_root.js";
import { threadPartMapSchema } from "./migrations/097_thread_part_map.js";
import { queueTransitionsQitemIdOrderSchema } from "./migrations/098_queue_transitions_qitem_id_order.js";
import { queueItemsRecoveryTagsSchema } from "./migrations/099_queue_items_recovery_tags.js";
import { typingGuardModesSchema } from "./migrations/100_typing_guard_modes.js";
import { resumeRotationSchema } from "./migrations/101_claude_resume_rotation.js";
import { sessionStopIntentSchema } from "./migrations/103_session_stop_intent.js";
import { usageSamplesClientCostSchema } from "./migrations/105_usage_samples_client_cost.js";
@@ -217,6 +218,7 @@ export const ALL_MIGRATIONS: Migration[] = [
threadPartMapSchema,
queueTransitionsQitemIdOrderSchema,
queueItemsRecoveryTagsSchema,
typingGuardModesSchema,
resumeRotationSchema,
sessionStopIntentSchema,
nodeEffortChoiceSchema,
@@ -0,0 +1,11 @@
import type { Migration } from "../migrate.js";
export const typingGuardModesSchema: Migration = {
name: "100_typing_guard_modes.sql",
sql: `
ALTER TABLE seat_delivery_guards ADD COLUMN desired_config TEXT;
ALTER TABLE seat_delivery_guards ADD COLUMN effective_config TEXT;
ALTER TABLE seat_delivery_guard_changes ADD COLUMN desired_config TEXT;
ALTER TABLE outbox_entries ADD COLUMN guard_delivery TEXT;
`,
};
@@ -1,3 +1,5 @@
import stringWidth from "string-width";
/**
* One place for the composer/prompt glyphs and the shapes built from them.
*
@@ -86,6 +88,17 @@ export function stripComposerPromptGlyph(line: string, promptClass: string = COM
return line.replace(promptPattern(promptClass, ""), "");
}
/** Read the prompt once for both advisory draft recognition and cursor-bound
* input checks. Keep the one prompt-space separate from authored whitespace.
*/
export function readComposerPromptLine(line: string, promptClass: string = COMPOSER_PROMPT_CLASS) {
const trimmed = line.trimStart();
const body = stripComposerPromptGlyph(trimmed, promptClass);
if (body === trimmed || (body !== "" && !/^\s/.test(body))) return null;
const indent = line.slice(0, line.length - trimmed.length);
return { indent, glyph: trimmed[0]!, text: body.slice(1), prefix: indent.length + 2 };
}
/**
* Distinct prompt glyphs in the capture up to and including `inputIndex`.
* The walk does not stop at a blank line or a rule: a multi-line draft can
@@ -111,3 +124,61 @@ export function composerPromptIsAmbiguous(lines: string[], inputIndex: number, p
if (promptClass !== COMPOSER_PROMPT_CLASS) return false;
return composerGlyphKindsInBlock(lines, inputIndex).size > 1;
}
export interface ComposerSnapshot {
screen: string;
cursor: { x: number; y: number; width: number; height: number };
inMode: boolean;
}
export interface ComposerOwnership { text: string; frame: string }
export interface ComposerInput {
state: "empty" | "text" | "unknown";
rows: string[];
columns: number;
frame?: string;
pasteMatchesExpected?: boolean;
cursorAtEnd: boolean;
collapsedPaste: string | null;
/** A proven display-only suffix, so readiness can reuse the same input read. */
ghost?: { line: number; offset: number; continuationRows: number };
}
export const COMPOSER_COLLAPSED_PASTE_PATTERN = /^\[Pasted text #\d+ \+(\d+) lines\]$/;
/** Preserve characters and spaces within rows. Only a real line break, a full
* terminal row, or a word that cannot fit may explain a rendered row boundary.
*/
export function composerContainsOwnedText(input: ComposerInput, expected: string): boolean {
if (input.state !== "text" || !input.cursorAtEnd || input.collapsedPaste || !input.rows.length) return false;
const text = expected.replace(/\r\n/g, "\n");
let offsets = new Set([0]);
for (let index = 0; index < input.rows.length; index++) {
const row = input.rows[index]!;
const next = new Set<number>();
for (const offset of offsets) {
if (!text.startsWith(row, offset)) continue;
const end = offset + row.length;
if (index === input.rows.length - 1) { if (end === text.length) return true; continue; }
if (text[end] === "\n") next.add(end + 1);
if (stringWidth(row) >= input.columns) next.add(end);
if (text[end] === " ") {
const word = /^\S+/.exec(text.slice(end + 1))?.[0] ?? "";
if (word && stringWidth(row + " " + word) > input.columns) next.add(end + 1);
}
}
offsets = next;
if (!offsets.size) return false;
}
return false;
}
/** An opaque label may be remembered only immediately after this lease pasted
* the text. Subsequent submission requires that same complete label and cursor.
*/
export function ownCollapsedPaste(input: ComposerInput, expected: string): string | null {
if (input.collapsedPaste && input.cursorAtEnd && input.pasteMatchesExpected) return input.collapsedPaste;
const match = input.collapsedPaste && input.cursorAtEnd ? COMPOSER_COLLAPSED_PASTE_PATTERN.exec(input.collapsedPaste) : null;
return match && Number(match[1]) === expected.split("\n").length - 1 ? input.collapsedPaste : null;
}
@@ -0,0 +1,148 @@
import type Database from "better-sqlite3";
import { createHash } from "node:crypto";
import { OutboxHandler, type OutboxEntry } from "./outbox-handler.js";
import { DeliveryGuardError, type SeatDeliveryGuard } from "./seat-delivery-guard.js";
import type { SendOpts, SendResult } from "./session-transport.js";
export interface GuardedDeliverySummary {
id: string;
state: "sending" | "held" | "complete" | "indeterminate";
reason: string;
}
/** Only receipt identity and the observed outcome live beside each original row.
* No executable options, timer, deadline or request queue survive a send. */
interface Receipt { ids: string[]; textHash: string; result?: SendResult }
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
type Deliver = (session: string, text: string, opts: SendOpts) => Promise<SendResult>;
export class DraftAwareDelivery {
private readonly outbox: OutboxHandler;
private readonly sends = new Set<Promise<SendResult>>();
private wakeApplicable?: (entries: readonly OutboxEntry[]) => boolean;
constructor(private readonly db: Database.Database, private readonly guard: SeatDeliveryGuard, private readonly deliver: Deliver) {
this.outbox = new OutboxHandler(db);
}
configureWakes(applicable: (entries: readonly OutboxEntry[]) => boolean): void { this.wakeApplicable = applicable; }
private receipt(id: string): Receipt | null {
const row = this.db.prepare("SELECT guard_delivery FROM outbox_entries WHERE outbox_id=?").get(id) as { guard_delivery: string | null } | undefined;
return row?.guard_delivery ? JSON.parse(row.guard_delivery) as Receipt : null;
}
lookup(id: string): GuardedDeliverySummary | null {
const receipt = this.receipt(id);
if (!receipt) return null;
const entry = this.outbox.getById(id)!;
const state = entry.deliveryState === "retained" || entry.deliveryState === "retired" ? "held"
: entry.deliveryState === "delivered" ? "complete" : entry.deliveryState === "sending" ? "sending" : "indeterminate";
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason
?? (state === "sending" ? "delivery_in_progress" : state === "complete" ? "delivered" : "interrupted_write");
return { id: receipt.ids[0]!, state, reason };
}
private result(id: string, receipt: Receipt): SendResult {
const entry = this.outbox.getById(id)!;
const delivery = this.lookup(id)!;
if (receipt.result && entry.deliveryState !== "retired") return { ...receipt.result, outboxIds: receipt.ids, delivery };
if (delivery.state === "held") return { ok: true, sessionName: entry.destinationSession, sent: false, verified: false,
outcome: "retained", reason: delivery.reason, outboxIds: receipt.ids, delivery };
return { ok: false, sessionName: entry.destinationSession, outcome: "failed", outboxIds: receipt.ids, delivery,
reason: delivery.state === "sending" ? "delivery_in_progress" : "delivery_indeterminate",
error: "This delivery has an unresolved write attempt. Inspect its existing ID; it is not replayed." };
}
existing(session: string, text: string, opts: SendOpts, ids: string[]): SendResult | null {
const receipt = this.receipt(ids[0]!);
if (!receipt) return null;
const entry = this.outbox.getById(ids[0]!)!;
if (receipt.textHash !== hash(text) || JSON.stringify(receipt.ids) !== JSON.stringify(ids)
|| entry.destinationSession !== session || entry.senderSession !== (opts.actorSession ?? "unknown")) {
throw new DeliveryGuardError("delivery_identity_conflict", "This delivery ID already names different content or identity.");
}
return this.result(ids[0]!, receipt);
}
/** Called inside SessionTransport's existing per-seat lease. Try exactly once. */
async send(session: string, text: string, opts: SendOpts, ids: string[]): Promise<SendResult> {
const prior = this.existing(session, text, opts, ids);
if (prior) return prior;
if (!ids.length || new Set(ids).size !== ids.length) throw new DeliveryGuardError("invalid_delivery_ids", "Distinct delivery IDs are required.");
const target = this.guard.target(session);
const receipt: Receipt = { ids, textHash: hash(text) };
this.db.transaction(() => {
for (const id of ids) {
const entry = opts.committedOutboxIds ? this.outbox.getById(id) : null;
if (opts.committedOutboxIds && !entry) throw new DeliveryGuardError("outbox_not_found", "A committed wake is missing; no replacement message was created.");
this.outbox.retain(entry ? { ...entry, tags: entry.tags ?? undefined, auditPointer: entry.auditPointer ?? undefined }
: { outboxId: id, senderSession: opts.actorSession ?? "unknown", destinationSession: session, body: text,
auditPointer: opts.auditPointer, identityProvenance: opts.identityProvenance }, target, !!opts.committedOutboxIds);
const claimed = this.db.prepare("UPDATE outbox_entries SET delivery_state='sending',guard_delivery=? WHERE outbox_id=? AND delivery_state='retained' AND guard_delivery IS NULL")
.run(JSON.stringify(receipt), id);
if (claimed.changes !== 1) throw new DeliveryGuardError("delivery_already_attempted", "This original delivery cannot be attempted again.");
}
})();
const pending = this.attempt(session, text, opts, receipt);
this.sends.add(pending);
try { return await pending; }
finally { this.sends.delete(pending); }
}
private async attempt(session: string, text: string, opts: SendOpts, receipt: Receipt): Promise<SendResult> {
let result: SendResult;
let inputWritten = false;
const entries = receipt.ids.map(id => this.outbox.getById(id)!);
try {
// Draft-aware is an immediate decision, including when a caller requested wait-for-idle.
await this.guard.beforeInput(session);
result = await this.deliver(session, text, { ...opts,
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
beforeWrite: () => {
if (opts.queueWake && !this.wakeApplicable?.(entries)) throw new DeliveryGuardError("draft_wake_superseded", "The queue wake is no longer applicable; no further input was written.");
opts.beforeWrite?.();
},
});
} catch (error) {
const refusal = error instanceof DeliveryGuardError && error.code.startsWith("draft_");
result = { ok: false, sessionName: session, outcome: "failed",
...(refusal || inputWritten ? { sent: inputWritten } : {}), reason: refusal ? error.code : "delivery_indeterminate",
error: refusal ? error.message : "The delivery attempt ended without a reliable write result. It is not replayed." };
}
if (inputWritten) result = { ...result, sent: true };
const preInputRefusal = ["draft_input_busy", "draft_input_unknown", "draft_input_changed", "draft_wake_superseded",
"target_needs_input", "wait_for_idle_timeout", "target_activity_unknown", "target_runtime_unverified", "target_runtime_not_running", "target_runtime_conflict",
"invalid_wait_for_idle", "invalid_dangerously_interact", "dangerously_interact_requires_reason", "prompt_override_audit_unavailable",
"transport_unavailable", "session_missing", "tmux_unavailable"].includes(result.reason ?? "");
const noWrite = !result.ok && !inputWritten && preInputRefusal;
if (noWrite) result = { ...result, ok: true, sent: false, verified: false, outcome: "retained",
warning: "Held, not delivered. No automatic retry is scheduled. Inspect the original ID with rig seat held-messages." };
else if (!inputWritten && result.sent === false) { const { sent: _sent, ...uncertain } = result; result = uncertain; }
const state = noWrite ? "retained" : result.ok && (result.verified || !opts.verify) ? "delivered" : "indeterminate";
try {
this.db.transaction(() => {
for (const id of receipt.ids) {
this.outbox.finalizeDelivery(id, state);
this.db.prepare("UPDATE outbox_entries SET guard_delivery=? WHERE outbox_id=?").run(JSON.stringify({ ...receipt, result }), id);
}
})();
} catch {
// Never turn a lost ledger acknowledgement into permission to replay a write.
try { this.db.transaction(() => receipt.ids.forEach(id => this.outbox.finalizeDelivery(id, "indeterminate")))(); } catch { /* Startup recovery owns abandoned claims. */ }
}
try { return this.result(receipt.ids[0]!, this.receipt(receipt.ids[0]!)!); }
catch {
return { ok: false, sessionName: session, outcome: "failed", reason: "delivery_indeterminate", outboxIds: receipt.ids,
delivery: { id: receipt.ids[0]!, state: "indeterminate", reason: "receipt_unavailable" },
error: "The final delivery receipt could not be read. Inspect the original IDs; no replay is authorized." };
}
}
/** Startup reconciles uncertain writes only; held messages stay held. */
recover(): void {
this.db.prepare("UPDATE outbox_entries SET delivery_state='indeterminate' WHERE guard_delivery IS NOT NULL AND delivery_state='sending'").run();
}
async stop(): Promise<void> { await Promise.allSettled([...this.sends]); }
}
+46 -29
View File
@@ -6,7 +6,7 @@ import { loadHumanRegistry, resolveRegisteredHumanAddress, type LoadResult } fro
import { resolveExternal } from "./gateway/external-admission.js";
import type { PersistedEvent } from "./types.js";
import { QueueTransitionLog, type OwnerNotificationLevel, type QueueTransition, type RecentQueueTransitionScope } from "./queue-transition-log.js";
import { WAKE_INTENT_PREFIX, type OutboxHandler } from "./outbox-handler.js";
import { WAKE_INTENT_PREFIX, type OutboxHandler, type OutboxEntry } from "./outbox-handler.js";
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
import { lastMeaningfulTransition, readWaitingView, type WaitingView, type WaitingActivityReader } from "./queue-waiting.js";
import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
@@ -271,13 +271,15 @@ interface QueueItemRow {
export interface QueueNudgeTransport {
deliveryTarget?(session: string): import("./seat-delivery-guard.js").GuardTarget | null;
retentionTarget?(session: string): import("./seat-delivery-guard.js").GuardTarget | null;
draftAwareTarget?(session: string): import("./seat-delivery-guard.js").GuardTarget | null;
configureGuardedWakes?(applicable: (entries: readonly OutboxEntry[]) => boolean): void;
send(
sessionName: string,
// (h): stampISO threads the nudge's compose time so the transport's delivered-latency calc can
// measure the wait for a handoff nudge too (the real impl is SessionTransport, which accepts it).
text: string,
opts?: { verify?: boolean; stampISO?: string; actorSession?: string; committedOutboxIds?: string[]; deliveryId?: string; auditPointer?: string }
): Promise<{ ok: boolean; verified?: boolean; error?: string; reason?: string; outcome?: string }>;
opts?: { verify?: boolean; stampISO?: string; actorSession?: string; committedOutboxIds?: string[]; deliveryId?: string; auditPointer?: string; queueWake?: boolean }
): Promise<{ ok: boolean; verified?: boolean; error?: string; reason?: string; outcome?: string; delivery?: { state: string } }>;
}
export interface QueueCreateInput {
@@ -863,7 +865,7 @@ export class QueueRepository {
this.wakeRepo = new QueueWakeRepository(db);
this.validateRig = opts?.validateRig ?? (() => true);
this.destinationAdvisory = opts?.destinationAdvisory ?? (() => null);
this.transport = opts?.transport;
if (opts?.transport) this.attachTransport(opts.transport);
this.workflowFrontierPredicate = opts?.workflowFrontierPredicate;
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
this.loadHumanRegistryFn = opts?.loadHumanRegistry ?? (() => loadHumanRegistry());
@@ -933,6 +935,38 @@ export class QueueRepository {
*/
attachTransport(transport: QueueNudgeTransport): void {
this.transport = transport;
transport.configureGuardedWakes?.(entries => entries.every(entry => {
const row = entry.auditPointer ? this.getById(entry.auditPointer) : null;
if (!row || row.destinationSession !== entry.destinationSession) return false;
return entry.outboxId.startsWith(WAKE_INTENT_PREFIX) ? this.currentWakeIntent(entry) : isBlockerLive(row.state);
}));
}
private currentWakeIntent(entry: OutboxEntry): boolean {
const row = entry.auditPointer ? this.db.prepare("SELECT state FROM queue_items WHERE qitem_id=?").get(entry.auditPointer) as { state: string } | undefined : undefined;
if (row?.state !== "pending") return false;
const prefix = `${WAKE_INTENT_PREFIX}blocker-`;
if (!entry.outboxId.startsWith(prefix)) return true;
const expected = Number(entry.outboxId.slice(prefix.length));
const latest = this.db.prepare(`SELECT transition_id FROM (
SELECT transition_id, state, LAG(state) OVER (ORDER BY transition_id) AS previous_state
FROM queue_transitions WHERE qitem_id=?)
WHERE state='pending' AND previous_state IS NOT 'pending' ORDER BY transition_id DESC LIMIT 1`)
.get(entry.auditPointer) as { transition_id: number } | undefined;
return expected === latest?.transition_id;
}
private classifyWakeResult(res: Awaited<ReturnType<QueueNudgeTransport["send"]>>): {
classified: "verified" | "indeterminate" | "failed" | "retained"; nudgeResult: string;
} {
if (res.outcome === "retained") return { classified: "retained", nudgeResult: res.reason?.startsWith("draft_")
? "retained:draft_aware" : "retained:typing_guard" };
if (res.ok) return res.verified ? { classified: "verified", nudgeResult: "verified" } : { classified: "indeterminate", nudgeResult: "delivered-ack-pending" };
const detail = res.error ?? res.reason ?? "unknown";
if (res.delivery?.state === "indeterminate" || res.delivery?.state === "sending" || isWakeTimeoutSignal(res.reason) || isWakeTimeoutSignal(res.error)) {
return { classified: "indeterminate", nudgeResult: `indeterminate:${detail}` };
}
return { classified: "failed", nudgeResult: `failed:${detail}` };
}
/**
@@ -1056,6 +1090,10 @@ export class QueueRepository {
const target = this.transport?.retentionTarget?.(input.toSession);
if (target) this.outbox.retain(record, target);
else {
// Draft-aware wakes remain pending until the transport owns their bounded
// retry. Manual retention is deliberately never drained.
const draftTarget = this.transport?.draftAwareTarget?.(input.toSession);
if (draftTarget) this.outbox.assertRetentionCapacity(draftTarget.nodeId, frozenEnvelope);
this.outbox.record(record);
const binding = this.transport?.deliveryTarget?.(input.toSession);
if (binding) this.db.prepare("UPDATE outbox_entries SET guard_binding=? WHERE outbox_id=? AND guard_binding IS NULL")
@@ -1163,18 +1201,7 @@ export class QueueRepository {
// original frozen intent and audit pointer survives; only transport coalesces.
let superseded = false;
const actionable = (entry: import("./outbox-handler.js").OutboxEntry): boolean => {
const row = entry.auditPointer ? this.db.prepare("SELECT state FROM queue_items WHERE qitem_id = ?").get(entry.auditPointer) as { state: string } | undefined : undefined;
let current = row?.state === "pending";
const resumePrefix = `${WAKE_INTENT_PREFIX}blocker-`;
if (current && entry.outboxId.startsWith(resumePrefix)) {
const expected = Number(entry.outboxId.slice(resumePrefix.length));
const latest = this.db.prepare(`SELECT transition_id FROM (
SELECT transition_id, state, LAG(state) OVER (ORDER BY transition_id) AS previous_state
FROM queue_transitions WHERE qitem_id = ?)
WHERE state = 'pending' AND previous_state IS NOT 'pending' ORDER BY transition_id DESC LIMIT 1`)
.get(entry.auditPointer) as { transition_id: number } | undefined;
current = expected === latest?.transition_id;
}
const current = this.currentWakeIntent(entry);
if (!current) {
// Existing failed state means the requested old delivery was refused;
// the explicit tag distinguishes supersession from a transport attempt.
@@ -1464,12 +1491,12 @@ export class QueueRepository {
}
const deliveryId = `guard-nudge-${qitemId}-${createHash("sha256").update(JSON.stringify([sourceSession, destinationSession, bodyOverride ?? null])).digest("hex")}`;
const held = !committedOutboxIds ? this.outbox?.getById(deliveryId) : null;
if (held?.deliveryState === "retained" || held?.deliveryState === "retired") {
if (held?.guardBinding) {
// Same logical nudge reuses its original frozen envelope, not a new timestamp.
text = held.body;
}
try {
const res = await this.transport!.send(destinationSession, text, { verify: true, stampISO, actorSession: sourceSession, committedOutboxIds, deliveryId: committedOutboxIds ? undefined : deliveryId, auditPointer: qitemId });
const res = await this.transport!.send(destinationSession, text, { verify: true, stampISO, actorSession: sourceSession, committedOutboxIds, deliveryId: committedOutboxIds ? undefined : deliveryId, auditPointer: qitemId, queueWake: true });
// OPR.0.3.2.21.FR-4(c) — wording rename: the prior literal
// "sent-unverified" read as a failure even in the common case
// (delivery confirmed but the synchronous ack window expired,
@@ -1477,21 +1504,11 @@ export class QueueRepository {
// "delivered-ack-pending" reads as healthy. The old "verified"
// case is unchanged for backward-compat with any tooling that
// already consumed the positive literal.
if (res.outcome === "retained") return { classified: "retained", nudgeResult: "retained:typing_guard" };
if (res.ok) {
return res.verified
? { classified: "verified", nudgeResult: "verified" }
: { classified: "indeterminate", nudgeResult: "delivered-ack-pending" };
}
// MF6: a TIMEOUT is ambiguous — the send may have landed but the ack window
// expired — so it records `indeterminate` (never silently delivered, never a
// hard `failed`). A definite non-timeout failure (unreachable, unknown
// session) stays `failed`.
const detail = res.error ?? res.reason ?? "unknown";
if (isWakeTimeoutSignal(res.reason) || isWakeTimeoutSignal(res.error)) {
return { classified: "indeterminate", nudgeResult: `indeterminate:${detail}` };
}
return { classified: "failed", nudgeResult: `failed:${detail}` };
return this.classifyWakeResult(res);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// A thrown timeout is equally ambiguous (see above).
+190 -22
View File
@@ -1,5 +1,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
/** A binding is captured before waiting. Never rebind an old operation to a new occupant. */
export interface GuardTarget {
@@ -15,6 +17,11 @@ interface Lease {
serialOwner?: Lease;
origin: "automatic" | "human" | "custody";
lifecycle?: boolean;
freshLifecyclePane?: string;
config?: TypingGuardConfig;
humanEpoch: number;
inputFrame?: string;
staged?: { text: string; collapsedPaste: string | null };
}
export class DeliveryGuardError extends Error {
@@ -31,6 +38,31 @@ export interface GuardPreference {
desired: boolean;
effective: boolean;
pending: boolean;
desiredMode: TypingGuardMode;
effectiveMode: TypingGuardMode;
}
export const TYPING_GUARD_MODES = ["off", "draft-aware", "hold"] as const;
export type TypingGuardMode = (typeof TYPING_GUARD_MODES)[number];
export interface TypingGuardSettings { mode: TypingGuardMode }
export interface TypingGuardConfig extends TypingGuardSettings { revision: string }
const DEFAULT_GUARD: TypingGuardConfig = { mode: "off", revision: "legacy-off" };
export function parseTypingGuardSettings(value: unknown): TypingGuardSettings {
const input = typeof value === "boolean" ? { mode: value ? "hold" : "off" } : value;
if (!input || typeof input !== "object" || Array.isArray(input)) throw new DeliveryGuardError("invalid_typing_guard", "Supply an enabled boolean or a typing-guard mode.");
const config = input as Record<string, unknown>;
if (!(TYPING_GUARD_MODES as readonly unknown[]).includes(config.mode)) throw new DeliveryGuardError("invalid_typing_guard", "mode must be off, draft-aware or hold.");
if (config.holdSeconds !== undefined || config.maxAttempts !== undefined) throw new DeliveryGuardError("invalid_typing_guard", "Draft-aware sends return their result immediately; retry settings are not supported.");
return { mode: config.mode as TypingGuardMode };
}
function storedGuard(raw: string | null | undefined, enabled: number): TypingGuardConfig {
if (!raw) return { ...DEFAULT_GUARD, mode: enabled ? "hold" : "off", revision: enabled ? "legacy-hold" : "legacy-off" };
const value = JSON.parse(raw) as Record<string, unknown>;
const settings = parseTypingGuardSettings({ mode: value.mode });
if (typeof value.revision !== "string" || !value.revision) throw new DeliveryGuardError("invalid_typing_guard", "Stored guard configuration has no revision.");
return { ...settings, revision: value.revision };
}
/** One serialization domain for preference activation, delivery and writing lifecycle.
@@ -41,26 +73,63 @@ export class SeatDeliveryGuard {
private readonly tails = new Map<string, Promise<void>>();
private readonly scope = new AsyncLocalStorage<Map<string, Lease>>();
private readonly humanLeases = new Set<Lease>();
private readonly humanEpochs = new Map<string, number>();
private inspectInput?: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>;
private inputAbsent?: (target: GuardTarget) => Promise<boolean>;
private readonly hasModeConfig: boolean;
constructor(
readonly db: Database.Database,
private readonly resolve: (target: string) => GuardTarget | null,
) {}
) {
this.hasModeConfig = (db.prepare("PRAGMA table_info(seat_delivery_guards)").all() as Array<{ name: string }>).some(c => c.name === "desired_config");
}
attachInputInspection(inspect: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>, absent: (target: GuardTarget) => Promise<boolean>): void {
this.inspectInput = inspect;
this.inputAbsent = absent;
}
/** Startup-only, before exposing routes or starting writers. A stopped operation cannot
* retain an in-memory lease. Persisted desired protection applies at the new boundary. */
recoverActivation(): void {
this.db.transaction(() => {
if (this.hasModeConfig) {
// Old daemons append an audit row even for a same-value 1 -> 1 write.
// That newer boolean decision supersedes a previously saved mode.
this.db.prepare(`UPDATE seat_delivery_guards AS g SET desired_config=NULL,effective_config=NULL
WHERE desired_config IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM seat_delivery_guard_changes c WHERE c.id=(SELECT max(id) FROM seat_delivery_guard_changes WHERE node_id=g.node_id)
AND c.desired_config=g.desired_config AND c.requested_at=g.changed_at)`).run();
this.db.prepare(`UPDATE seat_delivery_guards SET desired=CASE WHEN json_extract(desired_config,'$.mode')='off' THEN 0 ELSE 1 END
WHERE desired_config IS NOT NULL`).run();
}
this.db.prepare("UPDATE seat_delivery_guards SET effective = desired WHERE effective != desired").run();
this.db.prepare("UPDATE seat_delivery_guard_changes SET effective_at = ? WHERE effective_at IS NULL")
.run(new Date().toISOString());
if (this.hasModeConfig) {
this.db.prepare("UPDATE seat_delivery_guards SET effective_config=desired_config WHERE desired_config IS NOT NULL").run();
}
})();
}
preference(nodeId: string): GuardPreference {
const row = this.db.prepare("SELECT desired, effective FROM seat_delivery_guards WHERE node_id = ?")
.get(nodeId) as { desired: number; effective: number } | undefined;
return { nodeId, desired: !!row?.desired, effective: !!row?.effective, pending: row !== undefined && row.desired !== row.effective };
const { desired, effective } = this.configuration(nodeId);
return { nodeId, desired: desired.mode === "hold", effective: effective.mode === "hold", pending: desired.revision !== effective.revision,
desiredMode: desired.mode, effectiveMode: effective.mode };
}
configuration(nodeId: string): { desired: TypingGuardConfig; effective: TypingGuardConfig } {
const row = this.db.prepare("SELECT * FROM seat_delivery_guards WHERE node_id=?").get(nodeId) as
{ desired: number; effective: number; changed_at: string; desired_config?: string | null; effective_config?: string | null } | undefined;
if (row?.desired_config && this.hasModeConfig) {
const latest = this.db.prepare("SELECT desired_config,requested_at FROM seat_delivery_guard_changes WHERE node_id=? ORDER BY id DESC LIMIT 1").get(nodeId) as
{ desired_config: string | null; requested_at: string } | undefined;
if (latest?.desired_config !== row.desired_config || latest.requested_at !== row.changed_at) {
return { desired: storedGuard(null, row.desired), effective: storedGuard(null, row.effective) };
}
}
return { desired: storedGuard(row?.desired_config, row?.desired ?? 0), effective: storedGuard(row?.effective_config, row?.effective ?? 0) };
}
maybeTarget(name: string): GuardTarget | null { return this.resolve(name); }
@@ -86,22 +155,40 @@ export class SeatDeliveryGuard {
finally { release(); if (this.tails.get(nodeId) === tail) this.tails.delete(nodeId); }
}
async set(nodeId: string, enabled: boolean, actor: string, reason: string, timeoutMs = 2000): Promise<GuardPreference> {
async set(nodeId: string, value: unknown, actor: string, reason: string, timeoutMs = 2000): Promise<GuardPreference> {
if (!actor.trim() || !reason.trim()) throw new DeliveryGuardError("guard_reason_required", "Actor and reason are required.");
const at = new Date().toISOString();
const change = this.db.transaction(() => {
const old = this.preference(nodeId);
this.db.prepare(`INSERT INTO seat_delivery_guards(node_id, desired, effective, actor, reason, changed_at)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(node_id) DO UPDATE SET
desired=excluded.desired, actor=excluded.actor, reason=excluded.reason, changed_at=excluded.changed_at`)
.run(nodeId, Number(enabled), Number(old.effective), actor, reason, at);
return this.db.prepare(`INSERT INTO seat_delivery_guard_changes(node_id, desired, previous_desired, previous_effective, actor, reason, requested_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(nodeId, Number(enabled), Number(old.desired), Number(old.effective), actor, reason, at).lastInsertRowid;
const { change, desired } = this.db.transaction(() => {
const old = this.configuration(nodeId);
const settings = parseTypingGuardSettings(value);
if (!this.hasModeConfig && settings.mode === "draft-aware") throw new DeliveryGuardError("typing_guard_unavailable", "Draft-aware guard configuration requires the mode schema.");
const unchanged = settings.mode === old.desired.mode;
const desired = { ...settings, revision: unchanged ? old.desired.revision : randomUUID() };
// The legacy bits deliberately fail safe to hold when a daemon without modes opens this DB.
const args = [nodeId, Number(desired.mode !== "off"), Number(old.effective.mode !== "off"), actor, reason, at];
if (this.hasModeConfig) {
this.db.prepare(`INSERT INTO seat_delivery_guards(node_id,desired,effective,actor,reason,changed_at,desired_config,effective_config)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(node_id) DO UPDATE SET desired=excluded.desired,actor=excluded.actor,
reason=excluded.reason,changed_at=excluded.changed_at,desired_config=excluded.desired_config`)
.run(...args, JSON.stringify(desired), JSON.stringify(old.effective));
} else {
this.db.prepare(`INSERT INTO seat_delivery_guards(node_id,desired,effective,actor,reason,changed_at)
VALUES (?,?,?,?,?,?) ON CONFLICT(node_id) DO UPDATE SET desired=excluded.desired,actor=excluded.actor,
reason=excluded.reason,changed_at=excluded.changed_at`).run(...args);
}
const audit = [nodeId, Number(desired.mode !== "off"), Number(old.desired.mode !== "off"), Number(old.effective.mode !== "off"), actor, reason, at];
const change = this.hasModeConfig
? this.db.prepare(`INSERT INTO seat_delivery_guard_changes(node_id,desired,previous_desired,previous_effective,actor,reason,requested_at,desired_config)
VALUES (?,?,?,?,?,?,?,?)`).run(...audit, JSON.stringify(desired)).lastInsertRowid
: this.db.prepare(`INSERT INTO seat_delivery_guard_changes(node_id,desired,previous_desired,previous_effective,actor,reason,requested_at)
VALUES (?,?,?,?,?,?,?)`).run(...audit).lastInsertRowid;
return { change, desired };
})();
const activation = this.serial(nodeId, async () => {
this.db.transaction(() => {
// Later requests are serialized too. Apply each accepted transition, in order.
this.db.prepare("UPDATE seat_delivery_guards SET effective = ? WHERE node_id = ?").run(Number(enabled), nodeId);
this.db.prepare("UPDATE seat_delivery_guards SET effective = ? WHERE node_id = ?").run(Number(desired.mode !== "off"), nodeId);
if (this.hasModeConfig) this.db.prepare("UPDATE seat_delivery_guards SET effective_config=? WHERE node_id=?").run(JSON.stringify(desired), nodeId);
this.db.prepare("UPDATE seat_delivery_guard_changes SET effective_at = ? WHERE id = ?")
.run(new Date().toISOString(), change);
})();
@@ -125,12 +212,13 @@ export class SeatDeliveryGuard {
return this.serial(bound.nodeId, async () => {
const current = this.target(name);
if (!this.same(bound, current)) throw new DeliveryGuardError("guard_target_changed", "Input target changed while waiting; no input written.");
const pref = this.preference(bound.nodeId);
if (pref.effective || pref.desired) {
const config = this.configuration(bound.nodeId);
if (config.desired.mode === "hold" || config.effective.mode === "hold") {
if (held) return held(bound);
throw new DeliveryGuardError("typing_guard_enabled", "Automatic input is paused for this seat. Disable its typing guard explicitly before this writing operation.");
throw new DeliveryGuardError("typing_guard_enabled", "Automatic input is paused for this seat. Select off or draft-aware mode explicitly before this writing operation.");
}
const lease: Lease = { target: bound, active: true, origin: "automatic" };
const lease: Lease = { target: bound, active: true, origin: "automatic", humanEpoch: this.humanEpochs.get(bound.nodeId) ?? 0,
config: config.desired.mode === "draft-aware" ? config.desired : config.effective };
lease.serialOwner = lease;
try { return await this.scope.run(new Map([...(this.scope.getStore() ?? []), [lease.target.nodeId, lease]]), fn); }
finally { lease.active = false; }
@@ -177,7 +265,7 @@ export class SeatDeliveryGuard {
if (!target) { for (const captured of targets) validate(captured); return fn(); }
const inherited = this.scope.getStore()?.get(target.nodeId);
const run = async () => {
const lease: Lease = { target, active: true, origin: "custody" };
const lease: Lease = { target, active: true, origin: "custody", humanEpoch: this.humanEpochs.get(target.nodeId) ?? 0 };
lease.serialOwner = inherited?.active && inherited.serialOwner?.active ? inherited.serialOwner : lease;
try {
return await this.scope.run(new Map([...(this.scope.getStore() ?? []), [target.nodeId, lease]]), () => acquire(index + 1));
@@ -204,6 +292,13 @@ export class SeatDeliveryGuard {
if (this.ownsLifecycle(id)) return acquire(index + 1);
return this.operation(id, async () => {
const lease = this.scope.getStore()!.get(id)!;
if (lease.config?.mode === "draft-aware") {
// Only a positively absent input may receive fresh startup/resume writes.
// An active protected seat requires an explicit guard mode change first.
const absent = !lease.target.pane || await this.inputAbsent?.(lease.target) === true;
this.assertCurrent(id, lease);
if (!absent) throw new DeliveryGuardError("draft_aware_lifecycle", "This active seat uses draft-aware delivery. Select off mode before a writing lifecycle operation; held messages are not replayed.");
}
lease.lifecycle = true;
return acquire(index + 1);
});
@@ -217,6 +312,21 @@ export class SeatDeliveryGuard {
const lease = this.scope.getStore()?.get(nodeId);
if (!lease?.active || !lease.lifecycle) throw new DeliveryGuardError("guard_lease_required", "Binding changes require the complete lifecycle lease.");
lease.target = this.target(nodeId);
delete lease.staged;
delete lease.inputFrame;
}
/** Only a successful adapter creation/respawn may exempt its new shell from composer checks. */
noteFreshLifecyclePane(nodeId: string, pane: string): void {
const lease = this.scope.getStore()?.get(nodeId);
if (!lease?.active || !lease.lifecycle) throw new DeliveryGuardError("guard_lease_required", "A fresh pane requires its creation lifecycle lease.");
this.assertCurrent(nodeId, lease);
lease.freshLifecyclePane = pane;
}
private ownsFreshInput(lease: Lease): boolean {
return !!lease.lifecycle && !!lease.freshLifecyclePane
&& (lease.target.pane === null || lease.target.pane === lease.freshLifecyclePane);
}
private assertCurrent(name: string, lease: Lease): void {
@@ -234,6 +344,62 @@ export class SeatDeliveryGuard {
if (lease.origin === "custody") {
throw new DeliveryGuardError("guard_lease_required", "Custody cleanup does not authorize input or writing lifecycle operations.");
}
if (lease.origin === "automatic" && !this.ownsFreshInput(lease) && lease.config?.mode === "draft-aware"
&& lease.humanEpoch !== (this.humanEpochs.get(target.nodeId) ?? 0)) {
throw new DeliveryGuardError("draft_input_changed", "Human input arrived during this delivery. No further automatic input was written.");
}
}
needsInputCheck(name: string): boolean {
const target = this.maybeTarget(name);
if (!target) return false; // Proven private probes have no managed-seat policy.
const lease = this.scope.getStore()?.get(target.nodeId);
return !!lease?.active && lease.origin === "automatic" && !this.ownsFreshInput(lease) && lease.config?.mode === "draft-aware";
}
/** Called at the paste/key boundary after file and buffer preparation. */
async beforeInput(name: string, submit = false): Promise<void> {
if (!this.needsInputCheck(name)) return;
const target = this.target(name);
const lease = this.scope.getStore()!.get(target.nodeId)!;
const owned = submit && lease.staged && lease.inputFrame ? { text: lease.staged.text, frame: lease.inputFrame } : undefined;
const input = await this.inspectInput?.(target, owned).catch(() => null) ?? null;
this.checkInput(name);
if (!input || input.state === "unknown") throw new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.");
if (submit) {
const owned = lease.staged && (composerContainsOwnedText(input, lease.staged.text)
|| !!lease.staged.collapsedPaste && input.cursorAtEnd && input.collapsedPaste === lease.staged.collapsedPaste);
if (!owned) throw new DeliveryGuardError("draft_input_changed", "The input no longer contains only this delivery's staged text. Enter was not sent.");
} else if (input.state !== "empty") {
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
} else {
lease.inputFrame = input.frame;
}
}
expectStagedInput(name: string, text: string): void {
if (!this.needsInputCheck(name)) return;
const target = this.target(name);
this.scope.getStore()!.get(target.nodeId)!.staged = { text, collapsedPaste: null };
}
async notePasted(name: string, text: string): Promise<void> {
try {
if (!this.needsInputCheck(name)) return;
const target = this.target(name);
const lease = this.scope.getStore()!.get(target.nodeId)!;
lease.staged = { text, collapsedPaste: null };
const input = await this.inspectInput?.(target, lease.inputFrame ? { text, frame: lease.inputFrame } : undefined).catch(() => null) ?? null;
if (input) lease.staged.collapsedPaste = ownCollapsedPaste(input, text);
} catch { /* Observation cannot turn a completed paste into a claimed no-write failure. */ }
}
noteSubmitted(name: string): void {
try {
const target = this.maybeTarget(name);
const lease = target && this.scope.getStore()?.get(target.nodeId);
if (lease) lease.staged = undefined;
} catch { /* Enter already succeeded. */ }
}
/** Reconciliation is a synchronous DB transaction, not a nested input operation.
@@ -246,8 +412,8 @@ export class SeatDeliveryGuard {
if (this.tails.has(expected.nodeId) || [...this.humanLeases].some(l => l.active && l.target.nodeId === expected.nodeId)) {
throw new DeliveryGuardError("guard_operation_in_progress", "Seat operation in progress; reconciliation did not change custody. Retry after it finishes.");
}
const pref = this.preference(expected.nodeId);
if (pref.desired || pref.effective) {
const config = this.configuration(expected.nodeId);
if (config.desired.mode !== "off" || config.effective.mode !== "off") {
throw new DeliveryGuardError("typing_guard_enabled", "Reconciliation cannot replace the occupant while typing protection is enabled.");
}
return commit();
@@ -263,8 +429,10 @@ export class SeatDeliveryGuard {
/** Internal broker path only; never an option accepted by the send HTTP route. */
async humanInput<T>(name: string, fn: () => Promise<T>): Promise<T> {
const target = this.target(name);
const humanEpoch = (this.humanEpochs.get(target.nodeId) ?? 0) + 1;
this.humanEpochs.set(target.nodeId, humanEpoch);
const inherited = this.scope.getStore()?.get(target.nodeId);
const lease: Lease = { target, active: true, serialOwner: inherited?.active ? inherited.serialOwner : undefined, origin: "human" };
const lease: Lease = { target, active: true, serialOwner: inherited?.active ? inherited.serialOwner : undefined, origin: "human", humanEpoch };
this.humanLeases.add(lease);
try { return await this.scope.run(new Map([...(this.scope.getStore() ?? []), [lease.target.nodeId, lease]]), fn); }
finally { lease.active = false; this.humanLeases.delete(lease); }
+229 -16
View File
@@ -1,7 +1,9 @@
import stringWidth from "string-width";
import { CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
import { randomUUID } from "node:crypto";
import { COLLAPSED_PASTE, composerRegion, inspectStartupStagedText, QUEUED_PLACEHOLDER, startupSubmissionEvidence, type StartupSubmissionEvidence } from "./startup-submission-evidence.js";
import { COLLAPSED_PASTE, composerRegion, isComposerFooter, inspectStartupStagedText, QUEUED_PLACEHOLDER, startupSubmissionEvidence, type StartupSubmissionEvidence } from "./startup-submission-evidence.js";
import {
composerContainsOwnedText,
composerNumberedChoicePattern,
composerPromptClassForRuntime,
composerPromptIsAmbiguous,
@@ -14,9 +16,15 @@ import {
COMPOSER_SELECTION_SCAN_PATTERN,
findComposerInputLineIndex,
stripComposerPromptGlyph,
readComposerPromptLine,
COMPOSER_COLLAPSED_PASTE_PATTERN,
type ComposerInput,
type ComposerSnapshot,
} from "./composer-prompts.js";
export { inspectStartupStagedText } from "./startup-submission-evidence.js";
import { OutboxHandler } from "./outbox-handler.js";
import { DraftAwareDelivery, type GuardedDeliverySummary } from "./draft-aware-delivery.js";
import type { OutboxEntry } from "./outbox-handler.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import { claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
@@ -185,6 +193,16 @@ const CLAUDE_TIMERLESS_STATUS_PATTERN = /^[✶✢✳✻✽·*]\s+\S.*(?:…|\.{3
export interface ClassifyPaneOptions {
/** Treat a timer-less live status row above the composer as work in progress. */
timerlessStatusIsLive?: boolean;
/** Opted-in typing guards retain styling and cursor evidence for this read. */
composerSnapshot?: ComposerSnapshot;
runtime?: string | null;
}
/** The current Claude footer follows the input frame; earlier examples are history or draft text. */
function findClaudeFooterIndex(lines: string[]): number {
let bar = lines.length - 1;
while (bar >= 0 && (!lines[bar]!.trim() || CLAUDE_STATUS_WARNINGS.some(pattern => pattern.test(lines[bar]!.trim())))) bar--;
return bar;
}
function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions = {}) {
@@ -193,8 +211,7 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
// Exhausting the scan without reaching the status head is unknown, not idle.
const lines = paneContent.split("\n").slice(-20)
.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
let bar = lines.length - 1;
while (bar >= 0 && CLAUDE_STATUS_WARNINGS.some((pattern) => pattern.test(lines[bar]!.trim()))) bar--;
const bar = findClaudeFooterIndex(lines);
const supportedWarningFooter = lines[bar]?.trim() === "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
const modeFooter = CLAUDE_MODE_FOOTER.test(lines[bar]?.trim() ?? "");
let indent = /^([ \t]*)─{3,}$/.exec(lines[bar - 1] ?? "")?.[1];
@@ -206,13 +223,14 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
// Keep Codex footers and a bare prompt with no visible block on their old path.
if (bar < 2 || !CLAUDE_ANCHORED_MODE_FOOTER.test(lines[bar]!.trim())) return null;
prompt = lines[bar - 1] ?? "";
indent = /^([ \t]*)❯\s*$/.exec(prompt)?.[1];
const input = readComposerPromptLine(prompt, composerPromptClassForRuntime("claude-code"));
indent = input && !input.text.trim() ? input.indent : undefined;
if (indent === undefined) return null;
statusStart = bar - 2;
} else {
const upper = lines[bar - 3] ?? "";
if (!upper.startsWith(indent) || !/^─{3,}(?: .+ ─+)?$/.test(upper.slice(indent.length)) ||
!prompt.startsWith(`${indent}❯`) || !/^❯(?:\s|$)/.test(prompt.slice(indent.length))) return null;
!prompt.startsWith(`${indent}❯`) || !readComposerPromptLine(prompt, composerPromptClassForRuntime("claude-code"))) return null;
}
let liveStatus: string | null = null;
@@ -235,6 +253,139 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
return { text: prompt.slice(indent.length), bar: lines[bar]!.trim(), framed, hasWarnings: bar < lines.length - 1, supportedWarningFooter, modeFooter, headSeen, liveStatus };
}
const unknown = (): ComposerInput => ({ state: "unknown", rows: [], columns: 0, cursorAtEnd: false, collapsedPaste: null });
const rule = (line: string): boolean => /^[─━═-]{6,}(?: .+ [─━═-]+)?$/.test(line.trim());
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
for (let i = 0; i < codes.length; i++) {
const code = codes[i]!;
if (/^(?:38|48|58):/.test(code)) continue; // Colon-form color is one parameter.
if (code === "38" || code === "48" || code === "58") {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
i += skip;
} else if (code === "" || code === "0" || code === "22") faint = false;
else if (code === "2") faint = true;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells };
});
}
/** Inspect only the visible input containing the current cursor. Activity, hook
* freshness and text elsewhere in the pane are not evidence that input is empty.
*/
export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime: string | null = null, owned?: import("./composer-prompts.js").ComposerOwnership): ComposerInput {
const promptClass = composerPromptClassForRuntime(runtime);
if (!snapshot || snapshot.inMode) return unknown();
const { x, y, width, height } = snapshot.cursor;
if (![x, y, width, height].every(Number.isInteger) || x < 0 || y < 0 || x >= width || y >= height || width < 3 || height < 1) return unknown();
const styled = styledTextLines(snapshot.screen);
const lines = styled.map(line => line.text);
if (lines.at(-1) === "") lines.pop();
if (lines.length > height || !lines[y]) return unknown();
const footerAt = findClaudeFooterIndex(lines);
const footer = lines[footerAt]?.trim() ?? "";
let claudeEnd = footerAt - 1;
while (claudeEnd >= 0 && !lines[claudeEnd]!.trim()) claudeEnd--;
const candidates: ComposerInput[] = [];
for (let start = 0; start <= y; start++) {
const prompt = readComposerPromptLine(lines[start] ?? "", promptClass);
if (!prompt || !/^ *$/.test(prompt.indent)) continue;
const numbered = composerSelectionPrefixPattern(promptClass).test(lines[start]!.trimStart()) || /^\d+[.)]\s/.test(prompt.text);
const prefix = prompt.prefix;
let end = -1;
if (prompt.glyph === "❯") {
// Share the main reader's terminal footer anchor. A quoted frame within a
// multiline draft cannot supply either the closing rule or its indentation.
const alignedRule = (line: string) => rule(line) && line.length - line.trimStart().length === prompt.indent.length;
if ((!isComposerFooter(footer) && !CLAUDE_MODE_FOOTER.test(footer))
|| !alignedRule(lines[start - 1] ?? "") || !alignedRule(lines[claudeEnd] ?? "")) continue;
end = claudeEnd;
} else {
for (let i = start + 1; i < lines.length; i++) {
if (lines[i]!.trim()) continue;
const tail = lines.slice(i + 1).filter(line => line.trim());
if (lines[i + 1]?.trim() && tail.length === 1 && codexFooter(tail[0]!.trim())) { end = i; break; }
}
}
if (end <= y || x < prefix) continue;
const rows = [prompt.text];
let valid = true;
for (let i = start + 1; i < end; i++) {
const line = lines[i]!;
if (line.trim() && !line.startsWith(" ".repeat(prefix))) { valid = false; break; }
rows.push(line.slice(prefix));
}
if (!valid) continue;
// A suggestion is a wholly dim suffix beginning at the current cursor.
// A literal placeholder or text before a moved cursor remains authored input.
const cursorRow = y - start;
let ghost: ComposerInput["ghost"];
const currentRow = rows[cursorRow]!;
let cursorOffset = x === prefix ? 0 : -1;
let offset = 0;
for (const character of currentRow) {
offset += character.length;
if (stringWidth(currentRow.slice(0, offset)) === x - prefix) cursorOffset = offset;
}
if (cursorOffset >= 0) {
const suffix = rows.slice(cursorRow).map((row, i) => ({
text: row.slice(i === 0 ? cursorOffset : 0),
faint: styled[y + i]!.faintCells.slice(prefix + (i === 0 ? cursorOffset : 0), prefix + row.length),
}));
if (suffix.some(row => row.text.length > 0) && suffix.every(row => row.faint.every(Boolean))) {
ghost = { line: y, offset: prefix + cursorOffset, continuationRows: rows.length - cursorRow - 1 };
rows.splice(cursorRow + 1);
rows[cursorRow] = currentRow.slice(0, cursorOffset);
}
}
const visible = rows.map(line => line.trimEnd());
// The cursor bounds authored whitespace on the last input row. Captures may
// omit trailing blank cells or include screen padding; neither changes the bytes to submit.
if (cursorRow === rows.length - 1) {
const cellWidth = stringWidth(rows[cursorRow]!);
if (x - prefix >= cellWidth) visible[cursorRow] = rows[cursorRow]! + " ".repeat(x - prefix - cellWidth);
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
const empty = atStart && rows.length === 1 && rows[0] === "";
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
const frame = JSON.stringify([prompt.glyph, prefix, width, footer]);
const input: ComposerInput = { state: empty ? "empty" : "text", rows: visible, columns: width - prefix, cursorAtEnd, collapsedPaste: label, frame,
...(ghost ? { ghost } : {}) };
// A numbered body is allowed only as this operation's exact text in its
// previously empty composer. A newly observed menu gains no such authority.
if (numbered && !(owned?.frame === frame && composerContainsOwnedText(input, owned.text))) continue;
if (owned?.frame === frame && cursorAtEnd && visible.join("\n").includes("[Pasted text #")
&& hasExpectedStagedText(`${prompt.glyph} ${visible.join("\n")}`, owned.text, runtime)) {
input.collapsedPaste = visible.join("\n");
input.pasteMatchesExpected = true;
}
candidates.push(input);
}
return candidates.length === 1 ? candidates[0]! : unknown();
}
function findPromptDraftBeforeFooter(paneContent: string): string | null {
const rawLines = paneContent.split("\n").map((line) => line.trimEnd());
let lastLineIndex = rawLines.length - 1;
@@ -251,7 +402,8 @@ function findPromptDraftBeforeFooter(paneContent: string): string | null {
if (priorLine.trim().length === 0) return null;
const priorTrimmed = priorLine.trim();
const looksLikeDraft = PROMPT_DRAFT_PATTERNS.some((pattern) => pattern.test(priorTrimmed));
const prompt = readComposerPromptLine(priorLine);
const looksLikeDraft = prompt !== null && prompt.text.trim().length > 0;
const looksLikeSelection = COMPOSER_SELECTION_PATTERN.test(priorTrimmed);
if (!looksLikeDraft || looksLikeSelection) return null;
@@ -334,6 +486,16 @@ export function agentAppPaneActivity(classification: PaneActivityClassification,
}
export function classifyPaneActivity(paneContent: string, options: ClassifyPaneOptions = {}): PaneActivityClassification {
if (options.composerSnapshot) {
const snapshot = options.composerSnapshot;
const { ghost } = inspectComposerInput(snapshot, options.runtime);
const lines = styledTextLines(snapshot.screen).map(line => line.text);
if (ghost) {
lines[ghost.line] = lines[ghost.line]!.slice(0, ghost.offset);
lines.splice(ghost.line + 1, ghost.continuationRows);
}
paneContent = lines.join("\n");
}
const lastNonBlank = trimPaneLines(paneContent);
if (lastNonBlank.length === 0) {
return { state: "unknown", reason: "empty_capture", evidence: null };
@@ -479,6 +641,8 @@ export async function probeSessionActivity(input: {
binding?: Omit<ObservedBinding, "sessionName">;
/** See ClassifyPaneOptions.timerlessStatusIsLive. */
timerlessStatusIsLive?: boolean;
/** Supplied only by an active draft-aware send; ordinary observations stay unchanged. */
composerSnapshot?: () => Promise<ComposerSnapshot | null>;
/** The seat's community agent app (`agentAppOf`): its idle-looking screen reads unknown. */
agentApp?: string | null;
}): Promise<AgentActivity> {
@@ -591,10 +755,12 @@ export async function probeSessionActivity(input: {
};
const captureSeq = observed ? nextCaptureSeq++ : 0;
try {
const paneContent = await tmuxAdapter.capturePaneContent(sessionName, 20);
const snapshot = input.composerSnapshot ? await input.composerSnapshot().catch(() => null) : null;
const paneContent = snapshot?.screen ?? await tmuxAdapter.capturePaneContent(sessionName, 20);
const capturedAt = new Date().toISOString();
const classifyOptions = { timerlessStatusIsLive: input.timerlessStatusIsLive === true };
let classification = agentAppPaneActivity(classifyPaneActivity(paneContent ?? "", classifyOptions), input.agentApp);
let classification = agentAppPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
composerSnapshot: snapshot ?? undefined, runtime }), input.agentApp);
// Claude renders its prompt suggestion faint, which the plain capture loses. When a Claude pane reads
// as a draft, one capture with styling is classified on its own. Newer attention or work there is
// kept. A faint suggestion counts only when it's the same composer text this capture read as a draft;
@@ -1354,6 +1520,12 @@ export function readInputBox(pane: string | null, runtime: "claude-code" | "code
}
export interface SendOpts {
/** Internal provenance derived by the authenticated transport route. */
identityProvenance?: string;
/** Internal wake provenance, never accepted from the public transport route. */
queueWake?: boolean;
/** Internal observation of a completed input effect; never a permission callback. */
onInputEffect?: (phase: "pasted" | "submitted") => void;
/** Internal managed lifecycle prerequisite; never accepted from HTTP send options. */
beforeWrite?: () => void;
/** Stable caller request ID, reused for readback after transport uncertainty. */
@@ -1444,6 +1616,7 @@ export interface SendResult {
*/
outcome?: "delivered" | "rendered-unconfirmed" | "failed" | "retained";
outboxIds?: string[];
delivery?: GuardedDeliverySummary;
warning?: string;
error?: string;
reason?: string;
@@ -1557,6 +1730,7 @@ interface SessionReconciliationSnapshot {
export class SessionTransport {
readonly db: Database.Database;
readonly guardedDelivery?: DraftAwareDelivery;
private rigRepo: RigRepository;
private sessionRegistry: SessionRegistry;
private tmuxAdapter: TmuxAdapter;
@@ -1591,6 +1765,13 @@ export class SessionTransport {
this.listProcesses = deps.listProcesses;
this.runtimeAdapters = deps.runtimeAdapters ?? {};
this.verifyInputBox = deps.verifyInputBox;
const guard = this.tmuxAdapter.deliveryGuard;
if (guard && (this.db.prepare("PRAGMA table_info(outbox_entries)").all() as Array<{ name: string }>).some(c => c.name === "guard_delivery")) this.guardedDelivery = new DraftAwareDelivery(this.db, guard,
(session, text, opts) => this.sendUnguarded(session, text, opts));
}
configureGuardedWakes(applicable: (entries: readonly OutboxEntry[]) => boolean): void {
this.guardedDelivery?.configureWakes(applicable);
}
attachRuntimeAdapters(adapters: Record<string, TransportReadinessChecker>): void {
@@ -1896,6 +2077,14 @@ export class SessionTransport {
return pref.desired || pref.effective ? target : null;
}
draftAwareTarget(sessionName: string) {
const guard = this.tmuxAdapter.deliveryGuard;
const target = guard?.maybeTarget(sessionName);
if (!guard || !target) return null;
const policy = guard.configuration(target.nodeId);
return policy.desired.mode === "draft-aware" || policy.effective.mode === "draft-aware" ? target : null;
}
/** Deliver text and Enter to the resolved recipient under the send guards.
* With verification requested, compare rendered message counts, excluding
* agent input; a confirmed render does not prove agent consumption. */
@@ -1910,8 +2099,8 @@ export class SessionTransport {
}
const outbox = new OutboxHandler(this.db);
const ids = opts?.committedOutboxIds ?? [opts?.deliveryId ?? `guard-send-${randomUUID()}`];
const retainedResult = (): SendResult => ({ ok: true, sessionName, outcome: "retained", sent: false, verified: false,
outboxIds: ids, reason: "typing_guard_enabled", warning: `Retained, not delivered. Inspect with rig seat held-messages ${sessionName}; disabling does not replay held messages.` });
const retainedResult = (reason = "typing_guard_enabled"): SendResult => ({ ok: true, sessionName, outcome: "retained", sent: false, verified: false,
outboxIds: ids, reason, warning: `Retained, not delivered. Inspect with rig seat held-messages ${sessionName}; changing protection does not replay held messages.` });
try {
if (opts?.committedOutboxIds) {
const target = guard.target(sessionName);
@@ -1923,6 +2112,8 @@ export class SessionTransport {
}
}
}
const existing = this.guardedDelivery?.existing(sessionName, text, opts ?? {}, ids);
if (existing) return existing;
// Idempotent readback also after disabling: an old retained ID never becomes a new send.
// (P2: this readback is NOT a new retention and is outside the retained_no_write seam.)
if (!opts?.committedOutboxIds && opts?.deliveryId) {
@@ -1969,7 +2160,15 @@ export class SessionTransport {
sendOpts = { ...opts, preWaitedIdle: pre.evidence, preWaitBound: pre.bound };
}
}
const result = await guard.operation(sessionName, () => this.sendUnguarded(sessionName, text, sendOpts), async target => {
const result = await guard.operation(sessionName, async () => {
if (this.guardedDelivery && !sendOpts?.submitOnly && guard.needsInputCheck(sessionName)) {
return this.guardedDelivery.send(sessionName, text, sendOpts ?? {}, ids);
}
const prior = this.guardedDelivery?.existing(sessionName, text, sendOpts ?? {}, ids);
return prior ?? this.sendUnguarded(sessionName, text, sendOpts);
}, async target => {
const prior = this.guardedDelivery?.existing(sessionName, text, sendOpts ?? {}, ids);
if (prior) return prior;
if (opts?.submitOnly) return { ok: false, sessionName, sent: false, reason: "typing_guard_enabled", error: "Typing guard prevents submit-only; no Enter was sent." };
this.db.transaction(() => {
for (const id of ids) {
@@ -1977,6 +2176,7 @@ export class SessionTransport {
if (opts?.committedOutboxIds && (!prior || prior.destinationSession !== sessionName)) throw new Error("Committed wake target/ID mismatch");
outbox.retain(prior ? { ...prior, outboxId: id, tags: prior.tags ?? undefined, auditPointer: prior.auditPointer ?? undefined } : {
outboxId: id, senderSession: opts?.actorSession ?? "unknown", destinationSession: sessionName, body: text, auditPointer: opts?.auditPointer,
identityProvenance: opts?.identityProvenance,
}, target, !!opts?.committedOutboxIds);
}
})();
@@ -2037,7 +2237,10 @@ export class SessionTransport {
const preTarget = guard.maybeTarget(sessionName);
if (!preTarget) return null;
const prePref = guard.preference(preTarget.nodeId);
if (prePref.effective || prePref.desired) return null;
// Hold mode retains in the lease, and draft-aware mode reads the input there first and returns its
// immediate result (held, or refused with its reason): neither takes the lease-free wait. A
// draft-aware send whose input is empty waits for idle inside the lease, as before this wait existed.
if (prePref.effective || prePref.desired || prePref.desiredMode === "draft-aware" || prePref.effectiveMode === "draft-aware") return null;
meta = this.getSessionMeta(sessionName);
bound = guard.target(sessionName);
} catch {
@@ -2323,6 +2526,7 @@ export class SessionTransport {
error: `submitOnly refused: the pane of '${sessionName}' does not show the expected staged text — pressing Enter here could drive something else entirely. Nothing was submitted.`,
};
}
this.tmuxAdapter.deliveryGuard?.expectStagedInput(sessionName, expected);
const targetFailure = await checkClaudeTarget();
if (targetFailure) return targetFailure;
const copyModeFailure = await leavePaneMode("Nothing was submitted.");
@@ -2335,6 +2539,7 @@ export class SessionTransport {
if (!submitResult.ok) {
return { ok: false, sessionName, reason: "submit_failed", outcome: "failed", error: `submitOnly: Enter did not land on '${sessionName}': ${submitResult.message}` };
}
try { opts.onInputEffect?.("submitted"); } catch { /* Observation cannot change an input result. */ }
return observe({ ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true });
}
@@ -2591,15 +2796,17 @@ export class SessionTransport {
(result) => result.ok ? "ok" : "failed",
);
if (!textResult.ok) {
const policyRefusal = textResult.code.startsWith("draft_");
return observe({
ok: false,
sessionName,
reason: "send_failed",
reason: policyRefusal ? textResult.code : "send_failed",
outcome: "failed",
error: `Failed to send text to '${sessionName}': ${textResult.message}`,
...(waitMode ? { sent: false, ...waitEvidence } : {}),
...(waitMode || policyRefusal ? { sent: false, ...waitEvidence } : {}),
});
}
try { opts?.onInputEffect?.("pasted"); } catch { /* Observation cannot change a completed paste. */ }
// 4. Wait 200ms (spike-proven delay)
await this.sleep(200);
@@ -2638,15 +2845,17 @@ export class SessionTransport {
(result) => result.ok ? "ok" : "failed",
);
if (!submitResult.ok) {
const policyRefusal = submitResult.code.startsWith("draft_");
return observe({
ok: false,
sessionName,
reason: "submit_failed",
reason: policyRefusal ? submitResult.code : "submit_failed",
outcome: "failed",
error: `Text is visible in '${sessionName}' but was not submitted (Enter failed). The agent may need manual attention.`,
...(waitMode ? { sent: true, ...waitEvidence } : {}),
...(waitMode || policyRefusal ? { sent: true, ...waitEvidence } : {}),
});
}
try { opts?.onInputEffect?.("submitted"); } catch { /* Observation cannot change a completed submission. */ }
const interaction = promptOverride ? { promptInteraction: "enter-sent" as const } : {};
@@ -2916,6 +3125,8 @@ export class SessionTransport {
readinessFromPaneOnly?: boolean;
}): Promise<AgentActivity> {
const now = this.now();
const composerSnapshot = this.tmuxAdapter.deliveryGuard?.needsInputCheck(input.sessionName)
? () => this.tmuxAdapter.captureComposerSnapshot(input.binding?.pane ?? input.sessionName) : undefined;
const hookActivity = this.agentActivityStore?.getLatestForNode({
sessionName: input.sessionName,
now,
@@ -2966,6 +3177,7 @@ export class SessionTransport {
// vetoes (→ refuse); an unknown pane (the flaky-Codex case this trust
// exists for) or a clean idle pane does NOT veto → trust the stale hook.
const paneVeto = await probeSessionActivity({
composerSnapshot,
sessionName: input.sessionName,
runtime: input.runtime,
attachmentType: input.attachmentType as "tmux" | "external_cli" | null | undefined,
@@ -2982,6 +3194,7 @@ export class SessionTransport {
}
const probe = await probeSessionActivity({
composerSnapshot,
sessionName: input.sessionName,
runtime: input.runtime,
attachmentType: input.attachmentType as "tmux" | "external_cli" | null | undefined,
@@ -15,7 +15,7 @@ const VIM_MODE_PREFIX = /^-- INSERT --\s*/;
const MODE_BAR_START = /^(?:⏵⏵|⏸)(?:\s|$)/;
// The suffix is a circle glyph and an effort level, e.g. `◐ medium` or `● high`; arbitrary text is not one.
const PASTE_HINT = /^paste again to expand(?:\s{2,}[◐●○◑◒◓] (?:low|medium|high|xhigh|max) · \/effort)?$/;
const isComposerFooter = (line: string): boolean => {
export const isComposerFooter = (line: string): boolean => {
const bar = line.replace(VIM_MODE_PREFIX, "");
return (bar === "" && line !== "") || /(?:shift\+tab to cycle|\? for shortcuts)/i.test(bar) || MODE_BAR_START.test(bar)
|| PASTE_HINT.test(bar);
+2
View File
@@ -337,6 +337,7 @@ export async function startServer(port?: number) {
// tree post-bind so the HTTP surface is ready before the first
// tick (matches contextMonitor pattern).
deps.watchdogScheduler?.start();
deps.sessionTransport?.guardedDelivery?.recover();
// S10 — start the gateway subsystem's NETWORK services (replay, outbound poll,
// Socket Mode inbound) post-bind, contextMonitor pattern: the composed wire is
// already active; only its dial-out half waits for the supervision tree.
@@ -395,6 +396,7 @@ export async function startServer(port?: number) {
["proof-source-watch", () => deps.proofSourceWatch?.close()],
["health-diagnosis", () => deps.healthDiagnosis?.stop()],
["watchdog", () => deps.watchdogScheduler?.stop()],
["draft-delivery", () => deps.sessionTransport?.guardedDelivery?.stop()],
["seat-activity", () => deps.seatActivityService?.stop()],
["seat-structural-activity", () => deps.seatStructuralActivityService?.stop()],
["seat-identity", () => deps.seatIdentityReconciler?.stop()],
+23 -9
View File
@@ -19,6 +19,8 @@ import { buildRebuildPrimingChain } from "../domain/rebuild-priming-chain.js";
import { OPENRIG_HOME } from "../openrig-compat.js";
import { SettingsStore } from "../domain/user-settings/settings-store.js";
import { transportSenderSession } from "./require-sender-identity.js";
import type { SessionTransport } from "../domain/session-transport.js";
import { DeliveryGuardError } from "../domain/seat-delivery-guard.js";
export const seatRoutes = new Hono();
@@ -26,17 +28,22 @@ export const seatRoutes = new Hono();
seatRoutes.post("/set-typing-guard/:seatRef", async c => {
const guard = (c.get("tmuxAdapter" as never) as TmuxAdapter).deliveryGuard;
if (!guard) return c.json({ error: "Delivery guard unavailable" }, 503);
const body = await c.req.json<Record<string, unknown>>();
if (typeof body.enabled !== "boolean" || typeof body.reason !== "string" || !body.reason.trim()) {
return c.json({ error: "enabled boolean and reason required" }, 400);
const body = await c.req.json<Record<string, unknown>>().catch(() => null);
if (!body || typeof body.reason !== "string" || !body.reason.trim()
|| (body.enabled === undefined) === (body.mode === undefined)
|| body.enabled !== undefined && typeof body.enabled !== "boolean"
|| body.holdSeconds !== undefined || body.maxAttempts !== undefined) {
return c.json({ error: "Supply exactly one enabled boolean or mode, and a reason" }, 400);
}
const actor = transportSenderSession(c);
if (!actor) return c.json({ error: "Sender identity required for preference audit" }, 400);
try {
const target = guard.target(c.req.param("seatRef"));
const preference = await guard.set(target.nodeId, body.enabled, actor, body.reason);
return c.json({ ...preference, tradeoff: "Automatic terminal input is paused while enabled, even at an empty prompt. Disabling does not replay retained messages." }, preference.pending ? 202 : 200);
} catch (error) { return c.json({ error: (error as Error).message }, 409); }
const preference = await guard.set(target.nodeId, { mode: body.mode ?? (body.enabled ? "hold" : "off") }, actor, body.reason);
return c.json({ ...preference, tradeoff: "Hold retains all automatic input. Draft-aware sends once or immediately holds with the exact refusal reason; it schedules no retry. Off permits new sends. Changing mode does not flush earlier held messages." }, preference.pending ? 202 : 200);
} catch (error) {
return c.json({ error: (error as Error).message, code: (error as { code?: string }).code }, error instanceof DeliveryGuardError && error.code === "invalid_typing_guard" ? 400 : 409);
}
});
seatRoutes.get("/held-messages/:seatRef", c => {
@@ -45,13 +52,19 @@ seatRoutes.get("/held-messages/:seatRef", c => {
try {
const target = guard.target(c.req.param("seatRef"));
const outbox = new OutboxHandler(guard.db);
const guarded = (c.get("sessionTransport" as never) as SessionTransport | undefined)?.guardedDelivery;
const id = c.req.query("id");
if (id) {
const entry = outbox.getById(id);
if (entry?.guardBinding?.nodeId !== target.nodeId) return c.json({ error: "No retained history for this node and ID" }, 404);
return c.json({ entry });
const delivery = guarded?.lookup(id);
return c.json({ entry, ...(delivery ? { delivery } : {}) });
}
return c.json(outbox.heldForNode(target.nodeId, Number(c.req.query("limit") ?? 100), Number(c.req.query("offset") ?? 0)));
const page = outbox.heldForNode(target.nodeId, Number(c.req.query("limit") ?? 100), Number(c.req.query("offset") ?? 0));
return c.json({ ...page, items: page.items.map(entry => {
const delivery = guarded?.lookup(entry.outboxId);
return delivery ? { ...entry, delivery } : entry;
}) });
} catch (error) { return c.json({ error: (error as Error).message }, 400); }
});
@@ -65,7 +78,8 @@ seatRoutes.post("/retire-held-message/:seatRef/:id", async c => {
const target = guard.target(c.req.param("seatRef"));
const outbox = new OutboxHandler(guard.db); const id = c.req.param("id");
if (outbox.getById(id)?.guardBinding?.nodeId !== target.nodeId) return c.json({ error: "No held message for this node and ID" }, 404);
return c.json({ entry: outbox.retire(id, actor, body.reason), effect: "Retired from active quota; evidence preserved. No delivery, native consumption or work closure is asserted." });
const entry = outbox.retire(id, actor, body.reason);
return c.json({ entry, effect: "Retired from active quota; evidence preserved. No delivery, native consumption or work closure is asserted." });
} catch (error) { return c.json({ error: (error as Error).message }, 409); }
});
+12 -2
View File
@@ -105,6 +105,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
dangerouslyInteract: body.dangerouslyInteract,
reason: body.reason,
actorSession: derivedActor, // transport-derived, never the body claim
...(derivedActor ? { identityProvenance: "transport:v1" } : {}),
// Mechanics-gate fix (d9b3989a): the walk retry's bare-Enter mode, guarded in the
// transport by the expected-staged-text precheck.
submitOnly: body.submitOnly,
@@ -121,6 +122,14 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
guard_target_changed: 409,
delivery_identity_conflict: 409,
retained_quota_full: 409,
delivery_already_attempted: 409,
delivery_in_progress: 409,
delivery_indeterminate: 409,
draft_input_busy: 409,
draft_input_unknown: 409,
draft_input_changed: 409,
draft_hold_expired: 409,
draft_wake_superseded: 409,
session_missing: 404,
tmux_unavailable: 503,
transport_unavailable: 409,
@@ -151,7 +160,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
// never the relay). A null-actor send has no derived sender to attribute (no fabricated row). The
// send is already committed, so a rare audit-write failure is LOGGED, never a false-negative on a
// delivered send.
if (derivedActor && !body.submitOnly) { // submitOnly types no text — nothing to outbox-record
if (derivedActor && !body.submitOnly && !result.outboxIds?.length) { // Deferred sends already own their original outbox rows.
const outbox = c.get("outboxHandler" as never) as OutboxHandler | undefined;
if (outbox) {
try {
@@ -286,6 +295,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
reason: body.reason,
actorSession: derivedActor, // transport-derived, never the body claim
envelopeSender, // the From: is the DERIVED identity (never the body value) — orch ruling (a)
...(derivedActor ? { identityProvenance: "transport:v1" } : {}),
});
// A3b (P22 follow-on, planner-ruled IN scope): auto-record the fan-out — N rows, ONE per RESOLVED
@@ -299,7 +309,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
const outbox = c.get("outboxHandler" as never) as OutboxHandler | undefined;
if (outbox) {
for (const r of result.results) {
if (!r.sessionName || r.outcome === "retained") continue;
if (!r.sessionName || r.outcome === "retained" || r.outboxIds?.length) continue;
try {
const entry = outbox.record({
senderSession: derivedActor,
@@ -10,14 +10,17 @@ import {SessionTransport} from '../src/domain/session-transport.js';
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
import {TmuxAdapter} from '../src/adapters/tmux.js';
import {ContextMonitor} from '../src/domain/context-monitor.js';
import Database from 'better-sqlite3';
import {ALL_MIGRATIONS} from '../src/db/all-migrations.js';
import {migrate} from '../src/db/migrate.js';
const seat='writer@demo', input={sessionName:seat,runtime:'claude-code',usedPercentage:90};
const homes:string[]=[];
afterEach(()=>{vi.restoreAllMocks();for(const h of homes.splice(0))rmSync(h,{recursive:true,force:true});});
function fixture(manualPrepWaitMs=1000){
function fixture(manualPrepWaitMs=1000,guardDb?:Database.Database){
const home=mkdtempSync(join(tmpdir(),'compaction-preparation-'));homes.push(home);
let clock=10000,generation='generation-one',activity='idle';
let onSleep:(()=>Promise<void>)|undefined;
const db={prepare:()=>({get:()=>undefined,all:()=>[]})} as any;
const db=guardDb??({prepare:()=>({get:()=>undefined,all:()=>[]})} as any);
const writes:string[]=[],keys:string[][]=[];
const policy={enabled:true,thresholdPercent:80,preCompactInstruction:'Write a restore map.',compactInstruction:'',messageInline:'',messageFilePath:'',postRestoreAuditInstruction:''};
const tmux={probeSession:vi.fn(async()=>({state:'present'})),sendText:vi.fn(async(_:string,text:string)=>{writes.push(text);return{ok:true};}),sendKeys:vi.fn(async(_:string,k:string[])=>{keys.push(k);return{ok:true};})};
@@ -172,17 +175,24 @@ it('uncertain compact receipt never replays an already submitted command',async(
});
it('skip-map cannot bypass typing guard or a changed occupant',async()=>{
for(const change of ['guard','occupant']){
const f=fixture();const original=f.transport.waitUntilIdle.bind(f.transport);
vi.spyOn(f.transport,'waitUntilIdle').mockImplementationOnce(async(...args)=>{
const r=await original(...args);
if(change==='guard')vi.spyOn(f.guard,'preference').mockReturnValue({nodeId:'node-one',desired:true,effective:true,pending:false});
else f.generation('generation-two');
return r;
});
const result=await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true});
expect(result.triggered).toBe(false);expect(compacts(f)).toHaveLength(0);
expect(result).toMatchObject({reason:change==='guard'?'typing_guard_enabled':'stale_generation'});
for(const change of ['guard','hold-mode','occupant']){
const db=new Database(':memory:');
try{
migrate(db,ALL_MIGRATIONS);
db.exec("INSERT INTO rigs(id,name) VALUES ('rig-demo','demo'); INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES ('node-one','rig-demo','writer','claude-code');");
const f=fixture(1000,db);const original=f.transport.waitUntilIdle.bind(f.transport);
vi.spyOn(f.transport,'waitUntilIdle').mockImplementationOnce(async(...args)=>{
const r=await original(...args);
// Activate the real persisted control: a mocked preference read does not
// exercise the operation boundary or the compatible enabled selector.
if(change!=='occupant')await f.guard.set('node-one',change==='guard'?true:{mode:'hold'},'operator','protect input during preparation');
else f.generation('generation-two');
return r;
});
const result=await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true});
expect(result.triggered).toBe(false);expect(compacts(f)).toHaveLength(0);
expect(result).toMatchObject({reason:change==='occupant'?'stale_generation':'typing_guard_enabled'});
}finally{db.close();}
}
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { classifyPaneActivity, inspectComposerInput } from "../src/domain/session-transport.js";
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerSnapshot } from "../src/domain/composer-prompts.js";
function claude(body: string, cursor?: { x: number; y: number }, width = 80): ComposerSnapshot {
const rows = body.split("\n");
const last = rows.at(-1)!;
return { screen: ["Ready", "────────────────────", `❯ ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "────────────────────", "? for shortcuts", ""].join("\n"),
cursor: { x: 2 + last.length, y: 1 + rows.length, width, height: 24, ...cursor }, inMode: false };
}
describe("cursor-bound composer input", () => {
it("keeps nested prompt examples inside the current draft, including when its head is clipped", () => {
const body = "intro\n────────────────────\n❯ \n────────────────────\n? for shortcuts\n────────────────────\n❯ ";
const snapshot = claude(body, { x: 4, y: 8 });
expect(inspectComposerInput(snapshot).state).toBe("text");
const clipped = { ...snapshot, screen: snapshot.screen.split("\n").slice(3).join("\n"), cursor: { ...snapshot.cursor, y: 5 } };
expect(inspectComposerInput(clipped).state).toBe("unknown");
});
it("uses the styled input read for opted-in readiness, while preserving default draft and permission checks", () => {
const snapshot = claude("\x1b[2msuggested follow-up\x1b[22m", { x: 2, y: 2 });
snapshot.screen = snapshot.screen.replace("? for shortcuts", "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents\n✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
const plain = snapshot.screen.replace(/\x1b\[[0-9;]*m/g, "");
expect(classifyPaneActivity(plain)).toMatchObject({ state: "attention", reason: "prompt_draft" });
expect(classifyPaneActivity(plain, { composerSnapshot: snapshot })).toMatchObject({ state: "agent_idle" });
const permission = { ...snapshot, screen: snapshot.screen.replace("Ready", "Do you want to allow this command?") };
expect(classifyPaneActivity(plain, { composerSnapshot: permission })).toMatchObject({ state: "attention", reason: "permission_prompt" });
});
it("ignores a dim autocomplete suggestion at an empty cursor, preserving real drafts with the same spelling", () => {
const ghost = "Summarize the recent changes";
expect(inspectComposerInput(claude(`\x1b[2m${ghost}\x1b[22m`, { x: 2, y: 2 })).state).toBe("empty");
expect(inspectComposerInput(claude(ghost, { x: 2, y: 2 })).state).toBe("text");
});
it("excludes only a wholly dim suffix after the cursor when identifying staged text", () => {
const input = inspectComposerInput(claude("owned\x1b[2m ghost suggestion\x1b[22m", { x: 7, y: 2 }));
expect(input.state).toBe("text");
expect(composerContainsOwnedText(input, "owned")).toBe(true);
expect(composerContainsOwnedText(input, "owned ghost suggestion")).toBe(false);
const mixed = inspectComposerInput(claude("owned\x1b[2m ghost\x1b[22m human", { x: 7, y: 2 }));
expect(composerContainsOwnedText(mixed, "owned")).toBe(false);
});
it("recognizes Claude's non-breaking prompt space without erasing draft content", () => {
for (const body of ["", "unfinished request"]) {
const snapshot = claude(body);
snapshot.screen = snapshot.screen.replace("❯ ", "❯\u00a0");
const input = inspectComposerInput(snapshot);
expect(input.state).toBe(body ? "text" : "empty");
if (body) expect(composerContainsOwnedText(input, body)).toBe(true);
}
});
it.each(["Ask Codex to do anything", "Press up to edit queued messages"])("preserves a literal %s draft at the start cursor", text => {
expect(inspectComposerInput(claude(text, { x: 2, y: 2 })).state).toBe("text");
});
it("distinguishes an empty input from a draft even while a work status is visible", () => {
const empty = claude(""); empty.screen = empty.screen.replace("Ready", "✻ Working… (2s · esc to interrupt)");
expect(inspectComposerInput(empty).state).toBe("empty");
expect(inspectComposerInput(claude("a half-written request")).state).toBe("text");
expect(inspectComposerInput(claude("", { x: 5, y: 2 })).state).toBe("text");
expect(inspectComposerInput(claude("\n")).state).toBe("text");
expect(inspectComposerInput(claude("\n", { x: 2, y: 2 })).state).toBe("text");
expect(inspectComposerInput(claude(" ", { x: 2, y: 2 })).state).toBe("text");
});
it.each(["›", "»"])("recognizes the empty Codex placeholder with marker %s only at the input cursor", marker => {
const s: ComposerSnapshot = { screen: `Working\n${marker} \x1b[2mAsk Codex to do anything\x1b[22m\n\ngpt-5 · 90% context left\n`, cursor: { x: 2, y: 1, width: 80, height: 24 }, inMode: false };
expect(inspectComposerInput(s).state).toBe("empty");
expect(inspectComposerInput({ ...s, cursor: { ...s.cursor, x: 12 } }).state).toBe("text");
});
it("does not infer an empty input from history, a selector, copy mode or an incomplete capture", () => {
expect(inspectComposerInput(null).state).toBe("unknown");
expect(inspectComposerInput({ ...claude(""), inMode: true }).state).toBe("unknown");
expect(inspectComposerInput({ ...claude(""), cursor: { x: 0, y: 0, width: 80, height: 24 } }).state).toBe("unknown");
expect(inspectComposerInput(claude("1. Approve this command")).state).toBe("unknown");
expect(inspectComposerInput({ ...claude(""), screen: "❯\n" }).state).toBe("unknown");
});
it("does not mistake color channel values for faint styling", () => {
for (const sgr of ["38;2;2;2;2", "38;5;2", "38;2;0;255;255;2", "2;38;5;2;22"]) {
const value = claude(`\x1b[${sgr}mAsk Codex to do anything\x1b[0m`, { x: 2, y: 2 });
expect(inspectComposerInput(value).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
expect(composerContainsOwnedText(input, "Run twocommands\nthen report.")).toBe(false);
expect(composerContainsOwnedText(input, "Run two commands")).toBe(false);
expect(composerContainsOwnedText(inspectComposerInput(claude("owned plus a human draft")), "owned")).toBe(false);
expect(composerContainsOwnedText(inspectComposerInput(claude("owned", { x: 8, y: 2 })), "owned")).toBe(false);
});
it("accepts supported hard and word wrapping without deleting arbitrary whitespace", () => {
const hard = inspectComposerInput(claude("abcdefgh\nijkl", undefined, 10));
expect(composerContainsOwnedText(hard, "abcdefghijkl")).toBe(true);
const words = inspectComposerInput(claude("one two\nthree", undefined, 10));
expect(composerContainsOwnedText(words, "one two three")).toBe(true);
expect(composerContainsOwnedText(words, "one two three")).toBe(false);
expect(composerContainsOwnedText(inspectComposerInput(claude("one\ntwo")), "one two")).toBe(false);
});
it("matches Unicode cell widths and treats an opaque paste as separately owned evidence", () => {
const input = inspectComposerInput(claude("你好世界\nagain", { x: 7, y: 3 }, 10));
expect(composerContainsOwnedText(input, "你好世界again")).toBe(true);
const opaque = inspectComposerInput(claude("[Pasted text #4 +2 lines]"));
expect(ownCollapsedPaste(opaque, "one\ntwo\nthree")).toBe("[Pasted text #4 +2 lines]");
expect(ownCollapsedPaste(opaque, "one\ntwo")).toBeNull();
expect(composerContainsOwnedText(opaque, "one\ntwo\nthree")).toBe(false);
expect(ownCollapsedPaste(inspectComposerInput(claude("[Pasted text #4 +2 lines] foreign")), "one\ntwo\nthree")).toBeNull();
});
});
@@ -0,0 +1,137 @@
import Database from "better-sqlite3";
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it, vi } from "vitest";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
const run = promisify(execFile);
const pause = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
const composer = fileURLToPath(new URL("./fixtures/draft-composer.mjs", import.meta.url));
// The YAML stub runner cannot script editable input or its policy step yet.
// This private tmux fixture exercises the real cursor/capture/paste/Enter path;
// provider-specific renderings remain parser fixtures, not a live-model claim.
describe.skipIf(process.platform === "win32")("native draft-aware terminal delivery", () => {
it.each(["claude", "codex"])("%s input: protects drafts at paste and Enter while preserving delivery and siblings", async runtime => {
const scratch = mkdtempSync(join(tmpdir(), "draft-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
const tmux = async (args: string[]) => (await run("tmux", ["-S", socket, ...args], { env })).stdout;
const state = (node = "a") => JSON.parse(readFileSync(join(scratch, `${node}.json`), "utf8")) as { input: string; submissions: string[] };
const waitInput = async (input: string, node = "a") => vi.waitFor(() => expect(state(node).input).toBe(input), { timeout: 5000, interval: 25 });
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const panes: Record<string, string> = {};
for (const [node, session] of [["a", "worker@test"], ["b", "sibling@test"]]) {
const command = [process.execPath, composer, runtime, join(scratch, `${node}.json`)].map(shellQuote).join(" ");
panes[node!] = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", session!, command])).trim();
await waitInput("", node);
}
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES ('a','rig','worker','terminal'),('b','rig','sibling','terminal');
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('sa','a','worker@test','running'),('sb','b','sibling@test','running');
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('ga','a',1,'g1','fresh'),('gb','b',1,'g2','fresh');`);
for (const [node, session] of [["a", "worker@test"], ["b", "sibling@test"]]) {
db.prepare("INSERT INTO bindings(id,node_id,tmux_session,tmux_pane) VALUES (?,?,?,?)").run(`binding-${node}`, node, session, panes[node!]);
}
let at = Date.now();
const hooks: { load?: () => Promise<void>; beforeEnter?: () => Promise<void> } = {};
const adapter = new TmuxAdapter(async () => { throw new Error("Native fixture requires argv execution"); }, undefined, async args => {
const output = await tmux(args.slice(1));
if (args[1] === "load-buffer") await hooks.load?.();
return output;
});
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db),
now: () => new Date(at), sleep: async ms => { if (ms === 200) await hooks.beforeEnter?.(); await pause(ms); } });
const send = (id: string, text: string) => transport!.send("worker@test", text, { deliveryId: id, actorSession: "sender@test", verify: true });
await guard.set("a", { mode: "draft-aware" }, "human@test", "native fixture");
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(panes.a!)).state).toBe("empty"), { timeout: 5000 });
// A real human input arriving while the payload buffer is prepared must
// still be visible, with no automatic bytes or Enter in the input loop.
hooks.load = async () => {
hooks.load = undefined;
await tmux(["send-keys", "-t", panes.a!, "-l", "unfinished human draft"]);
await waitInput("unfinished human draft");
};
expect(await send("deferred", "one\nmessage")).toMatchObject({ outcome: "retained", delivery: { state: "held" } });
expect(state()).toEqual({ input: "unfinished human draft", submissions: [] });
await tmux(["send-keys", "-t", panes.a!, "C-u"]); await waitInput("");
expect(await send("deferred", "one\nmessage")).toMatchObject({ outcome: "retained", reason: "draft_input_busy" });
expect(state().submissions).toEqual([]);
expect(await send("fresh", "one\nmessage")).toMatchObject({ ok: true, sent: true, delivery: { state: "complete" } });
await vi.waitFor(() => expect(state().submissions).toEqual(["one\nmessage"]));
hooks.beforeEnter = async () => {
hooks.beforeEnter = undefined;
await tmux(["send-keys", "-t", panes.a!, "-l", " plus a human draft"]);
await waitInput("second message plus a human draft");
};
expect(await send("late", "second message")).toMatchObject({ ok: false, sent: true, reason: "draft_input_changed", delivery: { state: "indeterminate" } });
expect(state()).toEqual({ input: "second message plus a human draft", submissions: ["one\nmessage"] });
await send("late", "second message"); expect(state().submissions).toHaveLength(1);
await tmux(["send-keys", "-t", panes.a!, "C-u"]); await waitInput("");
await tmux(["copy-mode", "-t", panes.a!]);
expect(await send("copy-mode", "held during copy")).toMatchObject({ outcome: "retained", delivery: { reason: "draft_input_unknown" } });
await tmux(["send-keys", "-t", panes.a!, "-X", "cancel"]);
await guard.set("a", { mode: "hold" }, "human@test", "manual terminal");
expect(await send("inbox", "stay in the inbox")).toMatchObject({ outcome: "retained", reason: "typing_guard_enabled" });
expect(await transport.send("sibling@test", "sibling delivery", { verify: true })).toMatchObject({ ok: true });
await vi.waitFor(() => expect(state("b").submissions).toEqual(["sibling delivery"]));
expect(state().submissions).toEqual(["one\nmessage"]);
await adapter.humanInput("worker@test", async () => {
expect(await adapter.sendText("worker@test", "direct human message")).toEqual({ ok: true });
await waitInput("direct human message");
expect(await adapter.sendKeys("worker@test", ["Enter"])).toEqual({ ok: true });
});
await vi.waitFor(() => expect(state().submissions).toEqual(["one\nmessage", "direct human message"]));
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect literal input");
await tmux(["send-keys", "-t", panes.a!, "-l", "Ask Codex to do anything"]);
await waitInput("Ask Codex to do anything");
await tmux(["send-keys", "-t", panes.a!, "C-a"]);
await vi.waitFor(async () => expect((await adapter.captureComposerSnapshot(panes.a!))?.cursor.x).toBe(2));
expect(await send("literal-placeholder", "automatic message")).toMatchObject({ outcome: "retained", delivery: { reason: "draft_input_busy" } });
expect(state()).toEqual({ input: "Ask Codex to do anything", submissions: ["one\nmessage", "direct human message"] });
// The native capture must retain dim styling, both for an empty input and
// for an autocomplete suffix after the text owned by the delivery lease.
await tmux(["send-keys", "-t", panes.a!, "C-u", "C-t"]); await waitInput("");
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(panes.a!);
expect(snapshot?.screen).toContain("suggested follow-up");
expect(inspectComposerInput(snapshot).state).toBe("empty");
});
expect(await send("ghost", "owned delivery")).toMatchObject({ ok: true, delivery: { state: "complete" } });
await vi.waitFor(() => expect(state().submissions).toEqual(["one\nmessage", "direct human message", "owned delivery"]));
const framedDraft = "intro\n────────────────────\n❯ \n────────────────────\n? for shortcuts\n────────────────────\n❯ ";
await tmux(["send-keys", "-t", panes.a!, "C-t"]);
expect(await adapter.humanInput("worker@test", () => adapter.sendText("worker@test", framedDraft))).toEqual({ ok: true });
await waitInput(framedDraft);
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(panes.a!)).state).toBe("text"));
expect(await send("nested-prompt", "automatic message")).toMatchObject({ outcome: "retained", delivery: { reason: "draft_input_busy" } });
expect(state()).toEqual({ input: framedDraft, submissions: ["one\nmessage", "direct human message", "owned delivery"] });
} finally {
await transport?.guardedDelivery?.stop();
await tmux(["kill-server"]).catch(() => {});
db.close(); rmSync(scratch, { recursive: true, force: true });
}
}, 30_000);
});
@@ -0,0 +1,346 @@
import Database from "better-sqlite3";
import { Hono } from "hono";
import stringWidth from "string-width";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
import { EventBus } from "../src/domain/event-bus.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport, type SendOpts } from "../src/domain/session-transport.js";
import { seatRoutes } from "../src/routes/seat.js";
import { transportRoutes } from "../src/routes/transport.js";
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); });
function frame(body: string): ComposerSnapshot {
const rows = body.split("\n");
return { screen: ["Ready", "────────────────────", `❯ ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "────────────────────", "? for shortcuts", ""].join("\n"),
cursor: { x: 2 + stringWidth(rows.at(-1)!), y: 1 + rows.length, width: 240, height: 60 }, inMode: false };
}
function signal() {
let release!: () => void;
return { ready: new Promise<void>(resolve => { release = resolve; }), release: () => release() };
}
function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean } = {}) {
const db = new Database(saved ?? ":memory:");
if (!saved) {
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES ('a','rig','worker','${opts.runtime ?? "terminal"}'),('b','rig','sibling','terminal');
INSERT INTO bindings(id,node_id,tmux_session,tmux_pane) VALUES ('ba','a','worker@test','%1'),('bb','b','sibling@test','%2');
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('sa','a','worker@test','running'),('sb','b','sibling@test','running');
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('ga','a',1,'g1','fresh'),('gb','b',1,'g2','fresh');`);
}
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
guard.recoverActivation();
const bodies: Record<string, string | null> = { a: "", b: "" };
const history: Record<string, string> = { a: "", b: "" };
const writes: string[] = [], submissions: string[] = [];
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void> } = {};
let staged = "";
const tmux = new TmuxAdapter(async command => {
if (command.includes("load-buffer")) await hooks.load?.();
const node = command.includes("%2") ? "b" : "a";
if (command.includes("paste-buffer")) {
await hooks.paste?.();
writes.push(`paste:${node}:${staged}`); bodies[node] = staged;
}
if (command.includes("send-keys")) {
writes.push(`keys:${node}:${command}`);
if (command.includes("Enter")) {
submissions.push(bodies[node] ?? ""); history[node] += `${bodies[node]}\n`; bodies[node] = "";
}
}
return "";
}, { writeFile: async (_path, text) => { staged = text; }, unlink: async () => {}, tmpName: () => "/fixture/draft-input", bufferName: () => "draft-input" });
tmux.deliveryGuard = guard;
vi.spyOn(tmux, "probeSession").mockResolvedValue({ state: "present" });
vi.spyOn(tmux, "listPanes").mockImplementation(async name => [{ id: guard.target(name).pane! } as never]);
vi.spyOn(tmux, "getPaneCommand").mockResolvedValue("node");
vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async name => {
const body = bodies[guard.target(name).nodeId]; return body == null ? null : frame(body);
});
vi.spyOn(tmux, "capturePaneContent").mockImplementation(async name => {
const node = guard.target(name).nodeId; return bodies[node] == null ? null : history[node] + frame(bodies[node]!).screen;
});
let at = Date.now();
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), outbox = new OutboxHandler(db);
const transport = new SessionTransport({ db, tmuxAdapter: tmux, rigRepo, sessionRegistry, now: () => new Date(at), sleep: async ms => { await hooks.sleep?.(ms); },
agentActivityStore: { getLatestForNode: () => opts.idleHook === false ? null
: ({ state: "idle", reason: "fixture idle hook", evidenceSource: "runtime_hook", sampledAt: new Date(at).toISOString(), evidence: null }) } as never });
const guarded = transport.guardedDelivery!;
const repo = new QueueRepository(db, new EventBus(db), { transport, loadHumanRegistry: () => ({ ok: true, entities: [], warnings: [] }) });
repo.attachOutbox(outbox);
const app = new Hono();
app.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, tmux); c.set("rigRepo" as never, rigRepo);
c.set("sessionTransport" as never, transport); c.set("outboxHandler" as never, outbox); await next();
});
app.route("/api/seat", seatRoutes); app.route("/api/transport", transportRoutes());
cleanup.push(async () => { await guarded.stop(); db.close(); });
return { db, guard, tmux, transport, guarded, outbox, repo, app, bodies, history, writes, submissions, hooks,
advance: (ms = 4000) => { at += ms; },
enable: (settings = {}) => guard.set("a", { mode: "draft-aware", ...settings }, "human@test", "keep draft"),
send: (id: string, text = "incoming message", opts: SendOpts = {}) => transport.send("worker@test", text, { deliveryId: id, actorSession: "human@test", verify: true, ...opts }),
post: (path: string, body: unknown, actor = "human@test") => app.request(path, { method: "POST", headers: { "content-type": "application/json", "x-openrig-session": actor }, body: JSON.stringify(body) }),
};
}
describe("single-attempt draft-aware delivery through the real transport", () => {
it.each(["ordinary request ", "1. Review the change"])("submits ordinary authored text once: %j", async text => {
const f = fixture(); await f.enable();
expect(await f.send("ordinary", text)).toMatchObject({ ok: true, sent: true, delivery: { state: "complete" } });
expect(await f.send("ordinary", text)).toMatchObject({ delivery: { state: "complete" } });
expect(f.submissions).toEqual([text]);
});
it("completes a successful send without requested verification without claiming render proof", async () => {
const f = fixture(); await f.enable();
const result = await f.send("no-verify", "message", { verify: false });
expect(result).toMatchObject({ ok: true, sent: true, delivery: { state: "complete" } });
expect(result.verified).toBeUndefined();
expect(f.outbox.getById("no-verify")!.deliveryState).toBe("delivered");
await f.send("no-verify", "message", { verify: false });
expect(f.submissions).toEqual(["message"]);
});
it("keeps requested-but-unconfirmed verification indeterminate without resending", async () => {
const f = fixture(); await f.enable();
vi.mocked(f.tmux.capturePaneContent).mockResolvedValue("no render evidence");
expect(await f.send("not-confirmed")).toMatchObject({ ok: true, sent: true, verified: false, delivery: { state: "indeterminate" } });
await f.send("not-confirmed");
expect(f.outbox.getById("not-confirmed")!.deliveryState).toBe("indeterminate");
expect(f.submissions).toHaveLength(1);
});
// With no authoritative idle hook, a Claude seat's readiness comes from its screen, so the lease-free idle wait would read a draft as
// needs-input, or an unreadable screen as unknown until the deadline, before the draft-aware check ran. A draft-aware seat keeps the
// check's immediate result instead.
it.each(["draft", "unknown"])("a Claude seat with no idle hook returns the exact %s refusal and original ID immediately with wait-for-idle", async condition => {
const f = fixture(undefined, { runtime: "claude-code", idleHook: false }); await f.enable();
f.bodies.a = condition === "unknown" ? null : "unfinished human request";
const reason = condition === "unknown" ? "draft_input_unknown" : "draft_input_busy";
const sleep = vi.fn((ms: number) => f.advance(ms)); f.hooks.sleep = sleep; // a wait, if any, reaches its deadline
expect(await f.send("held-claude", "incoming message", { waitForIdleMs: 60000, verify: false })).toMatchObject({
outcome: "retained", sent: false, reason, outboxIds: ["held-claude"], delivery: { state: "held", reason },
});
expect(sleep).not.toHaveBeenCalled();
expect(f.writes).toEqual([]);
expect(f.outbox.getById("held-claude")).toMatchObject({ body: "incoming message", deliveryState: "retained" });
});
it("a Claude seat with no idle hook and an empty input box takes a wait-for-idle send once", async () => {
const f = fixture(undefined, { runtime: "claude-code", idleHook: false }); await f.enable();
expect(await f.send("empty-claude", "incoming message", { waitForIdleMs: 60000, verify: false })).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual(["incoming message"]);
});
it.each(["draft", "unknown"])("returns the exact %s refusal and original ID immediately, including with wait-for-idle", async condition => {
const f = fixture(); await f.enable(); f.bodies.a = condition === "unknown" ? null : "unfinished human request";
const reason = condition === "unknown" ? "draft_input_unknown" : "draft_input_busy";
const sleep = vi.fn(); f.hooks.sleep = sleep;
expect(await f.send("held", "incoming message", { waitForIdleMs: 60000 })).toMatchObject({
outcome: "retained", sent: false, reason, outboxIds: ["held"], delivery: { state: "held", reason },
});
expect(sleep).not.toHaveBeenCalled();
f.bodies.a = ""; f.advance(3600000);
await f.repo.drainPendingWakeIntents();
expect(await f.send("held")).toMatchObject({ outcome: "retained", reason });
expect(f.writes).toEqual([]);
expect(f.outbox.getById("held")).toMatchObject({ body: "incoming message", deliveryState: "retained" });
expect(f.db.prepare("SELECT count(*) n FROM outbox_entries").get()).toEqual({ n: 1 });
expect(await f.send("held", "different")).toMatchObject({ ok: false, reason: "delivery_identity_conflict" });
});
it("has no retry timer and keeps held receipts unchanged across restart and mode changes", async () => {
const first = fixture(); await first.enable(); first.bodies.a = "draft"; await first.send("restart");
const resumed = fixture(first.db.serialize()); resumed.guarded.recover();
await resumed.guard.set("a", false, "human@test", "allow new sends");
await resumed.enable();
vi.useFakeTimers();
try { await vi.advanceTimersByTimeAsync(3600000); } finally { vi.useRealTimers(); }
expect(await resumed.send("restart")).toMatchObject({ reason: "draft_input_busy", delivery: { state: "held" } });
expect(resumed.writes).toEqual([]);
expect(resumed.db.prepare("PRAGMA table_info(outbox_entries)").all().map((r: any) => r.name))
.not.toContain("retry_request");
});
it.each([false, true])("never replays an interrupted attempt (generic recovery first: %s)", async genericFirst => {
const first = fixture(); await first.enable(); first.bodies.a = "draft"; await first.send("crash");
first.db.exec("UPDATE outbox_entries SET delivery_state='sending',guard_delivery=json_remove(guard_delivery,'$.result');");
const resumed = fixture(first.db.serialize());
if (genericFirst) resumed.outbox.reconcileAbandonedSending("crash");
resumed.guarded.recover();
expect(await resumed.send("crash")).toMatchObject({ ok: false, reason: "delivery_indeterminate", delivery: { state: "indeterminate", reason: "interrupted_write" } });
expect(resumed.writes).toEqual([]);
});
it("returns an in-flight receipt without duplicate input and waits for active sends on shutdown", async () => {
const f = fixture(); await f.enable(); const entered = signal(), release = signal();
f.hooks.paste = async () => { entered.release(); await release.ready; };
const sending = f.send("concurrent"); await entered.ready;
expect(await f.send("concurrent")).toMatchObject({ ok: false, reason: "delivery_in_progress" });
let stopped = false; const stopping = f.guarded.stop().then(() => { stopped = true; });
await Promise.resolve(); expect(stopped).toBe(false);
release.release(); await Promise.all([sending, stopping]);
expect(f.submissions).toHaveLength(1);
});
it("leaves human edits visible and never replays an already-pasted message", async () => {
const f = fixture(); await f.enable();
f.hooks.sleep = ms => { if (ms === 200) f.bodies.a += " plus a human draft"; };
expect(await f.send("late")).toMatchObject({ ok: false, sent: true, reason: "draft_input_changed", delivery: { state: "indeterminate" } });
expect(f.bodies.a).toBe("incoming message plus a human draft");
await f.send("late"); expect(f.writes).toHaveLength(1); expect(f.submissions).toEqual([]);
});
it.each([false, true])("does not claim no write for an unknown native result (wait mode: %s)", async wait => {
const f = fixture(); await f.enable();
f.hooks.paste = () => { f.bodies.a = "incoming message"; throw new Error("lost native result after dispatch"); };
const opts = wait ? { waitForIdleMs: 1000 } : {};
const result = await f.send("unknown-write", "incoming message", opts);
expect(result).toMatchObject({ ok: false, delivery: { state: "indeterminate" } });
expect(result.sent).toBeUndefined();
await f.send("unknown-write", "incoming message", opts);
expect(f.submissions).toEqual([]);
});
it("a failed final ledger update cannot claim no input or authorize replay", async () => {
const f = fixture(); await f.enable();
const finalize = vi.spyOn(OutboxHandler.prototype, "finalizeDelivery").mockImplementationOnce(() => { throw new Error("ledger failure"); });
try {
const result = await f.send("ledger-failure");
expect(result).toMatchObject({ ok: false, reason: "delivery_indeterminate", delivery: { state: "indeterminate" } });
expect(result.sent).toBeUndefined(); await f.send("ledger-failure");
expect(f.submissions).toEqual(["incoming message"]);
} finally { finalize.mockRestore(); }
});
it("a lost final receipt read cannot report that no input was written", async () => {
const f = fixture(); await f.enable();
const prepare = f.db.prepare.bind(f.db);
let finalized = false;
const finalize = OutboxHandler.prototype.finalizeDelivery;
const finalizer = vi.spyOn(OutboxHandler.prototype, "finalizeDelivery").mockImplementation(function (this: OutboxHandler, id, state) {
const result = finalize.call(this, id, state); finalized = true; return result;
});
const reader = vi.spyOn(f.db, "prepare").mockImplementation((sql: string) => {
if (finalized && sql === "SELECT guard_delivery FROM outbox_entries WHERE outbox_id=?") throw new Error("receipt unavailable");
return prepare(sql);
});
try {
const result = await f.send("lost-receipt");
expect(result).toMatchObject({ ok: false, reason: "delivery_indeterminate", outboxIds: ["lost-receipt"] });
expect(result.sent).toBeUndefined(); expect(f.submissions).toHaveLength(1);
} finally { reader.mockRestore(); finalizer.mockRestore(); }
expect(await f.send("lost-receipt")).toMatchObject({ delivery: { state: "complete" } });
expect(f.submissions).toHaveLength(1);
});
it("does not serialize or replay a live prerequisite", async () => {
const f = fixture(); await f.enable(); f.bodies.a = "draft"; const beforeWrite = vi.fn();
expect(await f.send("live", "message", { beforeWrite })).toMatchObject({ outcome: "retained", reason: "draft_input_busy" });
f.bodies.a = ""; await f.send("live", "message");
expect(beforeWrite).not.toHaveBeenCalled(); expect(f.writes).toEqual([]);
});
});
describe("supported seat and queue integration", () => {
it("exposes exact held reasons, audit identity and retirement through HTTP", async () => {
const f = fixture(); f.bodies.a = "draft";
expect((await f.post("/api/seat/set-typing-guard/worker@test", { mode: "draft-aware", actor: "spoof", reason: "draft" })).status).toBe(200);
expect(f.db.prepare("SELECT actor FROM seat_delivery_guards").get()).toEqual({ actor: "human@test" });
const response = await f.post("/api/transport/send", { session: "worker@test", text: "http message", deliveryId: "http", actorSession: "spoof", verify: true });
expect(await response.json()).toMatchObject({ outcome: "retained", reason: "draft_input_busy", outboxIds: ["http"], delivery: { state: "held" } });
const page = await (await f.app.request("/api/seat/held-messages/worker@test")).json();
expect(page.items[0]).toMatchObject({ senderSession: "human@test", body: "http message", delivery: { state: "held", reason: "draft_input_busy" } });
expect(f.db.prepare("SELECT identity_provenance FROM outbox_entries").get()).toEqual({ identity_provenance: "transport:v1" });
expect(await (await f.app.request("/api/seat/status/worker@test")).json()).toMatchObject({ typingGuard: { effectiveMode: "draft-aware", heldCount: 1 } });
expect((await f.post("/api/seat/retire-held-message/sibling@test/http", { reason: "read" })).status).toBe(404);
expect((await f.post("/api/seat/retire-held-message/worker@test/http", { reason: "read" })).status).toBe(200);
expect(await (await f.app.request("/api/seat/held-messages/worker@test?id=http")).json()).toMatchObject({ entry: { deliveryState: "retired" }, delivery: { state: "held", reason: "message_retired" } });
f.bodies.a = ""; expect(await f.send("http", "http message")).toMatchObject({ outcome: "retained" }); expect(f.writes).toEqual([]);
});
it("returns a successful HTTP delivery once, preserving its original row", async () => {
const f = fixture(); await f.enable();
const send = () => f.post("/api/transport/send", { session: "worker@test", text: "http success", deliveryId: "http-success", verify: true });
expect(await (await send()).json()).toMatchObject({ ok: true, verified: true, outboxIds: ["http-success"] });
expect(await (await send()).json()).toMatchObject({ delivery: { state: "complete" } });
expect(f.db.prepare("SELECT count(*) n FROM outbox_entries").get()).toEqual({ n: 1 }); expect(f.submissions).toEqual(["http success"]);
});
it("broadcast preserves one original record per recipient", async () => {
const f = fixture(); await f.enable();
expect(await (await f.post("/api/transport/broadcast", { sessions: ["worker@test", "sibling@test"], text: "broadcast message", verify: true })).json()).toMatchObject({ sent: 2, failed: 0 });
expect(f.db.prepare("SELECT count(*) n FROM outbox_entries").get()).toEqual({ n: 2 });
expect(f.submissions).toEqual(["broadcast message", "broadcast message"]);
});
it("rejects invalid modes, ambiguous controls and obsolete retry settings", async () => {
const f = fixture();
for (const body of [{ enabled: true, mode: "draft-aware", reason: "ambiguous" }, { reason: "missing" }, { enabled: "true", reason: "type" },
{ mode: "automatic", reason: "old mode" }, { mode: "draft-aware", maxAttempts: 3, reason: "obsolete" },
{ mode: "draft-aware", holdSeconds: 60, reason: "obsolete" }, { mode: "hold" }]) {
expect((await f.post("/api/seat/set-typing-guard/worker@test", body)).status).toBe(400);
}
expect((await f.post("/api/seat/set-typing-guard/worker@test", { mode: "hold", reason: "draft", actor: "spoof" }, "")).status).toBe(400);
});
it("hold keeps messages indefinitely while a sibling still receives sends", async () => {
const f = fixture(); await f.guard.set("a", { mode: "hold" }, "human@test", "manual terminal");
expect(await f.send("inbox")).toMatchObject({ outcome: "retained", reason: "typing_guard_enabled" });
expect(await f.transport.send("sibling@test", "sibling message", { verify: true })).toMatchObject({ ok: true });
await f.enable(); expect(await f.send("inbox")).toMatchObject({ outcome: "retained" });
expect(f.submissions).toEqual(["sibling message"]);
});
it("a held queue wake remains on the existing queue recovery path without a second scheduler", async () => {
const f = fixture(); await f.enable(); f.bodies.a = "draft";
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work" });
await vi.waitFor(() => expect(f.repo.getById(item.qitemId)!.lastNudgeResult).toBe("retained:draft_aware"));
const id = "wake-intent-" + item.qitemId;
expect(f.guarded.lookup(id)).toMatchObject({ state: "held", reason: "draft_input_busy" });
f.bodies.a = ""; await f.repo.drainPendingWakeIntents();
expect(f.outbox.getById(id)!.deliveryState).toBe("retained");
expect(f.repo.getById(item.qitemId)!.state).toBe("pending"); expect(f.writes).toEqual([]);
});
it("rechecks queue applicability before paste", async () => {
const f = fixture(); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
const id = "wake-intent-" + item.qitemId; f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
f.hooks.load = () => { f.db.prepare("UPDATE queue_items SET state='done' WHERE qitem_id=?").run(item.qitemId); };
await f.repo.drainPendingWakeIntents();
expect(f.guarded.lookup(id)).toMatchObject({ state: "held", reason: "draft_wake_superseded" });
expect(f.writes).toEqual([]);
});
it.each([false, true])("coalesced wakes preserve every original ID and body (held: %s)", async held => {
const f = fixture(); await f.enable(); if (held) f.bodies.a = "draft"; const ids: string[] = [];
for (let i = 0; i < 2; i++) {
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work " + i, nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
const id = "wake-intent-" + item.qitemId; ids.push(id);
f.db.prepare("UPDATE outbox_entries SET tags=? WHERE outbox_id=?").run(JSON.stringify(["queue:return:common"]), id);
}
const original = ids.map(id => f.outbox.getById(id)!.body);
await f.repo.drainPendingWakeIntents();
expect(f.guarded.lookup(ids[1]!)).toEqual(f.guarded.lookup(ids[0]!));
f.bodies.a = ""; await f.repo.drainPendingWakeIntents();
expect(f.submissions).toHaveLength(held ? 0 : 1);
ids.forEach((id, i) => expect(f.outbox.getById(id)).toMatchObject({ body: original[i], deliveryState: held ? "retained" : "delivered" }));
expect(f.db.prepare("SELECT count(*) n FROM outbox_entries").get()).toEqual({ n: 2 });
});
});
+53
View File
@@ -0,0 +1,53 @@
// A deterministic editable terminal, not a provider emulator. It consumes real
// bracketed paste/Enter bytes so the native test can count actual submissions.
import { writeFileSync, renameSync } from "node:fs";
import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile] = process.argv.slice(2);
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (runtime === "claude") rows.push("────────────────────────────────────────");
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
}
const beforeCursor = input.slice(0, cursor).split("\n");
process.stdout.write(`\x1b[2J\x1b[H${rows.join("\r\n")}\x1b[${start + beforeCursor.length};${3 + stringWidth(beforeCursor.at(-1))}H`);
writeFileSync(`${stateFile}.tmp`, JSON.stringify({ input, submissions }));
renameSync(`${stateFile}.tmp`, stateFile);
}
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?2004h");
process.stdin.on("data", data => {
pending += decoder.write(data);
while (pending.length) {
if (pending.startsWith("\x1b[200~")) { pasting = true; pending = pending.slice(6); continue; }
if (pending.startsWith("\x1b[201~")) { pasting = false; pending = pending.slice(6); continue; }
if (pending[0] === "\x1b" && pending.length < 6) break;
const char = pending[0]; pending = pending.slice(1);
if (pasting || char >= " ") {
if (!pasting && char === "\x7f") {
if (cursor > 0) { input = input.slice(0, cursor - 1) + input.slice(cursor); cursor--; }
} else { input = input.slice(0, cursor) + (char === "\r" ? "\n" : char) + input.slice(cursor); cursor++; }
} else if (char === "\r" || char === "\n") { submissions.push(input); input = ""; cursor = 0; }
else if (char === "\x15") { input = ""; cursor = 0; }
else if (char === "\x01") cursor = 0;
else if (char === "\x05") cursor = input.length;
else if (char === "\x14") ghost = !ghost;
}
render();
});
render();
+1
View File
@@ -155,6 +155,7 @@ export const migrationsForFullTestDbExclusions: Record<string, string> = {
"029_classifier_leases.sql": "project-classification subsystem table — classifier-lease suites migrate it inline.",
"086_classification_fields_and_attempts.sql": "extends 028 (excluded here) and adds the S02 attempt ledger — classifier suites migrate it inline.",
"087_seat_delivery_guard.sql": "extends outbox_entries (027 is excluded); delivery-guard suites use ALL_MIGRATIONS. Revisit if a core-fixture consumer uses retained delivery.",
"100_typing_guard_modes.sql": "extends outbox_entries and its typing guard (027 and 087 are excluded); guard-mode and retry suites use ALL_MIGRATIONS.",
"089_classification_identity_provenance.sql": "extends project_classifications (028 is excluded); classifier fixtures migrate it inline.",
"030_views_custom.sql": "custom-views subsystem table — views suites migrate it inline.",
"032_watchdog_history.sql": "watchdog history table — watchdog suites migrate it inline (watchdog_jobs is the only watchdog base the core edge carries).",
@@ -81,6 +81,25 @@ describe("stub CLI journeys", () => {
// Exact arrays, not subset matches: an extra sibling is a failure.
}, 60_000);
it("hold mode retains a CLI send outside its pane and exposes its original ID", async () => {
const marker = `typing-guard-${randomUUID()}`;
try {
const preference = await cli(["seat", "set-typing-guard", recipient, "--mode", "hold", "--reason", "fixture manual input", "--json"]);
expect(preference).toMatchObject({ effective: true, effectiveMode: "hold", pending: false });
const receipt = await cli(["send", recipient, marker, "--verify", "--json"]);
expect(receipt).toMatchObject({ outcome: "retained", sent: false, verified: false });
expect(receipt.outboxIds).toHaveLength(1);
const id = receipt.outboxIds[0];
const retained = await cli(["seat", "held-messages", recipient, "--id", id, "--json"]);
expect(retained.entry).toMatchObject({ outboxId: id, deliveryState: "retained", destinationSession: recipient });
expect(retained.entry.body).toContain(marker);
expect(JSON.stringify(await cli(["capture", recipient, "--json"]))).not.toContain(marker);
expect(await cli(["seat", "status", sender, "--json"])).toMatchObject({ typingGuard: { effective: false, effectiveMode: "off" } });
} finally {
await cli(["seat", "set-typing-guard", recipient, "--enabled", "false", "--reason", "fixture cleanup", "--json"]);
}
}, 60_000);
it("replays one exact stream item after restart and an idempotent emit retry", async () => {
const id = `journey-${randomUUID()}`;
const tag = `journey-tag-${randomUUID()}`;
@@ -36,9 +36,12 @@ interface RunCall { site: number; argv: string[]; legacy: string }
const SESSION = "seat'q a";
const TARGET = { nodeId: "node-1", session: SESSION, occupant: "occupant-1", pane: "%1" };
function respond(cmd: string, failing: string[]): string {
function respond(cmd: string, failing: string[], composer = false): string {
const sub = cmd.split(" ")[1] ?? "";
if (failing.includes(sub)) throw new Error(`Command failed: ${cmd}\ncan't find session: missing`);
// #1072's composer snapshot reads a cursor and a screen before its mode check; other calls keep their replies.
if (composer && sub === "display-message" && cmd.includes("cursor_x")) return "2|0|80|24\n";
if (composer && sub === "capture-pane") return "❯ \n";
switch (sub) {
case "list-sessions": return `${SESSION}\nalpha\n`;
case "list-panes": return "%1|0|/w|80|24|1\n";
@@ -51,7 +54,7 @@ function respond(cmd: string, failing: string[]): string {
}
/** A shell-path adapter whose every run() call records its caller line, its argv and the string exec received. */
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean } = {}): TmuxAdapter {
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean } = {}): TmuxAdapter {
const execs: string[] = [];
const fileOps: TmuxFileOps = {
writeFile: async () => {},
@@ -61,7 +64,7 @@ function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?:
};
const adapter = new TmuxAdapter(async (cmd) => {
execs.push(cmd);
return respond(cmd, opts.failing ?? []);
return respond(cmd, opts.failing ?? [], opts.composer);
}, fileOps);
if (opts.guard) {
adapter.deliveryGuard = {
@@ -72,6 +75,11 @@ function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?:
input: (_name: string, fn: () => Promise<unknown>) => fn(),
operation: (_name: string, fn: () => Promise<unknown>) => fn(),
humanInput: (_name: string, fn: () => Promise<unknown>) => fn(),
// The draft-aware hooks (#1072); off here, so input takes the same path as without them.
attachInputInspection: () => {},
needsInputCheck: () => false,
beforeInput: async () => {},
notePasted: async () => {},
} as unknown as SeatDeliveryGuard;
}
const self = adapter as unknown as { run: (argv: string[], legacy?: string) => Promise<string> };
@@ -143,6 +151,7 @@ async function driveEveryMethod(): Promise<RunCall[]> {
// A failed paste deletes the loaded buffer; the guarded kill reads the session id first.
await recordingAdapter(calls, { failing: ["paste-buffer"] }).sendText(SESSION, "hello");
await recordingAdapter(calls, { guard: true }).killSession(SESSION);
await recordingAdapter(calls, { composer: true }).captureComposerSnapshot("%1");
return calls;
}
@@ -0,0 +1,296 @@
import Database from "better-sqlite3";
import stringWidth from "string-width";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { SeatDeliveryGuard, type GuardTarget } from "../src/domain/seat-delivery-guard.js";
import { parseTypingGuardSettings } from "../src/domain/seat-delivery-guard.js";
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { seatDeliveryGuardSchema } from "../src/db/migrations/087_seat_delivery_guard.js";
import { typingGuardModesSchema } from "../src/db/migrations/100_typing_guard_modes.js";
const cleanup: Array<() => void> = [];
afterEach(() => cleanup.splice(0).reverse().forEach(fn => fn()));
function screen(body: string): ComposerSnapshot {
const rows = body.split("\n");
return { screen: ["Ready", "────────────────────", `❯ ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "────────────────────", "? for shortcuts", ""].join("\n"),
cursor: { x: 2 + stringWidth(rows.at(-1)!), y: 1 + rows.length, width: 120, height: 40 }, inMode: false };
}
function fixture() {
const db = new Database(":memory:"); cleanup.push(() => db.close());
db.exec("CREATE TABLE nodes(id TEXT PRIMARY KEY); INSERT INTO nodes VALUES ('a'),('b');");
db.exec(outboxEntriesSchema.sql); db.exec(seatDeliveryGuardSchema.sql); db.exec(typingGuardModesSchema.sql);
const targets: Record<string, GuardTarget> = {
a: { nodeId: "a", session: "a", occupant: "g1", pane: "%1" },
b: { nodeId: "b", session: "b", occupant: "g2", pane: "%2" },
};
const guard = new SeatDeliveryGuard(db, name => Object.values(targets).find(t => t.nodeId === name || t.session === name || t.pane === name) ?? null);
const panes: Record<string, ComposerSnapshot | null> = { a: screen(""), b: screen("") };
const writes: string[] = [];
let staged = "";
let onLoad: (() => void) | undefined;
let renderPaste: ((text: string) => string) | undefined;
const tmux = new TmuxAdapter(async command => {
if (command.includes("load-buffer")) onLoad?.();
if (command.includes("paste-buffer")) {
const name = command.includes("%2") ? "b" : "a";
writes.push(`paste:${name}:${staged}`); panes[name] = screen(renderPaste?.(staged) ?? staged);
}
if (command.includes("send-keys")) writes.push(command);
return "";
}, { writeFile: async (_path, text) => { staged = text; }, unlink: async () => {}, tmpName: () => "/fixture/input", bufferName: () => "fixture-buffer" });
tmux.deliveryGuard = guard;
vi.spyOn(tmux, "listPanes").mockImplementation(async name => {
const t = guard.maybeTarget(name);
return t?.pane ? [{ id: t.pane, index: 0, cwd: "/fixture", width: 120, height: 40, active: true }] : [];
});
vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async name => panes[guard.target(name).nodeId] ?? null);
vi.spyOn(tmux, "probeSession").mockResolvedValue({ state: "present" });
vi.spyOn(tmux, "isPaneDead").mockResolvedValue(false);
return { db, guard, targets, panes, writes, tmux,
onLoad: (fn: () => void) => { onLoad = fn; }, renderPaste: (fn: (text: string) => string) => { renderPaste = fn; } };
}
describe("typing guard modes", () => {
it.each(["unchanged", "on", "off", "off-on"])("keeps rollback protection and honors the last legacy write (%s)", async setting => {
const f = fixture();
await f.guard.set("a", { mode: "draft-aware" }, "operator", "protect input");
expect(f.db.prepare("SELECT desired,effective FROM seat_delivery_guards WHERE node_id='a'").get()).toEqual({ desired: 1, effective: 1 });
f.db.exec("INSERT INTO outbox_entries(outbox_id,sender_session,destination_session,body,delivery_state,ts_dispatched) VALUES ('held','human','a','keep held','retained','fixture'),('uncertain','human','a','keep uncertain','indeterminate','fixture')");
// Main's boolean writer also audits 1 -> 1. Reuse changed_at deliberately:
// two writes in one clock tick must still be ordered by the audit row ID.
const legacySet = (enabled: number) => f.db.transaction(() => {
const row = f.db.prepare("SELECT desired,effective,changed_at FROM seat_delivery_guards WHERE node_id='a'").get() as { desired: number; effective: number; changed_at: string };
f.db.prepare("UPDATE seat_delivery_guards SET desired=?,effective=?,actor='legacy',reason='legacy setting' WHERE node_id='a'").run(enabled, enabled);
f.db.prepare("INSERT INTO seat_delivery_guard_changes(node_id,desired,previous_desired,previous_effective,actor,reason,requested_at,effective_at) VALUES ('a',?,?,?,'legacy','legacy setting',?,?)")
.run(enabled, row.desired, row.effective, row.changed_at, row.changed_at);
})();
if (setting === "on") legacySet(1);
if (setting === "off") legacySet(0);
if (setting === "off-on") { legacySet(0); legacySet(1); }
const reopened = new SeatDeliveryGuard(f.db, name => f.targets[name] ?? null);
reopened.recoverActivation();
const expected = setting === "unchanged" ? "draft-aware" : setting === "off" ? "off" : "hold";
expect(reopened.preference("a")).toMatchObject({ desiredMode: expected, effectiveMode: expected, pending: false });
if (expected === "hold") await expect(reopened.operation("a", async () => { throw new Error("must not write"); })).rejects.toMatchObject({ code: "typing_guard_enabled" });
expect(f.db.prepare("SELECT body,delivery_state FROM outbox_entries ORDER BY outbox_id").all()).toEqual([
{ body: "keep held", delivery_state: "retained" }, { body: "keep uncertain", delivery_state: "indeterminate" },
]);
expect(f.writes).toEqual([]);
});
it.each(["ordinary request ", "1. Review the change", "2) Review the change"])("submits its exact ordinary text once: %j", async text => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "preserve draft");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", text)).toEqual({ ok: true });
expect(await f.tmux.sendKeys("a", ["Enter"])).toEqual({ ok: true });
});
expect(f.writes[0]).toBe("paste:a:" + text);
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(1);
});
it.each(["padded", "trimmed"])("uses the cursor to distinguish authored trailing spaces from %s capture cells", async capture => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "preserve spaces");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "ordinary request ")).toEqual({ ok: true });
const pane = f.panes.a!;
const lines = pane.screen.split("\n");
lines[2] = capture === "padded" ? lines[2]!.padEnd(120) : lines[2]!.trimEnd();
pane.screen = lines.join("\n");
expect(await f.tmux.sendKeys("a", ["Enter"])).toEqual({ ok: true });
});
});
it("refuses a real numbered menu before any paste and edits to its numbered body after paste", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "protect menus");
f.panes.a = screen("1. Approve the command");
expect(await f.tmux.sendText("a", "1. Review the change")).toMatchObject({ code: "draft_input_unknown" });
expect(f.writes).toEqual([]);
f.panes.a = screen("");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "1. Review the change")).toEqual({ ok: true });
f.panes.a = screen("1. Review the change with a human edit");
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: false });
});
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(0);
});
it.each([false, true])("checks several paste placeholders and their literal tail (edited: %s)", async edited => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "large paste");
const tail = "This literal tail belongs only to the original message and provides enough visible identity.";
const text = ["hidden one", "hidden two", "hidden three", "hidden four", tail].join("\n");
const rendered = "[Pasted text #4 +2 lines]\n[Pasted text #5 +2 lines]\n" + tail;
f.renderPaste(() => rendered);
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", text)).toEqual({ ok: true });
if (edited) f.panes.a = screen(rendered + " human edit");
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: !edited });
});
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(edited ? 0 : 1);
});
it("upgrades the existing boolean control and retained outbox without adding any tables", async () => {
const db = new Database(":memory:"); cleanup.push(() => db.close());
db.exec("CREATE TABLE nodes(id TEXT PRIMARY KEY); INSERT INTO nodes VALUES ('a');");
db.exec(outboxEntriesSchema.sql); db.exec(seatDeliveryGuardSchema.sql);
const target = { nodeId: "a", session: "a", occupant: "g1", pane: "%1" };
const legacy = new SeatDeliveryGuard(db, () => target);
await legacy.set("a", true, "operator", "existing protection");
db.exec("INSERT INTO outbox_entries(outbox_id,sender_session,destination_session,body,delivery_state,ts_dispatched) VALUES ('old','human','a','keep these bytes','retained','2026-10-01T00:00:00Z')");
const tables = () => db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
const before = tables();
db.exec(typingGuardModesSchema.sql);
expect(tables()).toEqual(before);
const upgraded = new SeatDeliveryGuard(db, () => target);
upgraded.recoverActivation();
expect(upgraded.preference("a")).toMatchObject({ desired: true, effective: true, desiredMode: "hold", effectiveMode: "hold", pending: false });
expect(db.prepare("SELECT body,delivery_state,guard_delivery FROM outbox_entries WHERE outbox_id='old'").get())
.toEqual({ body: "keep these bytes", delivery_state: "retained", guard_delivery: null });
await upgraded.set("a", { mode: "draft-aware" }, "operator", "wait for clear input");
await upgraded.set("a", false, "operator", "legacy off");
expect(upgraded.preference("a")).toMatchObject({ effective: false, effectiveMode: "off" });
await upgraded.set("a", true, "operator", "legacy hold");
expect(upgraded.preference("a")).toMatchObject({ effective: true, effectiveMode: "hold" });
expect(db.prepare("SELECT count(*) n FROM seat_delivery_guards").get()).toEqual({ n: 1 });
});
it("does not treat an adopted existing pane as a newly created lifecycle shell", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
f.targets.a = { ...f.targets.a!, pane: null };
await f.guard.lifecycle(["a"], async () => {
f.targets.a = { ...f.targets.a!, pane: "%3", occupant: "adopted" };
f.guard.rebindLifecycle("a");
f.panes.a = screen("existing human draft");
expect(await f.tmux.sendText("a", "adoption hint")).toMatchObject({ ok: false, code: "draft_input_busy" });
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: false, code: "draft_input_changed" });
});
expect(f.writes).toEqual([]);
});
it("holds literal placeholder text when the operator moved the cursor to its start", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
const draft = screen("Ask Codex to do anything"); draft.cursor.x = 2;
f.panes.a = draft;
expect(await f.tmux.sendText("a", "message")).toMatchObject({ ok: false, code: "draft_input_busy" });
expect(f.writes).toEqual([]);
});
it("defaults off and rejects invalid modes and obsolete retry settings", () => {
const f = fixture();
expect(f.guard.configuration("a").effective.mode).toBe("off");
expect(parseTypingGuardSettings({ mode: "draft-aware" })).toEqual({ mode: "draft-aware" });
for (const input of [{ mode: "typo" }, { mode: "draft-aware", holdSeconds: 0 }, { mode: "draft-aware", holdSeconds: Infinity },
{ mode: "draft-aware", holdSeconds: 3601 }, { mode: "draft-aware", holdSeconds: null },
{ mode: "draft-aware", maxAttempts: null }, { mode: "draft-aware", maxAttempts: 1.5 }, { mode: "draft-aware", maxAttempts: 101 }]) {
expect(() => parseTypingGuardSettings(input)).toThrow();
}
});
it("activates after the existing operation, survives restart and keeps same-setting activation idempotent", async () => {
const f = fixture(); let release!: () => void; let entered!: () => void;
const started = new Promise<void>(resolve => { entered = resolve; });
const held = new Promise<void>(resolve => { release = resolve; });
const operation = f.guard.operation("a", async () => { entered(); await held; });
await started;
const pending = await f.guard.set("a", { mode: "draft-aware" }, "operator", "protect my input", 1);
expect(pending).toMatchObject({ desiredMode: "draft-aware", effectiveMode: "off", pending: true });
release(); await operation; await new Promise(resolve => setTimeout(resolve, 0));
const active = f.guard.configuration("a");
expect(f.guard.preference("a")).toMatchObject({ effectiveMode: "draft-aware", pending: false });
await f.guard.set("a", { mode: "draft-aware" }, "operator", "same preference");
expect(f.guard.configuration("a").effective.revision).toBe(active.effective.revision);
const desired = { ...active.desired, mode: "hold", revision: "restart-activation" };
f.db.prepare("UPDATE seat_delivery_guards SET desired=1,desired_config=? WHERE node_id='a'").run(JSON.stringify(desired));
f.db.prepare("UPDATE seat_delivery_guard_changes SET desired_config=? WHERE id=(SELECT max(id) FROM seat_delivery_guard_changes)").run(JSON.stringify(desired));
f.guard.recoverActivation();
expect(f.guard.preference("a")).toMatchObject({ desired: true, effective: true, effectiveMode: "hold", pending: false });
});
it("hold mode prevents raw automatic input but preserves human input and siblings", async () => {
const f = fixture(); await f.guard.set("a", { mode: "hold" }, "operator", "manual terminal");
expect(await f.tmux.sendText("a", "automatic")).toMatchObject({ ok: false, code: "typing_guard_enabled" });
expect(f.writes).toEqual([]);
expect(await f.tmux.humanInput("a", () => f.tmux.sendText("a", "human"))).toEqual({ ok: true });
expect(await f.tmux.sendText("b", "sibling")).toEqual({ ok: true });
expect(f.writes).toEqual(["paste:a:human", "paste:b:sibling"]);
});
it("observes the draft after buffer preparation and never pastes on unreadable input", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
f.onLoad(() => { f.panes.a = screen("a draft appeared during file preparation"); });
expect(await f.tmux.sendText("a", "message")).toMatchObject({ ok: false, code: "draft_input_busy" });
expect(f.writes).toEqual([]);
f.onLoad(() => { f.panes.a = null; });
expect(await f.tmux.sendText("a", "message")).toMatchObject({ ok: false, code: "draft_input_unknown" });
expect(f.writes).toEqual([]);
});
it("submits owned text once and refuses a draft that appears before Enter", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "first message")).toEqual({ ok: true });
expect(await f.tmux.sendKeys("a", ["Enter"])).toEqual({ ok: true });
});
f.panes.a = screen("");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "second message")).toEqual({ ok: true });
f.panes.a = screen("second message and an unfinished human input");
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: false, code: "draft_input_changed" });
});
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(1);
});
it("remembers only the opaque paste observed by this lease and rejects a replacement label", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
f.renderPaste(() => "[Pasted text #4 +2 lines]");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "one\ntwo\nthree")).toEqual({ ok: true });
f.panes.a = screen("[Pasted text #5 +2 lines]");
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: false, code: "draft_input_changed" });
});
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(0);
});
it("a brokered human action invalidates the automatic submission even if the visible text is unchanged", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
await f.guard.operation("a", async () => {
expect(await f.tmux.sendText("a", "owned")).toEqual({ ok: true });
await f.guard.humanInput("a", async () => {});
expect(await f.tmux.sendKeys("a", ["Enter"])).toMatchObject({ ok: false, code: "draft_input_changed" });
});
expect(f.writes.filter(line => line.includes("send-keys"))).toHaveLength(0);
});
it("refuses lifecycle effects on an active protected seat but permits a fresh, owned launch", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "keep my draft");
let effects = 0;
await expect(f.guard.lifecycle(["a"], async () => { effects++; })).rejects.toMatchObject({ code: "draft_aware_lifecycle" });
expect(effects).toBe(0);
f.targets.a = { ...f.targets.a!, pane: null };
await f.guard.lifecycle(["a"], async () => {
vi.mocked(f.tmux.listPanes).mockResolvedValueOnce([{ id: "%3", index: 0, cwd: "/fixture", width: 120, height: 40, active: true }]);
expect(await f.tmux.createSession("a", undefined, { OPENRIG_NODE_ID: "a" })).toEqual({ ok: true });
f.targets.a = { ...f.targets.a!, pane: "%3", occupant: "new" };
f.guard.rebindLifecycle("a");
f.panes.a = null;
expect(await f.tmux.sendText("a", "launch the runtime")).toEqual({ ok: true });
expect(await f.tmux.sendKeys("a", ["Enter"])).toEqual({ ok: true });
});
expect(f.writes).toHaveLength(2);
});
it("grants startup input only after a dead pane was successfully respawned", async () => {
const f = fixture(); await f.guard.set("a", { mode: "draft-aware" }, "operator", "protect resumed input");
vi.mocked(f.tmux.isPaneDead).mockResolvedValue(true); f.panes.a = null;
await f.guard.lifecycle(["a"], async () => {
expect(await f.tmux.sendText("a", "premature input")).toMatchObject({ ok: false, code: "draft_input_unknown" });
expect(await f.tmux.respawnPane("%1", "/bin/sh")).toEqual({ ok: true });
expect(await f.tmux.sendText("a", "launch the runtime")).toEqual({ ok: true });
expect(await f.tmux.sendKeys("a", ["Enter"])).toEqual({ ok: true });
});
expect(f.writes).toHaveLength(2);
});
});