mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b72acd57 | ||
|
|
dd500bc5cd | ||
|
|
b4dc9f1a6f | ||
|
|
0c1a9a567b | ||
|
|
fff202d831 | ||
|
|
f1fe5338c4 | ||
|
|
1c2bdf2273 | ||
|
|
716a3525c8 | ||
|
|
7750077e15 | ||
|
|
5f658464c3 | ||
|
|
6c70ac2f48 | ||
|
|
92a2b8f715 | ||
|
|
11cd9112c0 | ||
|
|
e26f2ec1aa | ||
|
|
74d4876172 | ||
|
|
941ee628b5 | ||
|
|
fcef3b7104 | ||
|
|
a6af6ede4a | ||
|
|
45f8c8f061 | ||
|
|
81d5a51a55 | ||
|
|
afab6befa2 | ||
|
|
86fe0a48e7 | ||
|
|
f15ad1c0d9 | ||
|
|
4205323187 |
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,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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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]); }
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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();}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user