Compare commits

..
Author SHA1 Message Date
dev-guard 2a9c204f2b fix(compaction): record content-free input refusal diagnostics 2026-11-12 07:38:54 +00:00
12 changed files with 243 additions and 134 deletions

No files matched your search

+22 -58
View File
@@ -13,13 +13,12 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
an answer, the answer now goes to an agent that reads it with the question and
decides. When no receiving agent is available, your answer waits. Setup's
guidance asks your agent to offer to register you as the team's person, and
registering works when you ask for it. `rig down` no longer stops a terminal it
decides. Answers go to the configured receiving agent or kernel operator when
available; otherwise they wait. Setup offers to register you as the team's
person. `rig down` no longer stops a terminal it
can't show belongs to the seat. There are fixes across delivery, Codex,
restore, the queue, Slack and speed, many contributed by people using
OpenRig. Two repairs aren't in this release, and some checks weren't run: see
Known limits.
OpenRig. Two repairs aren't in this release: see Known limits.
### Before you upgrade
@@ -27,8 +26,7 @@ Known limits.
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
OpenRig didn't create, a mismarked one or a replaced one is left running, and
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
`rig attach` as an external CLI is listed as not stopped, since OpenRig never
stops it; that alone doesn't make `rig down` exit 2. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
can ask for hook approval until it's relaunched.
@@ -36,29 +34,15 @@ Known limits.
- Answers nothing else reads, such as a host pairing request or a mode's
scheduled end, use the configured receiving agent or the kernel operator.
Answers wait when no receiver is available, and nothing decides them
meanwhile. To decide one yourself, use its own command: `rig host approve` or
`rig host deny` for a pairing request, `rig seat decide-permission` for a
runner's request. Closing an ask isn't permission, and words that were kept
aren't a default permit. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
- Pairing answers go to the receiving agent, and you can decide directly in the
TUI's pairing settings. A pairing request says who it went to and who reads
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
- A runner's permission answer goes to the receiving agent, which decides while
the runner waits, so a qualified answer such as "only if it's the test file"
is read before anything acts. Your answer is kept and an explicit decision
works, but the runner's confirmation updates to you aren't written in this
release. Don't expect a confirmation in the Feed, a notice of the receiver's
state or an immediate wake: the decision reaches the runner at its next read
(up to about 21 seconds in testing). To decide one yourself:
`rig seat decide-permission <askId> --option <id> --reason "…"`. This is the
runner's path only: a pairing request says who reads the answer, as above. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
- Setup's guidance asks your agent to offer to register you as the team's
person. In testing the agent didn't reliably make that offer (0 of 5 runs).
Registering works when you ask for it: `rig gateway human add`. Nothing is
registered without your yes, an existing default person is kept, and no person
is chosen automatically. The empty-registry hint is right. A team you start
from your own assistant fits your own logins,
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
- Setup offers to register you as the team's person, and the empty-registry
hint is right. A team you start from your own assistant fits your own logins,
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
### Harnesses and connections
@@ -96,9 +80,8 @@ Known limits.
- **Delivery and drafts:** Claude seats with a custom status line read idle and
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
Claude's context hint and drawn cursor are read correctly; when the input
can't be read, a deliberate override (`--dangerously-interact`) pastes with a
warning and doesn't press Enter, so it isn't submitted; update notices beside the
Claude's context hint and drawn cursor are read correctly; a deliberate
override is delivered when the input can't be read; update notices beside the
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
delivered instead of escalated; a working counter is compared only within one pane process; hook
@@ -131,9 +114,8 @@ Known limits.
config accepts two keys the daemon already resolves; `rig whoami` says the
team is still being built during `rig up`; rigs with `@` in the name find
attention; a seat waiting on a person names its question; the installer
reports PATH gaps with shell hints; daemon credentials stay out of a tmux
server's environment from its next start (a server already running keeps its
old environment until it restarts). ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
- **Pi and OMP:** typed edits submit in order on the affected Node releases
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
@@ -179,35 +161,17 @@ Known limits.
- **Custom blocker IDs** explain the wait, but their progress gives no event
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
- **Delivery evidence** records what was known, not that the agent read the
message; where the input ended up can read unknown. Events are best effort, so
a missing event isn't proof nothing was sent. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers** were run end to end with a real receiving
agent and the example runner, whose requesting agent was a stand-in; a
model-driven runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers:** when a person answers a runner's permission
request, the answer is kept, and an explicit decision works. In 0.7.12 the
runner's confirmation updates fail to record, so no receipt or Feed
confirmation appears, and the decision can reach the runner on its next read
rather than at once (up to about 21 seconds in our test). To settle a request
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
answers are separate, and their receipts are kept. Tested on one native daemon
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
- **A receiving agent you choose** (`agents.operator_session`) that runs in an
explicit or auto permission mode gets no OpenRig allowance for
`rig host approve` or `rig host deny`, so Claude's review can refuse them. A
kernel receiver in auto mode is included. The default kernel with the default
policy passed at its tested scope.
- **A receiver that was stopped and then started** may not act on an answer that
waited for it. In the tested case where it needed to ask you something back,
the question appeared in its own conversation, not in the Feed or Steering.
- **Delivery isn't fixed in general:** a footer row OpenRig doesn't recognize can
still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)), and a held message's full envelope can stay held;
clearing a draft doesn't deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)).
- **Not run for this release** (not run, not passed): the two-seat Codex journey
with `AGENTS.md` restore; Codex account-home, upgrade and continuity checks;
the OpenShell worker's second report; Pi model switches on real providers; ACP
in a desktop app; Slack directory paging; some Slack setup cases (channel and
thread precedence, a stopped receiver's delayed pickup, an inherited inbound
default, a plain yes with no receiver); native checks of [#2786](https://github.com/mvschwarz/openrig/pull/2786), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2807](https://github.com/mvschwarz/openrig/pull/2807)
and [#2813](https://github.com/mvschwarz/openrig/pull/2813), with [#2947](https://github.com/mvschwarz/openrig/pull/2947) partly; a button-and-words answer, a non-pairing ask
with an explicit owner, Steering's Approve page, and these answer and setup
checks on a default model (the tested runs used smaller models);
`--wait-for-kernel` ([#2888](https://github.com/mvschwarz/openrig/pull/2888)), the installed effect of [#2800](https://github.com/mvschwarz/openrig/pull/2800), and [#2720](https://github.com/mvschwarz/openrig/issues/2720) beyond one
platform.
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
again, so the Codex hook note there applies to this upgrade too. They weren't
+12
View File
@@ -226,6 +226,18 @@ retry is scheduled. The reasons are:
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
The last recorded managed-compaction input refusal appears as `inputReadFailure` in the attempt
record and `rig compact --state`: the preparation or compact stage, `composer`
(the explicit composer hold) or `draft-aware` check, reader status, confidence,
a fixed read-failure reason and cursor flags. An owed restore step reports it in
`owedRestore.lastDelivery`. These fields contain no input text, suggestions,
terminal frames or exception messages. Confidence and timeout fields are `null`
when that reader does not supply them; a known composer read timeout reports
`timedOut: true`. A generic `capture_unavailable` does not identify the failed
capture operation. Older records lack this diagnostic. It neither changes the
refusal nor retries an attempt; missing metadata cannot establish which check
caused a historical refusal.
Clearing a draft, changing modes or restarting does not flush saved messages or
callbacks. To try again, send a current request: it rechecks the recipient and input.
A caller abort before paste cancels input; after a write may have started, the
+2 -5
View File
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
results to relay." Carry their constraints and any declined permission choices in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
seat a model their account can run with `rig seat set-model`, then start it again with
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
operator is still pending.
@@ -207,9 +207,6 @@ person needs in their project or home folder and name that path.
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
their project doesn't show what the action touches. Their instruction changes no permission setting.
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
@@ -119,8 +119,7 @@ configurations, as below; do not take the pin from a cached page summary.
from the installing agent; do not ask for them again. If the goal is missing,
ask once: "What would you like to build or change?" If they already named a
team, use it and skip the questions it answers. Keep any choice the
handoff carries, such as a trust or hook-review prompt they declined or
a permission choice they made.
handoff carries, such as a trust or hook-review prompt they declined.
2. **Where it works.** If the folder is missing, ask which folder the team should
work in (usually a clone of their repository) and use its absolute path. Your
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
@@ -250,33 +249,11 @@ configurations, as below; do not take the pin from a cached page summary.
separates project files, not that Codex home. `rig setup` lists these writes. If they'd rather keep
their folder as it is, offer a separate copy, such as a git worktree.
If the plan declares non-interruptive or broad access, say so plainly before asking for their yes.
Before their yes, explain the team's permission defaults. Claude's team
default adds allowances for ordinary `rig` commands, project reads and
common tests. Branches, commits, running their program, installs and other
shell commands may ask, depending on existing native rules. A Codex seat
uses its configured writable roots and may ask for broader access. When a
prompt appears, they can answer in that seat's terminal or through you.
Their own agent's mode isn't the team's; existing user and project rules
can still affect the seats. Their yes starts the team; it isn't a yes to
later prompts.
- **A permission choice already made** (in the handoff or to you: fewer
prompts for this project, a mode, a rule, or keep prompts on): use it,
and don't ask again, when it covers the commands this goal will repeat
in this project. Keeping prompts on covers them all. A choice for other
commands or another scope, such as a user-wide rule for `rig` commands,
doesn't: make the one offer below for the commands it leaves out.
- **Otherwise, one offer if they want fewer interruptions:** name the
command families this goal will repeat in their folder (for example
`git switch`, `git add`, `git commit` and the project's test command)
and offer to allow just those, for this project only. For Claude seats
that's allow rules in the project's `.claude/settings.local.json`,
normally loaded by Claude sessions in that folder, including their own,
under the default setting sources; for a Codex seat, follow the same
skill's Codex rules. On their yes, apply it with
`applying-a-permission-policy`. Say what still asks: a command joined
with `&&`, `;` or a pipe asks unless every part is allowed, and on Claude
seats OpenRig's lifecycle commands still ask. On a no, keep the team
default and don't offer again.
If they keep permission prompts on, say what that will be like before
their yes: each seat stops and asks before an action its permissions
don't already cover, the asks come often, especially early, and each one
needs their answer, in the seat's pane or through you. Their yes starts
the team; it isn't a yes to those later asks.
For a team that already exists, `rig seat status <seat>` shows each seat's
planned permissions: the native mode, where it comes from, what OpenRig
adds and what isn't known. Read it to them as planned, not observed.
@@ -362,16 +339,10 @@ configurations, as below; do not take the pin from a cached page summary.
names. Tell them it's a usage limit, which seat, and when it resets, with
the menu's choices in its own words. One of them may spend usage credits,
so the choice is theirs.
When you see one, tell them where it is and where they can answer: which
seat, what its prompt asks (`rig capture <session>` with the seat's
`canonicalSessionName` shows it), and that seat's terminal, or through
you. A team seat's terminal is in the team's space from step 8. A kernel
seat's is in the kernel's view (`rig terminal open saved:kernel --window`;
the advisor has its own tab or window). Only when no space is open, give the
seat's `tmuxAttachCommand` as a command for a new terminal, with one
line on what it does (it shows that seat; closing the window leaves the
seat running), or just its name if it has none. Never send them to a
bare `tmux attach`. Don't answer another seat's prompt yourself.
When you see one, tell them where they already are: which seat, what its
prompt asks (`rig capture <session>` with the seat's
`canonicalSessionName` shows it) and the seat's `tmuxAttachCommand`, or
just its name if it has none. Don't answer another seat's prompt yourself.
With prompts on, several seats may wait at once. Gather them, then tell
the person in one message, one line per prompt: the seat, the exact
action it asks to take (the command or file), and the choices the prompt
@@ -395,14 +366,12 @@ configurations, as below; do not take the pin from a cached page summary.
and check the result before sending anything more; never resend an answer
whose effect you can't see. Otherwise they answer in the seat's pane.
Grouping can shorten the asking, not the number of prompts. If they find
the prompts too many and haven't already chosen, make step 6's one offer,
named from the commands that actually asked: allow those command families
for this project, applied on their yes with `applying-a-permission-policy`,
which says how to check it took effect in the seats. Their other choices
stay open: keep prompts on; take a prompt's own standing approval for one
command when it offers one; `rig seat set-permissions` for later launches;
or Workshop's broad-access bundle. An answer to a prompt never changes a
permission mode.
the prompts too many, lay out their choices without picking one: keep
prompts on; take a prompt's own standing approval for one command when it
offers one; or change the team's permissions deliberately, as a separate
choice (`applying-a-permission-policy`, `rig seat set-permissions` for
later launches, or Workshop's broad-access bundle). An answer to a prompt
never changes a permission mode.
Avoid these:
- launching a team without the person's yes;
+5 -3
View File
@@ -1,3 +1,4 @@
import type { InputReadDiagnostic } from "../domain/input-read-diagnostic.js";
import { DeliveryGuardError, type SeatDeliveryGuard } from "../domain/seat-delivery-guard.js";
import { writeFile as fsWriteFile, unlink as fsUnlink } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -77,7 +78,7 @@ export function defaultTmuxFileOps(): TmuxFileOps {
export type TmuxResult =
| { ok: true; warnings?: string[]; stopPhase?: StopPhase }
| { ok: false; code: string; message: string; stopPhase?: StopPhase };
| { ok: false; code: string; message: string; stopPhase?: StopPhase; inputReadFailure?: InputReadDiagnostic };
/** Furthest external stop boundary reached; an attempted command can have an uncertain effect. */
export type StopPhase = "none" | "detach_attempted" | "detached" | "kill_attempted";
@@ -246,7 +247,7 @@ function isTmuxTransportAbsentError(err: unknown): boolean {
}
function classifyWriteError(err: unknown): TmuxResult {
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message };
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message, ...(err.inputReadFailure ? { inputReadFailure: err.inputReadFailure } : {}) };
if (!(err instanceof Error)) {
return { ok: false, code: "unknown", message: String(err) };
}
@@ -600,7 +601,8 @@ export class TmuxAdapter {
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
});
} catch (error) {
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message),
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
}
}
@@ -1,3 +1,4 @@
import type { InputReadDiagnostic } from "./input-read-diagnostic.js";
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
import { TerminalPolicyRefusal } from "./terminal-policy.js";
import { DeliveryGuardError } from "./seat-delivery-guard.js";
@@ -425,6 +426,7 @@ type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_
/** Safe delivery diagnostics: never retain pane contents or an exception message. */
export interface RestoreDeliveryAttempt {
inputReadFailure?: InputReadDiagnostic;
stage: PendingPostCompactStage;
at: string;
ok: boolean;
@@ -461,6 +463,7 @@ export interface ObservedCompaction {
detail: string;
}
export interface PreparationState {
inputReadFailure?: InputReadDiagnostic & { stage: "preparation" | "compact" };
/** The reading that held this preparation send; never includes draft text. */
inputBox?: SendResult["heldInputBoxReading"];
policyActor?: TerminalActor;
@@ -565,6 +568,7 @@ interface PreparationFailureRun {
}
interface CompactionRecord {
inputReadFailure?: PreparationState["inputReadFailure"];
preparationFailures?: PreparationFailureRun;
inputBox?: PreparationState["inputBox"];
/** The daemon process that last wrote it. */
@@ -1000,6 +1004,7 @@ export class ClaudeCompactionEnforcer {
} catch (error) {
this.recordRestoreFailure(input.sessionName, stage, record, {
ok: false, reason: error instanceof DeliveryGuardError ? error.code : "send_failed",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
}, error instanceof Error ? error.name : typeof error);
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
throw error;
@@ -1027,7 +1032,7 @@ export class ClaudeCompactionEnforcer {
private recordRestoreFailure(
session: string, stage: PendingPostCompactStage | undefined,
record: { file: string; record: CompactionRecord } | undefined,
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox">, errorClass?: string,
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox" | "inputReadFailure">, errorClass?: string,
): void {
if (!stage || !record || this.records.get(session) !== record || this.pendingPostCompactRestore.get(session) !== stage) return;
const prior = record.record.lastDelivery;
@@ -1042,6 +1047,7 @@ export class ClaudeCompactionEnforcer {
const lastDelivery: RestoreDeliveryAttempt = {
stage, at: new Date(this.now()).toISOString(), ok: result.ok, reason: result.reason ?? "send_failed",
...(result.outcome ? { outcome: result.outcome } : {}),
...(result.inputReadFailure ? { inputReadFailure: result.inputReadFailure } : {}),
...(result.activity ? { activity: { state: result.activity.state, reason: result.activity.reason, evidenceSource: result.activity.evidenceSource } } : {}),
...(errorClass ? { errorClass } : {}), failures: waiting ? 0 : (prior?.failures ?? 0) + 1,
...(held ? { inputBox: held } : {}), ...(unreadable ? { unreadable } : {}),
@@ -1598,7 +1604,7 @@ export class ClaudeCompactionEnforcer {
if (error instanceof DeliveryGuardError) {
const written = (error as { inputWritten?: boolean }).inputWritten === true;
const attempted = (error as { inputAttempted?: boolean }).inputAttempted === true;
prep = { ok: false, sent: written, reason: error.code,
prep = { ok: false, sent: written, reason: error.code, inputReadFailure: error.inputReadFailure,
inputPhase: written ? "written" as const : attempted ? "attempted" as const : "not_reached" as const };
} else prep = null;
}
@@ -1611,7 +1617,8 @@ export class ClaudeCompactionEnforcer {
// Neither a failed response nor its loss proves no paste/Enter. Never replay uncertainty.
attempt.delivery = prep?.ok && prep.outcome !== "retained" ? "delivered" : notSent ? "not_sent" : "uncertain";
attempt.inputBox = prep?.heldInputBoxReading;
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox });
if (prep?.inputReadFailure) attempt.inputReadFailure = { stage: "preparation", ...prep.inputReadFailure };
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox, inputReadFailure: attempt.inputReadFailure });
// The replacement request went out (or may have): the ended attempt's gate is used up.
if (!notSent && attempt.afterEnded !== undefined && this.pendingPreCompactPrep.get(input.sessionName) === attempt) this.lastEnded.delete(input.sessionName);
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted) return;
@@ -1661,7 +1668,7 @@ export class ClaudeCompactionEnforcer {
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, allowDraftWhileIdle: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: attempt.prepared ? idleWait : Math.max(1, Math.min(idleWait, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox">;
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox" | "inputReadFailure">;
let guardRefused = false;
try {
const guard = this.sessionTransport.deliveryGuard;
@@ -1672,11 +1679,16 @@ export class ClaudeCompactionEnforcer {
const attempted = (error as { inputAttempted?: boolean } | null)?.inputAttempted === true;
guardRefused = error instanceof DeliveryGuardError && !written && !attempted;
result = { ok: false, reason: error instanceof DeliveryGuardError ? error.code : "delivery_uncertain",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(written ? { sent: true, inputPhase: "written" as const } : attempted ? { inputPhase: "attempted" as const } : {}) };
} finally {
delete attempt.compactInFlight;
}
if (!result.ok || result.outcome === "retained") {
if (result.inputReadFailure) {
attempt.inputReadFailure = { stage: "compact", ...result.inputReadFailure };
this.persist(input.sessionName, attempt.attemptId, { inputReadFailure: attempt.inputReadFailure });
}
const phase = result.inputPhase;
// A write that was issued, or text already in the input, is never "nothing sent".
const writeAttempted = phase === "attempted" || phase === "written" || result.sent === true;
@@ -1927,7 +1939,7 @@ export class ClaudeCompactionEnforcer {
compact: "not_sent", sends: record.sends,
deadlineAt: record.deadlineAt ?? (record.delivery === "not_sent" ? null : record.updatedAt + AUTO_PREP_WAIT_MS_DEFAULT),
policyWasEnabled: true, preparationRoot: record.preparationRoot, controller: new AbortController(),
compactInstruction: record.compactInstruction, inputBox: record.inputBox,
compactInstruction: record.compactInstruction, inputBox: record.inputBox, inputReadFailure: record.inputReadFailure,
// A held attempt comes back held, with its recorded hold (never stamped again) and its map's age and usage.
...(held ? { prepared: true as const, preparedAt: record.preparedAt ?? record.updatedAt, preparedReason: record.preparedReason ?? "map_complete_seat_never_idle" } : {}),
...(held ? { mapReadyAt: record.mapReadyAt ?? record.preparedAt ?? record.updatedAt } : record.mapReadyAt !== undefined ? { mapReadyAt: record.mapReadyAt } : {}),
@@ -95,7 +95,8 @@ export class DraftAwareDelivery {
: guardError ? error.message : "The delivery attempt ended without a reliable write result. Repeating the request may duplicate input.";
result = { ok: false, sessionName: session, outcome: "failed", inputPhase,
...(inputWritten || inputPhase === "not_reached" ? { sent: inputWritten } : {}),
reason: guardError ? error.code : "delivery_indeterminate", error: detail };
reason: guardError ? error.code : "delivery_indeterminate", error: detail,
...(guardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
}
if (inputWritten) result = { ...result, sent: true };
const preInputRefusal = ["draft_input_busy", "draft_input_unknown", "draft_input_changed", "draft_wake_superseded",
@@ -0,0 +1,47 @@
/** Reporting only: never retain composer text, suggestions, frames, hints or exception messages. */
export interface InputReadDiagnostic {
check: "composer" | "draft-aware";
status: "empty" | "text" | "typed" | "likely-suggestion" | "typed-with-suggestion" | "unknown" | "no-input-box";
confidence: "high" | "medium" | "low" | "unknown" | null;
reason: string;
cursor: { present: boolean; valid: boolean | null; atEnd: boolean | null };
/** null means this reader does not report whether a timeout occurred. */
timedOut: boolean | null;
}
const reasons = new Set(["capture_unavailable", "pane_in_mode", "invalid_cursor", "incomplete_frame",
"ambiguous_composer", "unrecognized_composer", "read_timeout", "read_budget_spent", "cursor_moved",
"cursor_unavailable", "cursor_outside", "unrecognized_input_box", "input_not_clear",
"paste_controls", "paste_long", "paste_viewport"]);
/** Copy only fixed vocabulary and cursor flags. Unknown reader reasons remain content-free. */
export function inputReadDiagnostic(check: InputReadDiagnostic["check"], reading: {
state?: string; status?: string; confidence?: string; unknownReason?: string;
cursor?: { x: number; y: number; width: number; height: number } | null;
cursorAtEnd?: boolean; evidence?: string[];
} | null, reason?: string): InputReadDiagnostic {
const evidence = reading?.evidence ?? [];
const timeout = evidence.includes("input box reads timed out");
const derived = reason ?? reading?.unknownReason ?? (timeout ? "read_timeout"
: evidence.includes("input box budget spent") ? "read_budget_spent"
: evidence.includes("cursor moved during the capture") ? "cursor_moved"
: evidence.includes("cursor unavailable") ? "cursor_unavailable"
: evidence.some(e => e === "cursor outside the input box" || e === "cursor seen once outside the input box") ? "cursor_outside"
: reading?.status === "no-input-box" ? "unrecognized_input_box"
: reading?.status === "unknown" || !reading ? "capture_unavailable" : "input_not_clear");
const status = reading?.status ?? reading?.state;
const confidence = reading?.confidence;
const cursor = reading?.cursor;
return {
check,
status: ["empty", "text", "typed", "likely-suggestion", "typed-with-suggestion", "unknown", "no-input-box"].includes(status ?? "")
? status as InputReadDiagnostic["status"] : "unknown",
confidence: ["high", "medium", "low", "unknown"].includes(confidence ?? "")
? confidence as InputReadDiagnostic["confidence"] : null,
reason: reasons.has(derived) ? derived : "capture_unavailable",
cursor: { present: !!cursor, valid: cursor ? [cursor.x, cursor.y, cursor.width, cursor.height].every(Number.isInteger)
&& cursor.x >= 0 && cursor.y >= 0 && cursor.width > cursor.x && cursor.height > cursor.y : null,
atEnd: typeof reading?.cursorAtEnd === "boolean" ? reading.cursorAtEnd : null },
timedOut: timeout ? true : null,
};
}
@@ -1,3 +1,4 @@
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAddress } from "./session-registry.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
@@ -28,7 +29,7 @@ interface Lease {
}
export class DeliveryGuardError extends Error {
constructor(readonly code: string, message: string) { super(message); }
constructor(readonly code: string, message: string, readonly inputReadFailure?: InputReadDiagnostic) { super(message); }
// Hono's error protocol also preserves this typed refusal on lifecycle routes.
getResponse(): Response {
@@ -440,14 +441,14 @@ export class SeatDeliveryGuard {
this.checkInput(name);
return observed;
};
const unknown = () => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.");
const unknown = (reading: ComposerInput | null) => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.", inputReadDiagnostic("draft-aware", reading));
let input = await read();
// Claude may still be redrawing this lease's own paste: one bounded re-read before refusing its Enter.
if (submit && lease.staged && (!input || input.state === "unknown")) { await this.sleep(OWN_INPUT_REREAD_MS); input = await read(); }
if (!input || input.state === "unknown") {
// No Enter was sent, so the paste stays there unsubmitted, and the next delivery may recognize it as OpenRig's.
if (submit && lease.staged) this.leftovers.set(target.nodeId, { target: lease.target, humanEpoch: lease.humanEpoch, ...lease.staged });
throw unknown();
throw unknown(input);
}
if (leftover === "recognize" && !submit && this.ownsLeftover(target, input)) { lease.ownLeftover = "recognized"; return; }
if (typeof leftover === "object" && lease.ownLeftover === "recognized") {
@@ -466,7 +467,7 @@ export class SeatDeliveryGuard {
lease.ownLeftover = "submitted";
await this.sleep(OWN_INPUT_REREAD_MS);
const after = await read();
if (!after || after.state === "unknown") throw unknown();
if (!after || after.state === "unknown") throw unknown(after);
input = after;
}
}
@@ -489,7 +490,7 @@ export class SeatDeliveryGuard {
: uncertainty === "long"
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
throw new DeliveryGuardError("draft_input_unknown", message);
throw new DeliveryGuardError("draft_input_unknown", message, inputReadDiagnostic("draft-aware", input, `paste_${uncertainty}`));
}
lease.inputFrame = input.frame;
}
@@ -1,3 +1,4 @@
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
import { expectedConversation, sameConversation, resolveExpectedConversation, type ExpectedConversation } from "./expected-conversation.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
@@ -2176,6 +2177,7 @@ export interface SendResult {
heldInputBox?: InputBoxAnnotation["status"];
/** The existing preserve-input reading, without the draft or suggestion text. No extra capture. */
heldInputBoxReading?: Pick<InputBoxAnnotation, "status" | "confidence" | "evidence" | "cursor">;
inputReadFailure?: InputReadDiagnostic;
}
/** One reading of a Claude Code or Codex input box: `exact` (the sent text, word boundaries kept), `ambiguous`
@@ -2823,6 +2825,7 @@ export class SessionTransport {
sent: written ? true : attempted ? undefined : false,
inputPhase: written ? "written" : attempted ? "attempted" : "not_reached",
reason: (error as { code?: string }).code ?? "expected_identity_unavailable", error: (error as Error).message,
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(receiptUnconfirmed ? { warning: "Conditional input receipt could not be finalized; inspect before retrying." } : {}) };
}
}
@@ -2944,17 +2947,20 @@ export class SessionTransport {
if ((error as { inputWritten?: boolean }).inputWritten === true) {
// Only a throw before the Enter key reached the adapter positively means Enter was never sent.
const enter = (error as { enterAttempted?: boolean }).enterAttempted === true ? "the submission (Enter) outcome is unconfirmed" : "Enter was not sent";
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written", reason: (error as { code?: string }).code ?? "submit_refused",
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "submit_refused",
error: `Text was written to '${sessionName}' but ${enter}: ${(error as Error).message}` };
}
// A throw after the text write was issued: whether it landed is unknown, so it is not "nothing sent" either.
if ((error as { inputAttempted?: boolean }).inputAttempted === true) {
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted", reason: (error as { code?: string }).code ?? "delivery_indeterminate",
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "delivery_indeterminate",
error: `A text write to '${sessionName}' was issued, but its result was lost: ${(error as Error).message}` };
}
const noInputCode = error instanceof OutboxHandlerError && error.code === "retained_quota_full" ? "retained_quota_full" as const
: error instanceof DeliveryGuardError && error.code === "terminal_seat" ? "terminal_seat" as const : undefined;
return { ok: false, sessionName, sent: false, reason: (error as { code?: string }).code ?? "guard_unavailable", error: (error as Error).message,
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(noInputCode ? { noInputCode, inputPhase: "not_reached" as const } : {}) };
}
}
@@ -3350,7 +3356,7 @@ export class SessionTransport {
}
if (!submitResult.ok && !enterIssued && submitResult.code === CALLER_CANCELLED && cancel?.aborted) return callerCancelled(sessionName);
if (!submitResult.ok) {
return { ok: false, sessionName, inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
return { ok: false, sessionName, ...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}), inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
? `submitOnly: Enter reported a failure for '${sessionName}'; submission is unconfirmed: ${submitResult.message}`
: `submitOnly: Enter was not sent to '${sessionName}': ${submitResult.message}` };
}
@@ -3700,7 +3706,7 @@ export class SessionTransport {
heldInputBoxReading = { status, confidence, evidence, cursor };
}
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`, inputReadDiagnostic("composer", box));
}
if (!clear) pastedOverInputBox = box?.status ?? "unknown";
} : undefined;
@@ -3735,6 +3741,7 @@ export class SessionTransport {
ok: false,
sessionName,
reason: policyRefusal ? textResult.code : "send_failed",
...(textResult.inputReadFailure ? { inputReadFailure: textResult.inputReadFailure } : {}),
outcome: "failed",
// The adapter's draft checks run before the paste (only file/buffer preparation precedes them), and its
// after-paste observation cannot fail the write, so a draft_* receipt proves the target input was never reached.
@@ -3803,6 +3810,7 @@ export class SessionTransport {
ok: false,
sessionName,
reason: policyRefusal ? submitResult.code : "submit_failed",
...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}),
outcome: "failed",
inputPhase: "written",
error: policyRefusal
@@ -3,6 +3,9 @@
// per poll, filling the seat's held quota. Real SessionTransport, real draft-aware delivery and outbox on an
// in-memory database; the tmux adapter records commands instead of running them.
import Database from "better-sqlite3";
import { Hono } from "hono";
import { inputReadDiagnostic } from "../src/domain/input-read-diagnostic.js";
import { compactionRoutes } from "../src/routes/compaction.js";
import stringWidth from "string-width";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -83,7 +86,11 @@ async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"
const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length });
const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>)
.filter((r) => !state || r.state === state);
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body,
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, recover: () => {
const next = new ClaudeCompactionEnforcer({ resolveClaudeCompactionPolicy: () => policy } as any, transport,
{ openrigHome: home, now: () => clock, resolveOccupantGeneration: () => "g1" });
next.recoverSeat(input); return next;
}, body: () => body,
onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
}
@@ -361,3 +368,95 @@ it("the managed idle wait treats only a recognized draft according to the ordina
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
expect(f.effects()).toMatchObject({ prepPastes: 1, compactPastes: 1, enters: 2 });
});
it.each(["preparation", "compact"] as const)("records the %s refusal through the existing transport without content or input", async stage => {
const f = await fixture({ pane: "%1", mode: stage === "preparation" ? "draft-aware" : "off" });
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => {
if (stage === "preparation") {
vi.mocked(f.tmux.captureComposerSnapshot).mockResolvedValue({ ...frame("PRIVATE_INPUT https://private.invalid/gateway"), inMode: true });
} else if (text.startsWith("/compact")) {
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, hint: "https://private.invalid/gateway" });
return send(s, text, { ...opts, preserveInput: true });
}
return send(s, text, opts);
});
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
const state = f.e.getPreparationState(seat)!;
const expected = stage === "preparation"
? { stage, check: "draft-aware", status: "unknown", confidence: null, reason: "pane_in_mode", timedOut: null,
cursor: { present: true, valid: true, atEnd: false } }
: { stage, check: "composer", status: "unknown", confidence: "unknown", reason: "read_timeout", timedOut: true,
cursor: { present: false, valid: null, atEnd: null } };
expect(state).toMatchObject({ inputReadFailure: expected, compact: "not_sent" });
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: stage === "preparation" ? 0 : 1, enters: stage === "preparation" ? 0 : 1 });
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
expect(JSON.parse(record)).toMatchObject({ inputReadFailure: expected });
const app = new Hono();
app.use("*", async (c, next) => { c.set("compactionEnforcer" as never, f.e as never); c.set("db" as never, f.db as never); await next(); });
app.route("/api/compaction", compactionRoutes());
const response = await app.request(`/api/compaction/state?session=${seat}`);
const body = await response.text();
expect(response.status).toBe(200);
expect(JSON.parse(body)).toMatchObject({ preparation: { inputReadFailure: expected } });
expect(record + body).not.toMatch(/PRIVATE_INPUT|private\.invalid/);
});
it("distinguishes an opted-in composer timeout from the separate draft-aware check", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, suggestion: { text: "PRIVATE_SUGGESTION" }, hint: "https://private.invalid/gateway" });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
const state = f.e.getPreparationState(seat)!;
expect(state).toMatchObject({ delivery: "not_sent", inputReadFailure: { stage: "preparation", check: "composer", reason: "read_timeout", timedOut: true,
status: "unknown", confidence: "unknown", cursor: { present: false, valid: null, atEnd: null } } });
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
expect(record).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid/);
expect(f.writes).toEqual([]);
});
it("retains a held completed map's compact diagnostic across record recovery", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
await f.e.maybeAutoCompact(input); f.stageMap();
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
expect(await f.e.maybeAutoCompact(input)).toMatchObject({ triggered: false, reason: "prepared_awaiting_window" });
const state = f.e.getPreparationState(seat)!;
expect(state).toMatchObject({ prepared: true, compact: "not_sent", inputReadFailure: { stage: "compact", check: "composer", reason: "cursor_unavailable", timedOut: null } });
const recordPath = join(dirname(state.mapPath), COMPACTION_RECORD_FILE);
const record = JSON.parse(readFileSync(recordPath, "utf8"));
// A new enforcer in this process deliberately ignores this process's live record; model a predecessor daemon.
writeFileSync(recordPath, JSON.stringify({ ...record, daemon: "previous-daemon" }));
expect(f.recover().getPreparationState(seat)?.inputReadFailure).toEqual(state.inputReadFailure);
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 });
});
it("records the same diagnostic on an owed restore step without advancing it", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
await f.e.maybeAutoCompact(input);
expect(f.e.getOwedRestore(seat)?.stage).toBe("restore_prompt");
const before = f.effects(), send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
await f.e.maybeAutoCompact(input);
expect(f.e.getOwedRestore(seat)).toMatchObject({ stage: "restore_prompt", lastDelivery: { stage: "restore_prompt", reason: "draft_input_unknown",
inputReadFailure: { check: "composer", reason: "cursor_unavailable", timedOut: null } } });
expect(f.effects()).toEqual(before);
});
it.each([
{ state: "unknown", unknownReason: "invalid_cursor", cursor: { x: -1, y: 0, width: 80, height: 24 }, reason: "invalid_cursor", valid: false },
{ state: "unknown", unknownReason: "ambiguous_composer", reason: "ambiguous_composer", valid: null },
{ state: "unknown", unknownReason: "PRIVATE_INPUT https://private.invalid/gateway", reason: "capture_unavailable", valid: null },
])("diagnostic vocabulary excludes content and reports existing cursor validity ($reason)", ({ reason, valid, ...reading }) => {
const safe = inputReadDiagnostic("draft-aware", { ...reading, rows: ["PRIVATE_INPUT"], suggestionRows: ["PRIVATE_SUGGESTION"], frame: "https://private.invalid/gateway" } as any);
expect(safe).toMatchObject({ reason, cursor: { valid } });
expect(JSON.stringify(safe)).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid|rows|frame/);
});
+2 -5
View File
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
results to relay." Carry their constraints and any declined permission choices in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
seat a model their account can run with `rig seat set-model`, then start it again with
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
operator is still pending.
@@ -207,9 +207,6 @@ person needs in their project or home folder and name that path.
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
their project doesn't show what the action touches. Their instruction changes no permission setting.
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with