Compare commits

...
Author SHA1 Message Date
dev60-review bdaa8717bd feat(plugins): give the openrig plugin a PNG icon for Claude's directory
Claude's plugin directory takes a square PNG or JPEG icon of 512 to
2048 px at .claude-plugin/icon.png, not SVG, and uses it as the
listing icon only the first time a plugin is saved or submitted. The
openrig plugin had only assets/logo.svg. This adds that mark rendered
at 1024x1024 (RGBA PNG, 53,823 bytes).
2026-11-12 01:31:02 +00:00
dev60-review b68742c3c2 fix(plugins): give each plugin manifest an https repository URL
Claude's plugin directory doesn't accept the github: shorthand in a
plugin's repository field (LISTING_URL_SCHEME) and drops it from the
listing preview. The five manifests of the openrig and openrig-core
plugins (Claude, Codex and the shared plugin.json) now carry
https://github.com/... URLs for the same repositories.
2026-11-12 01:23:59 +00:00
Mike Schwarz 80cfb7d01a fix(cli,daemon): an adopted session's route says to end its tmux session; rig seat stop's help matches (#3006)
An adopted session's recovery route told people to close its terminal before relaunching, but closing a viewer leaves the tmux session and its name alive, so the relaunch could still collide with it. The route now says to end the underlying tmux session, and rig seat stop's help says the same. Guidance and help text only, with the test pins updated.
2026-11-11 17:18:51 -08:00
Evgeny Zotov f1a06c4f1b The pi-runner pane keeps the operator's draft out of the stream (#2842)
Typing into a Pi runner seat's pane while the agent streamed fused the draft into the streamed text, and an echoed partial draft could freeze into scrollback as a fake message, while runner diagnostics printed inline over the transcript. One compositor now owns the pane's bottom region, a status line and a compose line, and repaints it on every transcript write. While a draft or status exists, streamed text is line-buffered above it. Enter is taken before readline sees it, so the only durable copy of the input is the existing commit line. Draftless streaming is byte-identical to before, and the READY and EXITED markers stay in the transcript for the readiness probe. Widths are measured in terminal cells per grapheme cluster, so wide and emoji drafts erase exactly the rows they used.
2026-11-11 16:59:47 -08:00
Roberto Campos a7b8a27eb8 fix(daemon): park a Claude seat until the reset its usage-limit message states (#99) (#2917)
A Claude seat stopped by a usage limit was retried on the bounded interval, because the typed failure carries no reset time, so on a 5-hour or weekly limit it was retried for hours or sat idle after the reset. The relay now reads the reset the limit message states and sends it as an ISO time. The daemon keeps it only on the typed rate-limit failure from the seat's own Claude session and within eight days, enforced at the route too. A message with no clear reset, an impossible date or an unsupported fragment keeps the bounded retry. For #99.
2026-11-11 16:48:28 -08:00
Edward Bowie be2690179d fix(daemon): read completed Codex 0.167 turns as idle; keep unsupported versions unknown (#2954)
A Codex seat at an empty prompt could read running for as long as its pane redrew, when the pane showed a Codex version OpenRig hadn't recorded. The completed and streaming Codex 0.167 layouts are now recorded, so a completed 0.167 turn reads idle. A pane naming another unrecorded version reads unknown rather than running from redraws, while positive work and attention stay visible and the unfinished-streaming guard is kept. For #2881.
2026-11-11 16:40:17 -08:00
Edward Bowie a71ce16dd7 fix(daemon): reclaim pane claims left by removed or terminal archived owners (#2869)
Fresh, fork and rebuild handovers that launch a successor in the seat's tmux session could not finalize while discovery still belonged to a removed node, or to a node in an archived rig whose latest session is superseded, detached or exited. Finalization now reclaims that stale claim. Another live owner still refuses, including when ownership changes during the tmux presence check. For #2861.
2026-11-11 15:59:18 -08:00
ziadeh 0834a16949 docs(reference): say what clear-attention's check proves, and that a full restore now clears itself (#2964)
The startup guide says what clear-attention's check proves: that the agent in the pane was started to resume the recorded conversation, from launch evidence, not which conversation is open now. When the check fails, the restore outcome stays and the seat still needs attention. set-resume-token is described for the one case where the agent was started with the right conversation. A full restore now clears its own attention, and getting-started points to the partial-restore sequence. Docs only. A follow-up to #2674 and #2711.
2026-11-11 15:56:33 -08:00
Mike Schwarz f1668d6907 docs(cli): show a complete periodic-reminder registration (#3002)
An agent can copy a complete periodic-reminder YAML spec and registration command from rig watchdog register --help or the keeping-a-watch reference, and the watchdog skill points to that example. The text names all five required flags, that a YAML target wins over the CLI's, and that --message is the context-usage-threshold shortcut only. It keeps an agent's recurring reminder apart from a person's one-shot queue watch. Help, guidance and generated skill mirrors only; registration is unchanged.
2026-11-11 15:06:54 -08:00
Mike Schwarz 89999f6ebf fix(daemon): write a person's note about their runner answer, and wake a waiting runner on the decision (#3001)
When a person answers a runner's permission request, OpenRig keeps the answer and writes the person a note: who decides, or that their answer is kept, the tool hasn't run, and how to decide directly. For a person answering as the host's human seat, neither note was ever written: rows to a human seat need an evidence reference, both notes had none, and the queue refused them. Both creates now carry the request as their evidence reference, one note per request, and a notification failure never rolls back the person's answer. A runner already waiting when the answer was handed on now follows the handoff, so it sees the agent's decision without waiting up to 25 seconds for its read to end. The reply text points to the note.
2026-11-11 14:52:43 -08:00
James B. Shields f3a3b1711b fix(ssh): avoid passive TCP probes in host status (#2979)
Routine CLI and daemon host-list reads no longer run ssh -G or open a raw TCP connection for an SSH entry. Passive SSH status stays unknown, explicit rig host doctor checks stay available, and HTTP and HTTPS reachability probes keep their behaviour. The TUI shows unknown as (unknown) in dim styling, apart from a confirmed (unreachable), in host rows and host detail. This trades away the passive SSH reachability that #2481 added, for host reads that make no network connection. The ssh argument separation from #2973 is kept.
2026-11-11 14:27:44 -08:00
billbenetti 7886582f9d fix(daemon): a handover whose successor was launched but not ready says it may be running, not "retry" (#2985) (#3003)
When rig seat handover failed at start_agent after the in-place respawn, successor_create_failed said the seat was re-wakeable and to retry, although the retiree had ended and the successor's launch was already in the pane, so a retry could end a successor still starting there. Now, when the launch was sent and only readiness failed, the failure carries replacementStarted and successorPane, and its guidance says the handover wasn't committed, that a successor may be starting or working in that pane unregistered, and to look at the pane before another handover. Shell-looking output alone isn't read as proof it stopped. State, events, binding, the 500 and exit 2 are unchanged. For #2985.
2026-11-11 14:24:47 -08:00
61 changed files with 2211 additions and 252 deletions

No files matched your search

+16 -13
View File
@@ -75,20 +75,20 @@ in the rig's `attentionCount` and the TUI's attention totals.
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
the seat's activity next turns to running or idle, or when the whole restore completes
after the seat already reads running or idle, the daemon checks the live seat: its tmux
session is alive, and the pane runs the agent resuming the conversation the seat
records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When it fails, the restore outcome
stays and the seat still needs attention, which is true when the pane runs another
conversation. A check that started before a newer restore of the seat doesn't settle
the newer outcome.
session is alive, and the agent in its pane was started to resume the conversation the
seat records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When the check fails, the restore
outcome stays and the seat still needs attention. The check uses launch evidence; it
does not prove which conversation is open now.
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
command says which check failed, and `--reason` doesn't bypass it. If
the seat records the wrong conversation and you know the right one, set it with
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again. After a restore of only some seats, or a community
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
outcome instead.
command says which check failed, and `--reason` doesn't bypass it. If the seat records
the wrong conversation and the agent in its pane was started with the right one, set it
with `rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again; a token the running agent wasn't started with fails the same
check. After a restore of only some seats, or a community harness's assisted startup,
`--reason "<what you saw>"` can acknowledge the outcome instead, once any startup text
left in the input box is submitted (not sent again) and the seat has responded.
## Two Categories of Startup
@@ -278,7 +278,10 @@ with an old Claude header or input frame above it and Claude already running. A
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
unavailable; inspect its pane before deciding how to recover. When the launch was already sent, the failure's
guidance names that pane and the result carries `replacementStarted` and `successorPane`: the previous agent was
already ended, the new one may be working there without being registered as the seat's occupant, and another
handover or `rig seat launch <seat> --fresh --stop --reason <why>` ends it if it goes ahead.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
+21 -7
View File
@@ -84,11 +84,23 @@ so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
interval, even when the seat's own login still has room. A reset time the
helper's message states is not used: OpenRig keeps a stated reset only when the
hook's Claude `session_id` is the seat's own resume token, so a helper never
holds the pool past that one interval.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
The event carries no usage percentage or account identity. When the seat's own
Claude states when the limit resets (for example `resets Nov 10, 4pm
(America/Sao_Paulo)`, `resets in 2h 30m`, or the older `usage limit
reached|<epoch>`), the hook relay reads that time on the seat's machine and
forwards only the resulting timestamp (`resetsAt`), never the message text. It
reads the reset only after the typed `rate_limit` failure; the text never
decides that a seat is limited. A reset it can't read completely (an unknown or
truncated time zone, an unsupported unit, a date that doesn't exist such as Feb
30) or that is in the past or more than eight days away gives no reset. The
daemon applies the same eight-day bound to any sender.
The event marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
@@ -99,13 +111,15 @@ Pending work handled by the wake ladder uses the existing local Claude pool
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
a known reset supplies the timer, and so does a reset the seat's own typed
failure stated (`provider-reset`). Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
limits. Claude limit banners are not scraped from the terminal, and older
runtimes or failures that do not emit this hook still depend on status line
data. Without either
source, provider usage remains unknown.
+1 -1
View File
@@ -898,7 +898,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| --- | --- |
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
| A restored seat still counts as needing attention after its prompt was answered | After a full restore of a Claude or Codex seat, the daemon clears this by itself once the seat reads running or idle and passes its check. If it still counts, check its `restoreOutcome` in `rig ps --nodes --json` (the RESTORE column): when it is `attention_required`, run `rig seat clear-attention <seat>`, which says which check failed; answering the prompt again or running `rig seat continue` doesn't change it. For a partial restore, submit any startup text left in the input box without sending it again, and wait for the agent to respond before clearing attention. See [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
+28
View File
@@ -296,3 +296,31 @@ Unknown: not said yet.
delivery guarantee.
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
allowance. For an allowance, see "A usage warning" above.
## Recurring reminders for agents
An agent-to-agent reminder can repeat until stopped. This is separate from
[a person's one-shot watch](#setting-a-watch), which uses `rig queue block --wake-after`.
Save this as `periodic-reminder.yaml`, replacing the example session names with your own:
```yaml
policy: periodic-reminder
target:
session: worker@my-team
message: "Finish the current action, then check the pending work."
```
Register it with all five required flags:
```bash
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \
--target-session worker@my-team --interval-seconds 600 \
--registered-by coordinator@my-team
```
At registration, `--policy` selects the policy; the YAML `policy` field is not the
selector. YAML `target.session` takes precedence over `--target-session`, so keep them
aligned. If YAML omits the target, the CLI target is the fallback. Put a non-empty
`message` in YAML: `--message` is a shortcut only for `context-usage-threshold`.
The first reminder is due after the interval; this example repeats every 600 seconds.
+8 -35
View File
@@ -24,9 +24,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient, operatorAuthHeaders } from "../client.js";
@@ -168,34 +167,13 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
// `--` before the target: a destination that begins with `-` is fed to ssh as the host, not
// read as an option. ssh answers an invalid hostname on its own.
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* against an HTTP host's URL host:port; passive SSH entries report
* `unknown` without running `ssh` or opening a raw TCP socket (explicit SSH
* verification lives in `rig host doctor`). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
@@ -205,17 +183,12 @@ export async function probeHostStatus(
timeoutMs = 1500,
): Promise<"reachable" | "unreachable" | "unknown"> {
try {
let target: string;
let port: number;
if (host.transport === "ssh") {
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
return "unknown";
}
const u = new URL(host.url);
const target = u.hostname.replace(/^\[|\]$/g, "");
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
const r = await tcpProbe(target, port, timeoutMs);
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
} catch {
+4 -3
View File
@@ -1493,9 +1493,10 @@ Examples:
.addHelpText("after", `
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
session is refused: to end only it, run rig unclaim <session> (it releases the
session and leaves its process running), then end that process in its own
terminal. rig down <rig> ends every seat in its rig, so it is the route only
for a one-seat rig.
session and leaves its process running), then end that process and the
underlying tmux session, including any remaining shell. Closing only its
viewing window leaves the tmux session running. rig down <rig> leaves a
session OpenRig didn't start running.
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
adopted and is refused too, until its next launched handover or managed launch;
the refusal says so. Stopping ends that agent's session and any work in progress:
+22 -2
View File
@@ -85,7 +85,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
"Context transcripts measured 113K–153K tokens/MB. The margin is the protection " +
"because prompt-bound consumers act only at turn boundaries",
)
.option("--spec <path>", "Path to YAML spec file (optional for context-usage-threshold)")
.option("--spec <path>", "YAML spec file (required except for context-usage-threshold)")
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive, idle-gate-qitem, context-usage-threshold)")
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
@@ -100,7 +100,27 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
.option("--json", "JSON output for agents")
.addHelpText("after", "\nTranscript density varies by session shape; do not tune a context threshold near the context wall.\n\nThe returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>")
.addHelpText("after", `
Recurring reminder for an agent (not a person's one-shot queue watch):
Save periodic-reminder.yaml:
policy: periodic-reminder
target:
session: worker@my-team
message: "Finish the current action, then check the pending work."
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \\
--target-session worker@my-team --interval-seconds 600 \\
--registered-by coordinator@my-team
All five flags above are required for periodic-reminder. At registration,
--policy selects the policy; a YAML policy field is not the selector.
YAML target.session takes precedence over --target-session; keep them aligned.
Put the reminder message in YAML. --message is only a context-usage-threshold shortcut.
For a person's one-shot watch, use rig queue block --wake-after instead.
Transcript density varies by session shape; do not tune a context threshold near the context wall.
The returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>`)
.action(async (opts: {
spec?: string;
policy: string;
@@ -1,51 +1,144 @@
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
// `ssh -G`; no real ssh runs.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
// `rig host list` / `rig host ls` status is tested at process and probe boundaries:
// passive SSH rows must not invoke ssh -G or trigger a TCP probe.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probeHostStatus } from "../src/commands/host.js";
import { Command } from "commander";
import { hostCommand, type DoctorDeps } from "../src/commands/host.js";
const { execFileMock, connectMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
connectMock: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, execFile: execFileMock };
});
vi.mock("node:net", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:net")>();
return { ...actual, connect: connectMock };
});
interface ProbeCall {
target: string;
port: number;
timeoutMs: number;
}
interface ExecFileCall {
command: string;
args: string[];
timeout: number | undefined;
}
let home: string;
let savedHome: string | undefined;
let execFileCalls: ExecFileCall[];
let log: ReturnType<typeof vi.spyOn>;
function writeRegistry(hosts: unknown[]): void {
writeFileSync(join(home, "hosts.yaml"), JSON.stringify({ hosts }));
}
function deps(tcpProbe: DoctorDeps["tcpProbe"]): DoctorDeps {
return {
run: async () => { throw new Error("unexpected remote SSH command"); },
httpGet: async () => { throw new Error("unexpected HTTP request"); },
tcpProbe,
};
}
async function listHosts(hosts: unknown[], tcpProbe: DoctorDeps["tcpProbe"], verb: "list" | "ls" = "ls") {
writeRegistry(hosts);
const program = new Command().exitOverride();
program.addCommand(hostCommand(deps(tcpProbe)));
await program.parseAsync(["node", "rig", "host", verb, "--json"]);
const output = log.mock.calls.map((call) => String(call[0])).find((line) => line.trim().startsWith("["));
if (output === undefined) throw new Error("host list did not print its JSON array");
return JSON.parse(output) as Array<Record<string, unknown>>;
}
let dir: string;
let savedPath: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
writeFileSync(join(dir, "ssh"), `#!/bin/sh
case "$*" in
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
*) exit 255 ;;
esac
`);
chmodSync(join(dir, "ssh"), 0o755);
savedPath = process.env["PATH"];
process.env["PATH"] = `${dir}:${savedPath}`;
});
afterEach(() => {
process.env["PATH"] = savedPath;
rmSync(dir, { recursive: true, force: true });
home = mkdtempSync(join(tmpdir(), "host-list-ssh-inert-"));
savedHome = process.env["OPENRIG_HOME"];
process.env["OPENRIG_HOME"] = home;
execFileCalls = [];
execFileMock.mockReset();
execFileMock.mockImplementation((
command: string,
args: string[],
options: { timeout?: number },
callback: (error: Error | null, stdout: string) => void,
) => {
execFileCalls.push({ command, args: [...args], timeout: options.timeout });
queueMicrotask(() => callback(null, ""));
});
connectMock.mockReset();
connectMock.mockImplementation(() => { throw new Error("unexpected direct TCP socket"); });
log = vi.spyOn(console, "log").mockImplementation(() => {});
});
describe("rig host ls status for an ssh host", () => {
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
const dials: string[] = [];
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
dials.push(`${target}:${port}`);
afterEach(() => {
vi.restoreAllMocks();
rmSync(home, { recursive: true, force: true });
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = savedHome;
});
describe("rig host list/ls passive status", () => {
it.each(["list", "ls"] as const)("reports an SSH host unknown without launching a process or probing TCP through host %s", async (verb) => {
const probes: ProbeCall[] = [];
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
probes.push({ target, port, timeoutMs });
return "open";
};
const rows = await listHosts([{ id: "devbox", transport: "ssh", target: "devbox", user: "ops" }], tcpProbe, verb);
expect({ status: rows[0]?.status, execFileCalls, probes, directSockets: connectMock.mock.calls }).toEqual({
status: "unknown",
execFileCalls: [],
probes: [],
directSockets: [],
});
expect(status).toBe("reachable");
expect(dials).toEqual(["10.1.2.3:2222"]);
});
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
const dials: string[] = [];
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
dials.push(`${t}:${port}`);
return "closed";
});
expect(status).toBe("unknown");
expect(dials).toEqual([]);
it.each([
{ label: "HTTP default port", url: "http://example.test", target: "example.test", port: 80 },
{ label: "HTTP explicit port", url: "http://example.test:7433", target: "example.test", port: 7433 },
{ label: "HTTPS default port", url: "https://example.test", target: "example.test", port: 443 },
{ label: "bracketed IPv6 HTTPS", url: "https://[2001:db8::7]:9443", target: "2001:db8::7", port: 9443 },
])("preserves CLI $label status probing through its injected seam", async ({ url, target, port }) => {
const probes: ProbeCall[] = [];
const tcpProbe: DoctorDeps["tcpProbe"] = async (host, servicePort, timeoutMs) => {
probes.push({ target: host, port: servicePort, timeoutMs });
return "open";
};
const rows = await listHosts([{ id: "web", transport: "http", url }], tcpProbe);
expect({ status: rows[0]?.status, probes, execFileCalls, directSockets: connectMock.mock.calls }).toEqual({
status: "reachable",
probes: [{ target, port, timeoutMs: 1500 }],
execFileCalls: [],
directSockets: [],
});
});
it("maps an HTTP closed-probe control to unreachable", async () => {
const probes: ProbeCall[] = [];
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
probes.push({ target, port, timeoutMs });
return "closed";
};
const rows = await listHosts([{ id: "web", transport: "http", url: "http://example.test:7433" }], tcpProbe);
expect({ status: rows[0]?.status, probes, directSockets: connectMock.mock.calls }).toEqual({
status: "unreachable",
probes: [{ target: "example.test", port: 7433, timeoutMs: 1500 }],
directSockets: [],
});
});
});
+17 -1
View File
@@ -14,11 +14,13 @@ import { configCommand } from "../src/commands/config.js";
describe("rig host add/list", () => {
let dir: string;
let savedHome: string | undefined;
let tcpProbeCalls: Array<{ target: string; port: number; timeoutMs: number }> = [];
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
savedHome = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = dir;
tcpProbeCalls = [];
});
afterEach(() => {
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
@@ -43,7 +45,15 @@ describe("rig host add/list", () => {
function run(argv: string[]) {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
prog.addCommand(hostCommand({
run: async () => { throw new Error("unexpected cross-host command in add/list test"); },
httpGet: async () => { throw new Error("unexpected HTTP GET in add/list test"); },
httpPost: async () => { throw new Error("unexpected HTTP POST in add/list test"); },
tcpProbe: async (target, port, timeoutMs) => {
tcpProbeCalls.push({ target, port, timeoutMs });
return "unknown";
},
}));
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
@@ -97,11 +107,17 @@ describe("rig host add/list", () => {
const added = await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN", "--json"]));
expect(JSON.parse(added.out.join("\n"))).toMatchObject({ ok: true, entry: { id: "f1" } });
expect(added.err).toEqual([]);
const beforeTextList = tcpProbeCalls.length;
const { out } = await capture(() => run(["list"]));
expect(tcpProbeCalls.length - beforeTextList).toBe(1);
const text = out.join("\n");
expect(text).toContain("env:FACTORY_TOKEN");
expect(text).toContain("unknown");
expect(text).not.toContain("super-secret-bearer-value");
const beforeJsonList = tcpProbeCalls.length;
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
expect(tcpProbeCalls.length - beforeJsonList).toBe(1);
expect(JSON.parse(jsonOut.join(""))).toMatchObject([{ id: "f1", status: "unknown" }]);
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
});
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -144,10 +144,134 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
...promptBoundaryFields(hookEvent, providerPayload),
occurredAt: now().toISOString(),
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
...limitResetField(runtime, hookEvent, subtype, providerPayload, now),
...conversationFields(providerPayload, env),
};
}
// #99: a Claude usage limit says when it resets only in its rendered text ("You've hit your weekly limit ·
// resets Nov 10, 4pm (America/Sao_Paulo)"). The typed error still decides THAT the seat is limited; the text
// is read here, on the seat's machine and in its time zone, only for WHEN, and only the instant leaves the
// relay: never the text itself.
function limitResetField(runtime, hookEvent, subtype, providerPayload, now) {
if (runtime !== "claude-code" || hookEvent !== "StopFailure" || subtype !== "rate_limit") return {};
for (const value of [providerPayload.last_assistant_message, providerPayload.error_details]) {
if (typeof value !== "string") continue;
const resetsAt = claudeLimitResetAt(value.slice(0, 2000), now());
if (resetsAt) return { resetsAt };
}
return {};
}
const RESET_MAX_MS = 8 * 24 * 3600 * 1000;
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const RESET_EPOCH_RE = /usage limit reached\|(\d{10})\b/i;
// Each unit must be a whole word ("2 months" is not "2 m"), and a "resets in" fragment is read only this way.
const RESET_IN_LEAD_RE = /\bresets?\s+in\b/i;
const RESET_IN_RE = /\bresets?\s+in\s+(?:(\d+)\s*(?:d|days?)\b\s*)?(?:(\d+)\s*(?:h|hrs?|hours?)\b\s*)?(?:(\d+)\s*(?:m|mins?|minutes?)\b)?/i;
// A zone, when the text opens one, must be closed: "4pm (Not/AZone" is truncated, not local time.
const RESET_AT_RE = /\bresets?\s+(?:at\s+|on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*(\()([^)\n]{0,64})(\))?)?/i;
/** The reset instant a Claude usage-limit message states, as an ISO string, or null when it states none
* this can read. A wall-clock time without a zone is the seat's local time, which is what Claude renders. */
function claudeLimitResetAt(text, nowDate) {
const nowMs = nowDate.getTime();
if (!Number.isFinite(nowMs) || typeof text !== "string") return null;
let resetMs = null;
const epoch = RESET_EPOCH_RE.exec(text);
if (epoch) {
resetMs = Number(epoch[1]) * 1000;
} else if (RESET_IN_LEAD_RE.test(text)) {
const relative = RESET_IN_RE.exec(text);
// The whole fragment must be read: nothing numeric may follow what was, across a separator either
// ("in 2h 30x", "in 2h, 30x" and "in 2h and 30x" are not two hours).
if (!relative || !(relative[1] || relative[2] || relative[3])
|| /^(?:\s|[,;&+]|and\b)*\d/i.test(text.slice(relative.index + relative[0].length))) return null;
resetMs = nowMs + ((Number(relative[1] || 0) * 24 + Number(relative[2] || 0)) * 60 + Number(relative[3] || 0)) * 60_000;
} else {
const wall = RESET_AT_RE.exec(text);
if (wall) resetMs = wallClockReset(wall, nowMs);
}
if (resetMs === null || !Number.isFinite(resetMs) || resetMs <= nowMs || resetMs > nowMs + RESET_MAX_MS) return null;
return new Date(resetMs).toISOString();
}
function wallClockReset(match, nowMs) {
const [, monthName, day, hourText, minuteText, meridiem, zoneOpen, zoneText, zoneClose] = match;
if (zoneOpen && (!zoneClose || !zoneText.trim())) return null; // a truncated or empty zone: no reset
const hour12 = Number(hourText);
const minute = Number(minuteText || 0);
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
const hour = (hour12 % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
const zone = zoneText ? zoneText.trim() : null;
let toUtc;
try {
toUtc = zone ? zonedClock(zone) : localClock();
} catch {
return null; // a zone this runtime doesn't know: no reset rather than a guessed one
}
const today = toUtc.partsAt(nowMs);
if (monthName) {
const month = MONTHS.indexOf(monthName.toLowerCase());
if (month < 0) return null;
for (const year of [today.year, today.year + 1]) {
if (!isCalendarDay(year, month, Number(day))) continue; // "Feb 30" names no day; never roll it into March
const at = toUtc.instant(year, month, Number(day), hour, minute);
if (Number.isNaN(at)) return null;
if (at > nowMs) return at;
}
return null;
}
const at = toUtc.instant(today.year, today.month, today.day, hour, minute);
if (Number.isNaN(at)) return null; // ambiguous today: never advance a day on a guess
return at > nowMs ? at : toUtc.instant(today.year, today.month, today.day + 1, hour, minute);
}
function isCalendarDay(year, month, day) {
return Number.isInteger(day) && day >= 1 && day <= new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
function localClock() {
return wallClock(
(ms) => { const d = new Date(ms); return { year: d.getFullYear(), month: d.getMonth(), day: d.getDate(), hour: d.getHours(), minute: d.getMinutes() }; },
(ms) => -new Date(ms).getTimezoneOffset() * 60_000,
);
}
function zonedClock(timeZone) {
const format = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric",
day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
const partsAt = (ms) => {
const parts = Object.fromEntries(format.formatToParts(new Date(ms)).map((part) => [part.type, Number(part.value)]));
return { year: parts.year, month: parts.month - 1, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second };
};
const offsetAt = (ms) => {
const p = partsAt(ms);
return Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
};
return wallClock(partsAt, offsetAt);
}
/** A clock whose instant() is the one moment a wall time names, or NaN when it names none (skipped by a DST jump) or
* two (repeated by a DST fall-back): the message doesn't say which, so no reset rather than a guess a day off. */
function wallClock(partsAt, offsetAt) {
return {
partsAt,
instant: (year, month, day, hour, minute) => {
const asUtc = Date.UTC(year, month, day, hour, minute);
const want = new Date(asUtc); // normalizes a day past the month's end (tomorrow)
const matches = new Set();
for (const probe of [asUtc - 14 * 3600_000, asUtc, asUtc + 14 * 3600_000]) {
const candidate = asUtc - offsetAt(probe);
const p = partsAt(candidate);
if (p.year === want.getUTCFullYear() && p.month === want.getUTCMonth() && p.day === want.getUTCDate()
&& p.hour === want.getUTCHours() && p.minute === want.getUTCMinutes()) matches.add(candidate);
}
return matches.size === 1 ? [...matches][0] : NaN;
},
};
}
// OPR.0.4.3.28 B1+B3 — resolve the ingest base URL + token without depending on
// the operator seeding OPENRIG_URL/OPENRIG_ACTIVITY_HOOK_TOKEN into the shell:
// 1. env OPENRIG_URL / token (unchanged fast path).
@@ -390,6 +514,7 @@ if (require.main === module) {
module.exports = {
buildOpenRigPayload,
claudeLimitResetAt,
claimFirstLaunchHook,
buildSessionIdentityPayload,
parseJson,
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": [
"openrig",
@@ -8,7 +8,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
}
@@ -94,6 +94,11 @@ pools and missing edge artifacts.
## Active surface
For a worked `periodic-reminder` YAML spec, full registration command and required
flags, read `rig context get reference/keeping-a-watch.md#recurring-reminders-for-agents`.
This is an agent's recurring reminder. A person's watch uses the one-shot queue
pattern in `rig context get reference/keeping-a-watch.md#setting-a-watch`.
Daemon `rig watchdog` is the active host coordination surface for new
work. History records only loud evaluations (`sent` / `terminal`);
quiet skip reasons are NOT recorded — POC parity so agents are not
+376 -29
View File
@@ -21,7 +21,8 @@
import fs from "node:fs";
import nodePath from "node:path";
import readline from "node:readline";
import { PassThrough } from "node:stream";
import { PassThrough, Writable } from "node:stream";
import stringWidth from "string-width";
import { StringDecoder } from "node:string_decoder";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
@@ -71,14 +72,306 @@ export function preparePiAccount(agentDir: string, accountHome: string | undefin
// Use Node's line editor in raw mode, with only paste framing handled here.
export const MAX_PI_INPUT_BYTES = 1024 * 1024;
// ── The compose region's physical layout ──────────────────────────────────────────
// One layout model for painting, erasing and the caret: cells fill terminal rows
// greedily, a wide character that no longer fits wraps whole to the next row, and
// an explicit newline starts one. Every row the compositor paints or erases, and
// the caret it places, derives from here — measured with the daemon's string-width.
/** Where a compose region sits on screen, and where its caret lands. */
export interface ComposeLayout { rows: number; caretRow: number; caretColumn: number; }
const SEGMENTER = (() => {
try {
return new Intl.Segmenter(undefined, { granularity: "grapheme" });
} catch {
return null;
}
})();
/** Grapheme clusters: what a terminal lays out. A ZWJ family emoji is one cluster
* taking two cells where its code points would sum to six; a combining mark adds none. */
export function* graphemesOf(text: string): Generator<string> {
if (SEGMENTER) {
for (const part of SEGMENTER.segment(text)) yield part.segment;
} else {
for (const character of text) yield character;
}
}
const TAB_WIDTH = 8;
export function composeLayout(text: string, width: number, caret = text.length): ComposeLayout {
if (!text) return { rows: 0, caretRow: 0, caretColumn: 0 };
if (width <= 0) return { rows: text.split("\n").length, caretRow: 0, caretColumn: 0 };
let row = 0;
let column = 0;
let caretRow = 0;
let caretColumn = 0;
let index = 0;
const place = () => { caretRow = row; caretColumn = column; };
for (const segment of graphemesOf(text)) {
if (index === caret) place();
if (segment === "\n") {
row += 1;
column = 0;
} else if (segment === "\t") {
// A tab advances to the next ordinary tab stop, clamped to the last column.
column = Math.min((Math.floor(column / TAB_WIDTH) + 1) * TAB_WIDTH, width - 1);
} else {
const cells = Math.max(0, stringWidth(segment));
if (column + cells > width) {
row += 1;
column = 0;
}
column += cells;
}
index += segment.length;
}
if (index === caret) place();
return { rows: row + 1, caretRow, caretColumn };
}
// ── Pane live region (compose + status) ────────────────────────────────────────
// A seat pane is a plain terminal: one bottom live region holds the operator's draft
// and the runner's status line, and is erased and repainted around every transcript
// write. Nothing else may write at the cursor, or a draft freezes into scrollback and
// streamed text fuses with it mid-word. When the terminal's width is unknown the
// compositor places nothing — the pane falls back to plain output, and the draft,
// though still editable, is not drawn.
/** The live region a pane host repaints around transcript writes. */
export interface PaneCompositor {
/** Bind the line editor the compose line renders from, and where its cursor sits. */
attach(read: () => string, redraw: () => void, caretUp?: () => number, readCursor?: () => number): void;
/** Unbind the editor; any held streamed tail is flushed and the live region is dropped. */
detach(): void;
/** A keystroke changed the draft: repaint the live region from readline's state. A
* draft that just went away flushes a held streamed tail first, as plain output would. */
refresh(): void;
/** Streamed model text: verbatim while no live region exists; once a draft or a
* status line is on the bottom, complete lines go above the region and an
* unfinished line is held, so no live line is split or frozen into scrollback. */
transcript(text: string): void;
/** One complete line of transcript. */
line(text: string): void;
/** A keypress is about to echo: close an open transcript line first, or the echo
* and the next streamed deltas would land on top of it. */
beforeInput(): void;
/** Pin the latest diagnostic on the status line. The transcript copy is the caller's;
* this pin is never the only copy. */
diagnostic(text: string): void;
/** Drop a pinned status line. */
clearStatus(): void;
/** Before the submit commits: drop the draft line so no partial copy scrolls, and
* finish any held streamed line; the durable copy is the commit line below. */
beforeSubmit(): void;
/** Repaint the live region in place. */
repaint(): void;
}
export function createPaneCompositor(out: NodeJS.WriteStream): PaneCompositor {
let status: string | null = null;
/** The status text actually on screen: clipped when it was painted. A resize must
* size the region from this, not from the longer text it was pinned with. */
let paintedStatus: string | null = null;
let readInput: (() => string) | null = null;
let redrawLine: (() => void) | null = null;
let held = "";
let openLine = false;
let screenRows = 0;
let readCaretUp: (() => number) | null = null;
let readCaretIndex: (() => number) | null = null;
const widthKnown = () => typeof out.columns === "number" && out.columns > 0;
const composeText = () => (readInput && redrawLine ? readInput() : "");
const composeRows = () => {
const text = composeText();
return text && widthKnown() ? composeLayout(text, out.columns).rows : 0;
};
const clip = (text: string) => {
const flat = text.replace(/[\r\n\t]+/g, " ").trimEnd();
if (!widthKnown() || stringWidth(flat) < out.columns) return flat;
let used = 0;
let clipped = "";
for (const segment of graphemesOf(flat)) {
const cells = Math.max(0, stringWidth(segment));
if (used + cells > out.columns - 1) break;
clipped += segment;
used += cells;
}
return `${clipped}\u2026`;
};
const eraseLines = (lines: number) => {
let sequence = "";
for (let index = 0; index < lines; index += 1) sequence += index === 0 ? "\r\u001b[2K" : "\u001b[1A\r\u001b[2K";
if (sequence) out.write(sequence);
};
/** Rows this compositor has on screen, recorded when it painted them. Readline never writes
* to the terminal, so nothing else can change these rows behind the compositor's back —
* except a reflow, which the resize handler owns. */
const paint = () => {
paintedStatus = status !== null ? clip(status) : null;
if (paintedStatus !== null) {
out.write(paintedStatus);
out.write("\n");
}
const rows = composeRows();
if (rows) redrawLine?.();
screenRows = (status !== null ? 1 : 0) + (rows || (status !== null ? 1 : 0));
// Only a painted region closes an open transcript line. With nothing painted the
// cursor still sits on the line a flush left open, and the next keystroke belongs
// to that line's context, not to a fresh one.
if (screenRows > 0) openLine = false;
};
const eraseScreen = () => {
// Nothing painted: there is no region to erase, and stepping down from a stale
// caret count would move the cursor off the transcript line it is still on.
if (screenRows === 0) return;
const down = readCaretUp ? readCaretUp() : 0;
if (down > 0) out.write(`\u001b[${down}B`);
eraseLines(screenRows);
screenRows = 0;
};
const flushHeld = () => {
if (!held) return;
out.write(held.endsWith("\n") ? held : `${held}\n`);
held = "";
openLine = false;
};
const writeVerbatim = (text: string) => {
out.write(text);
openLine = !text.endsWith("\n");
};
const writeAbove = (text: string) => {
eraseScreen();
out.write(text.endsWith("\n") ? text : `${text}\n`);
paint();
};
// A tile attaching or detaching reflows the pane, and the terminal keeps the cursor on
// its character. So the old paint now occupies exactly what the new width says it does:
// the pinned status wraps to ceil(cells/columns) rows, the draft to its layout rows.
// Move down to the region's last row — past the lines below the caret — erase exactly
// that many rows, and repaint at the new width. No max(): under-erasing leaves a stale
// copy of a wrapped status, over-erasing drops a transcript line off the screen.
const onResize = () => {
if (!widthKnown()) {
screenRows = 0;
return;
}
const draft = composeText();
const layout = draft ? composeLayout(draft, out.columns, readCaretIndex ? readCaretIndex() : draft.length) : null;
const draftRows = layout ? layout.rows : 0;
const statusLines = paintedStatus !== null ? Math.max(1, Math.ceil(stringWidth(paintedStatus) / out.columns)) : 0;
const down = layout ? layout.rows - 1 - layout.caretRow : 0;
if (down > 0) out.write(`\u001b[${down}B`);
// A pinned status without a draft also owns the blank row its trailing newline
// left the cursor on — the same row paint() counts into screenRows.
eraseLines(statusLines + (draftRows || (status !== null ? 1 : 0)));
screenRows = 0;
paint();
};
out.on("resize", onResize);
const pane: PaneCompositor = {
attach(read, redraw, caretUp, readCursor) {
readInput = read;
redrawLine = redraw;
readCaretUp = caretUp ?? null;
readCaretIndex = readCursor ?? null;
},
detach() {
eraseScreen();
flushHeld();
readInput = null;
redrawLine = null;
readCaretUp = null;
readCaretIndex = null;
openLine = false;
screenRows = 0;
},
refresh() {
eraseScreen();
// With no region left, an unfinished streamed line goes out verbatim so the
// next delta continues it on the same row instead of splitting mid-word.
if (held && status === null && !composeText()) {
writeVerbatim(held);
held = "";
}
paint();
},
transcript(text) {
if (!text) return;
if (status === null && composeRows() === 0 && screenRows === 0) {
if (held) {
writeVerbatim(held);
held = "";
}
writeVerbatim(text);
return;
}
held += text;
let newline = held.indexOf("\n");
while (newline >= 0) {
const line = held.slice(0, newline + 1);
held = held.slice(newline + 1);
writeAbove(line);
newline = held.indexOf("\n");
}
},
line(text) {
pane.transcript(`${text}\n`);
},
beforeInput() {
if (!openLine) return;
out.write("\n");
openLine = false;
},
diagnostic(text) {
// Without a known width there is no region to pin into. Callers already wrote
// the transcript copy; printing here would duplicate it.
if (!widthKnown()) return;
if (openLine) {
out.write("\n");
openLine = false;
}
eraseScreen();
status = clip(text);
paint();
},
clearStatus() {
if (status === null) return;
eraseScreen();
status = null;
paint();
},
beforeSubmit() {
eraseScreen();
openLine = false;
flushHeld();
},
repaint() {
paint();
},
};
return pane;
}
export function createRunnerInput(
input: NodeJS.ReadStream,
output: NodeJS.WriteStream,
onSubmit: (block: string) => void,
pane?: PaneCompositor,
): readline.Interface {
const reject = () => output.write(
"[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.\n",
);
const say = (text: string) => {
if (pane) {
pane.line(text);
pane.diagnostic(text);
} else {
output.write(`${text}\n`);
}
};
const reject = () => {
say("[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.");
};
const submit = (block: string) => {
if (Buffer.byteLength(block) > MAX_PI_INPUT_BYTES) reject();
else if (block.trim()) onSubmit(block);
@@ -94,14 +387,37 @@ export function createRunnerInput(
|| (major === 25 && (minor === 0 || (minor === 1 && patch === 0)));
const keys = new PassThrough();
const editorInput = batchText ? new PassThrough() : keys;
// Readline edits against a discard sink: it must never draw on the terminal, because
// _refreshLine does moveCursor(0, -prevRows) + clearScreenDown(), which erases whatever the
// compositor put above the draft. The compositor owns every terminal byte instead.
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
// Readline wraps its edit against output.columns; carry the terminal's geometry onto the sink.
Object.assign(sink, { columns: output.columns, rows: output.rows });
let caretUp = 0;
const renderCompose = () => {
const text = `${editor.line ?? ""}`;
if (!text) return;
const width = typeof output.columns === "number" && output.columns > 0 ? output.columns : 0;
output.write(text);
if (!width) return;
const cursor = Math.max(0, Math.min(Number(editor.cursor) || 0, text.length));
const layout = composeLayout(text, width, cursor);
caretUp = layout.rows - 1 - layout.caretRow;
if (caretUp > 0) output.write(`\u001b[${caretUp}A`);
output.write("\r");
if (layout.caretColumn > 0) output.write(`\u001b[${layout.caretColumn}C`);
};
pane?.attach(() => `${editor.line ?? ""}`, renderCompose, () => caretUp, () => Math.max(0, Math.min(Number(editor.cursor) || 0, `${editor.line ?? ""}`.length)));
const editor = readline.createInterface({
input: editorInput, output, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
input: editorInput, output: sink, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
});
// Node forces dumb-terminal mode without editing if TERM=dumb. When terminal: true
// was requested, remove the dumb-mode override so VT100 line editing is preserved.
if (Object.prototype.hasOwnProperty.call(editor, "_ttyWrite")) {
delete (editor as any)._ttyWrite;
}
const decoder = new StringDecoder("utf8");
let pending = "";
let paste: string | null = null;
@@ -111,7 +427,7 @@ export function createRunnerInput(
// Node documents changing rl.line together with rl.cursor. The installed
// typings mark them readonly, so assign this pair through one explicit seam.
Object.assign(editor, { line, cursor });
editor.prompt(true);
pane?.refresh();
};
const clear = () => setLine("", 0);
// Node 22.21–22.22.0 (also some 24/25 releases) appends batched text after
@@ -137,6 +453,7 @@ export function createRunnerInput(
}
const consume = (text: string) => {
if (paste === null) {
pane?.beforeInput();
keys.write(text);
if (batchText) flushTyped();
} else if (!discarded) {
@@ -170,7 +487,7 @@ export function createRunnerInput(
paste = null;
discarded = false;
clear();
output.write("\n[pi-runner] input cleared\n");
say("[pi-runner] input cleared");
onSubmit("/abort");
} else if (paste === null) {
paste = "";
@@ -189,6 +506,7 @@ export function createRunnerInput(
paste = null;
}
}
pane?.refresh();
};
const wasRaw = input.isRaw;
const end = () => editor.close();
@@ -196,7 +514,10 @@ export function createRunnerInput(
output.write("\u001b[?2004h");
input.on("data", receive);
input.once("end", end);
editor.on("line", submit);
editor.on("line", (line) => {
pane?.beforeSubmit();
submit(line);
});
editor.once("close", () => {
input.removeListener("data", receive);
input.removeListener("end", end);
@@ -204,6 +525,7 @@ export function createRunnerInput(
input.pause();
keys.removeListener("keypress", keypress);
keys.destroy();
pane?.detach();
if (editorInput !== keys) editorInput.destroy();
output.write("\u001b[?2004l");
});
@@ -359,6 +681,11 @@ export interface RunnerIo {
mirrorLine(line: string): void;
/** Append raw text to the current pane line (streamed deltas). */
mirrorAppend(text: string): void;
/** Pin a copy of a diagnostic on the pane's status line, when the pane provides one.
* The transcript copy is written by the caller; this pin is never the only copy. */
diagnostic?(text: string): void;
/** Drop a pinned status line at the start of a new agent turn. */
clearStatus?(): void;
/** POST to the daemon activity endpoint. May resolve to the parsed JSON
* response (null on transport failure) so identity delivery can retry. */
postActivity(payload: Record<string, unknown>): void | Promise<Record<string, unknown> | null>;
@@ -592,7 +919,7 @@ export class RunnerCore {
/** A frame that can't be reassembled is lost, and said so. If it was a get_entries reply, the cursor stays where it was. */
private reportDroppedFrame(reason: string | null | undefined): void {
if (reason) this.io.mirrorLine(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
if (reason) this.diag(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
}
private get runtime(): string { return this.opts.runtime ?? "pi"; }
@@ -601,7 +928,7 @@ export class RunnerCore {
handleUserBlock(block: string): void {
if (block === "/abort") {
this.io.sendRpc({ type: "abort" });
this.io.mirrorLine("[pi-runner] abort sent");
this.diag("[pi-runner] abort sent");
return;
}
if (block.startsWith("/followup ")) {
@@ -646,7 +973,7 @@ export class RunnerCore {
} else {
// Advertised but refused: v1 as before, so a reply over 1 MiB still fails, visibly.
const detail = typeof record.error === "string" ? record.error : "refused";
this.io.mirrorLine(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
this.diag(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
}
return;
}
@@ -656,7 +983,7 @@ export class RunnerCore {
if (record.id === GET_STATE_ID) {
this.ready = false;
this.writeSidecar({});
this.io.mirrorLine(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
this.diag(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
// OMP cannot provide a resume token without get_state; stop it so the
// launch fails now instead of waiting out readiness.
if (this.runtime === "omp") this.io.stopChild?.();
@@ -665,11 +992,11 @@ export class RunnerCore {
if (record.id === CATCH_UP_ID && this.runtime === "pi") {
// A refused catch-up (a saved cursor this session's history doesn't hold, such as an older runner's UI-event
// id) is history sync, not the seat's resume: say so, and take a confirmed cursor from the history's tail.
this.io.mirrorLine(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
this.diag(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
this.io.sendRpc({ type: "get_entries", id: CURSOR_REFRESH_ID });
return;
}
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc: ${message}`);
this.diag(`[${this.runtime}-runner] ERROR rpc: ${message}`);
// Only a rejected input means the seat cannot do the requested work.
// A mistimed /abort (or any control command) leaves the seat idle.
if (this.runtime === "omp" && typeof record.command === "string" && Object.hasOwn(INPUT_COMMANDS, record.command)) {
@@ -687,13 +1014,13 @@ export class RunnerCore {
this.ready = this.runtime === "pi" || !!this.sessionFile;
if (!this.ready) {
if (this.runtime === "omp") {
this.io.mirrorLine("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
this.diag("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
this.io.stopChild?.();
return;
}
// For custom protocol: pi-rpc community harnesses, reporting no session file
// reads as needing attention rather than stopping the child process.
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
this.diag(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
this.raiseAttention("runtime_error");
return;
}
@@ -856,6 +1183,13 @@ export class RunnerCore {
this.attentionIsLatest = false;
}
/** A diagnostic stays in the transcript — the readiness probe and the operator both read
* it there; a pane with a status line pins the latest one as a convenience copy. */
private diag(text: string): void {
this.io.mirrorLine(text);
this.io.diagnostic?.(text);
}
private handleEvent(event: Record<string, unknown>): void {
if (event.type === "extension_ui_request") {
const method = event.method;
@@ -863,7 +1197,7 @@ export class RunnerCore {
const detail = [event.title, event.message].filter((text): text is string => typeof text === "string")
.map(stripVTControlCharacters).join(" — ").replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 400);
if (this.runtime !== "omp" && !dialog) {
if (method === "notify") this.io.mirrorLine(`[${this.runtime}-runner] ${detail}`);
if (method === "notify") this.diag(`[${this.runtime}-runner] ${detail}`);
return; // Pi notifications and other fire-and-forget UI requests expect no answer.
}
// RPC does not mount an interactive UI. Reply at once; otherwise the
@@ -872,7 +1206,7 @@ export class RunnerCore {
this.io.sendRpc({ type: "extension_ui_response", id: event.id, cancelled: true });
}
if (dialog) {
this.io.mirrorLine(this.runtime !== "omp"
this.diag(this.runtime !== "omp"
? `[${this.runtime}-runner] Extension ${method}: ${detail}; cancelled because the managed RPC pane cannot answer it. Operator action required: review the cancelled request and re-send an instruction the extension can complete without a dialog.`
: "[omp-runner] Approval/input requested; cancelled because the managed RPC pane cannot answer it. Operator action required: review the denied action and re-send a safe instruction, or change the seat permission policy explicitly.");
this.raiseAttention("permission_prompt");
@@ -892,7 +1226,10 @@ export class RunnerCore {
this.writeSidecar({});
}
if (event.type === "agent_start") this.clearAttention();
if (event.type === "agent_start") {
this.clearAttention();
this.io.clearStatus?.();
}
if (this.runtime === "omp") {
if (event.type === "agent_start" || event.type === "turn_start"
|| (event.type === "message_start" && message?.role === "assistant")
@@ -936,7 +1273,7 @@ export class RunnerCore {
// the repeat lacks it, then reset at the next assistant message/agent
// turn, not at agent_end.
if (event.type === "message_end" || !this.assistantErrorShown) {
this.io.mirrorLine(mapped.errorNotice);
this.diag(mapped.errorNotice);
}
this.assistantErrorShown = true;
// Only typed, explicitly transient HTTP failures may release the send
@@ -1445,6 +1782,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
// The child may close its pipe before the first RPC write completes.
child.stdin.on("error", () => { /* spawn/exit handlers own the diagnosis */ });
const pane = createPaneCompositor(process.stdout);
// Diagnostics stay in the transcript — the pane's readiness probe greps it, and people
// and agents read the history there; the status line only pins the latest copy.
const paneDiag = (text: string) => {
pane.line(text);
pane.diagnostic(text);
};
const chargeLookup = new OpenRouterChargeLookup({
fetchGeneration: openRouterGenerationFetcher(() => childEnv["OPENROUTER_API_KEY"]),
postCharges: async (piSessionId, charges) => {
@@ -1463,7 +1808,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
}, () => false as const).finally(() => clearTimeout(timeout));
},
schedule: (fn, ms) => { setTimeout(fn, ms).unref(); },
notice: (line) => { process.stdout.write(`${line}\n`); },
notice: (line) => { paneDiag(line); },
});
// One activity post at a time: the daemon reads its newest record by arrival, so a beat still in flight when a turn
@@ -1486,8 +1831,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
sendRpc: (cmd) => {
try { child.stdin.write(`${JSON.stringify(cmd)}\n`); } catch { /* exit handler reports */ }
},
mirrorLine: (line) => process.stdout.write(`${line}\n`),
mirrorAppend: (text) => process.stdout.write(text),
mirrorLine: (line) => pane.line(line),
mirrorAppend: (text) => pane.transcript(text),
diagnostic: (text) => pane.diagnostic(text),
clearStatus: () => pane.clearStatus(),
postActivity: (payload) => {
if (!endpoint || typeof fetch !== "function") return;
const result = postInOrder(payload).catch(() => null);
@@ -1568,7 +1915,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
let reportedPiCapability = false;
readline.createInterface({ input: child.stderr }).on("line", (line) => {
if (!line.trim()) return;
process.stdout.write(`[${runtime}:err] ${line}\n`);
paneDiag(`[${runtime}:err] ${line}`);
stderrTail.push(line.length > OMP_STDERR_TAIL_CHARS ? `${line.slice(0, OMP_STDERR_TAIL_CHARS)}...` : line);
if (stderrTail.length > OMP_STDERR_TAIL_LINES) stderrTail.shift();
if (runtime === "pi" && !core.isReady() && !reportedPiCapability) {
@@ -1582,15 +1929,15 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
});
if (detail) {
reportedPiCapability = true;
process.stdout.write(`[pi-runner] ${detail}\n`);
paneDiag(`[pi-runner] ${detail}`);
}
}
});
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block));
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block), pane);
if (runtime !== "omp") {
child.on("error", (err) => {
console.error(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
paneDiag(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
const code = (err as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1;
core.handlePiExit(code);
input.close();
@@ -1617,7 +1964,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
if (!core.isReady()) {
const how = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
const output = stderrTail.length > 0 ? ` Its last output: ${stderrTail.join(" | ")}` : " It printed nothing on stderr.";
console.error(transportUp
paneDiag(transportUp
? "[omp-runner] ERROR OMP did not establish a resumable RPC session. Authenticate this isolated seat using HOME=<seat-root> PI_CODING_AGENT_DIR=<seat-root>/agent omp and /login, or provide its declared model provider key in the OpenRig daemon environment. Default OMP credentials are not shared."
: `[omp-runner] ERROR OMP exited during startup, before its RPC transport started (${command}, ${how}).${output}`);
}
@@ -1626,7 +1973,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
process.exitCode = code ?? 1;
};
child.on("error", (err) => {
console.error(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
paneDiag(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
recordExit(null);
});
// "close" fires once OMP's stdio is drained, so its stderr is in the tail
@@ -26,6 +26,11 @@ export interface HookActivityInput {
sourceSequence?: number | null;
/** How often the emitter promises to report while it stays idle (OpenRig's pi runner). */
livenessSeconds?: number | null;
/** #99: the reset instant a Claude usage limit stated; kept only on that typed failure. */
resetsAt?: string | null;
/** The provider's own session id the hook reports (Claude's session_id). A stated reset is kept only when this is
* the seat's own Claude session (its resume token), so a helper on another login can't park the seat's pool. */
sessionId?: string | null;
}
export type RecordHookActivityResult =
@@ -113,6 +118,16 @@ export class AgentActivityStore {
} catch { return unavailablePermissionStop(); }
}
/** #99: whether a hook's Claude session id is the seat's own Claude session (its current resume token). A helper
* Claude started from the seat's shell posts as the seat but runs its own session, possibly on another login. */
private isSeatOwnClaudeSession(nodeId: string, hookSessionId: string | null | undefined): boolean {
const token = typeof hookSessionId === "string" ? hookSessionId.trim() : "";
if (!token) return false;
const row = this.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
.get(nodeId) as { resume_type: string | null; resume_token: string | null } | undefined;
return row?.resume_type === "claude_id" && row.resume_token?.trim() === token;
}
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
if (!input.sessionName && !input.nodeId) {
return {
@@ -159,6 +174,10 @@ export class AgentActivityStore {
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
if (input.resetsAt && isClaudeRateLimitHook(activity.runtime, input.hookEvent, input.subtype)
&& this.isSeatOwnClaudeSession(session.node_id, input.sessionId)) {
activity.resetsAt = input.resetsAt;
}
const event = this.eventBus.emit({
type: "agent.activity",
@@ -967,7 +967,7 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
function runnerAnswerNotice(ask: QueueItem): string | undefined {
if (ask.state === "handed-off" && ask.handedOffTo) {
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run.`;
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.`;
}
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
+7 -5
View File
@@ -1508,7 +1508,7 @@ export function hookOrPaneActivity(
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
if (structural && structural.state !== "unknown") {
if (structural && (structural.state !== "unknown" || structural.reason === "unrecognized_client_version")) {
return {
source: "pane",
activity: {
@@ -1617,8 +1617,9 @@ export async function attachAgentActivity(
*
* needs_input > running > MOTION > idle / unknown
*
* A current, target-bound complete idle frame is the narrow exception to motion below.
* A generic footer or old idle capture is not that evidence; D2 remains unchanged.
* A current, target-bound complete idle frame is the narrow idle exception to motion below.
* An explicitly unsupported client version also retains unknown rather than inferring work from redraws.
* A generic footer or old idle capture is not completed-idle evidence; D2 remains unchanged.
* Live motion otherwise UPGRADES a verdict of `idle` or `unknown`; it never touches `needs_input` or an
* already-`running` verdict, and it never MANUFACTURES `idle`. That asymmetry is the whole
* safety argument, and it is what each half of the defect needs:
@@ -1644,7 +1645,7 @@ export async function attachAgentActivity(
* signal improving. Silence therefore leaves the prior verdict exactly as it was.
*/
const withMotion = (activity: AgentActivity, currentIdleFrame = false): AgentActivity => {
if (!motionRunning || (currentIdleFrame && activity.state === "idle")) return activity;
if (!motionRunning || activity.reason === "unrecognized_client_version" || (currentIdleFrame && activity.state === "idle")) return activity;
if (activity.state !== "idle" && activity.state !== "unknown") return activity;
return {
state: "running",
@@ -1682,7 +1683,8 @@ export async function attachAgentActivity(
// An idle hook alone still yields to motion (D2); when a current complete idle frame exists, report
// that frame's provenance instead.
const resolved = hookOrPaneActivity(hookActivity, structural, motionRunning && currentIdleFrame, entry.runtime);
const resolved = hookOrPaneActivity(hookActivity, structural,
motionRunning && (currentIdleFrame || structural?.reason === "unrecognized_client_version"), entry.runtime);
if (resolved?.source === "hook") return { ...entry, agentActivity: withMotion(resolved.activity) };
if (resolved?.source === "limit") return { ...entry, agentActivity: resolved.activity };
if (resolved?.source === "pane") return { ...entry, agentActivity: withMotion(resolved.activity, currentIdleFrame) };
@@ -219,6 +219,8 @@ export type ReactiveEventInput = {
// Required: a reactive event without a freshness bound could never pass BR-2, and a momentary
// event must always carry when it expires. Staleness itself stays the shared predicate's job.
staleAfter: string;
/** #99: when the provider's own failure says the limit resets. Absent, the pool falls back to the bounded retry. */
resetsAt?: string;
} & ({ provider: ProviderKind; accountRef: string; seatSession?: never }
| { provider: "claude"; seatSession: string; accountRef?: never });
@@ -236,6 +238,7 @@ export function reactiveEventSignal(input: ReactiveEventInput): ProviderSignal {
// At-limit exhaustion is an actionable switch trigger; stream/stop errors are advisory.
automationUse: input.kind === "at_limit" ? "allow_switch_decision" : "advisory_only",
// No usedPercent — a reactive event is exhaustion evidence, not a remaining meter.
...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
};
}
@@ -319,8 +322,8 @@ export function deriveUsageLimitPools(input: {
const statedResets = pool.signals
.map((signal) => Date.parse(signal.resetsAt ?? ""))
.filter((value) => Number.isFinite(value) && value > nowMs);
// A typed Claude failure has no reset timestamp. Use the existing bounded retry interval,
// as for Codex events; a Claude meter missing its reset still cannot invent one.
// A typed Claude failure carries a reset only when Claude's own message stated one (#99). Without it, use
// the existing bounded retry interval, as for Codex events; a Claude meter missing its reset still cannot invent one.
const fallbackResets = pool.signals
.filter((signal) => pool.provider === "codex" || (signal.sourceClass === "provider_event" && signal.authority === "reactive_error"))
.map((signal) => Date.parse(signal.asOf) + input.fallbackSeconds * 1000)
@@ -4,6 +4,9 @@ import type { CodexAuthMetadata } from "./codex-auth-reader.js";
import { reactiveEventSignal, type ReactiveEventKind } from "./provider-signals.js";
import type { ProviderSignal } from "./provider-types.js";
/** #99: the farthest a stated Claude reset may sit after its failure: the relay's own cap (activity-relay.cjs). */
const CLAUDE_STATED_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
interface ReactiveSeat {
seatSession: string;
runtime: string;
@@ -98,9 +101,13 @@ export function collectReactiveEventSignals(deps: ReactiveTapDeps): ReactiveTapR
|| isVerificationReason(event.reason) || event.stale) continue;
const eventAtMs = Date.parse(event.eventAt ?? "");
if (!Number.isFinite(eventAtMs) || eventAtMs > nowMs || nowMs >= eventAtMs + deps.freshnessMs) continue;
// #99: the reset Claude's message stated, when the relay could read one, after the failure and within the relay's
// eight-day cap. The cap is enforced here too, so no other sender can park a pool longer than the relay could.
const resetMs = Date.parse(event.resetsAt ?? "");
signals.push(reactiveEventSignal({
provider: "claude", seatSession: seat.seatSession, kind: "at_limit",
asOf: event.eventAt!, staleAfter: new Date(eventAtMs + deps.freshnessMs).toISOString(),
...(Number.isFinite(resetMs) && resetMs > eventAtMs && resetMs <= eventAtMs + CLAUDE_STATED_RESET_MAX_MS ? { resetsAt: new Date(resetMs).toISOString() } : {}),
}));
continue;
}
@@ -4272,7 +4272,9 @@ export class QueueRepository {
// The person learns who decides and in what recorded state, so a delay is shown: one update per request.
const toldId = `qitem-runner-permission-passed-${item.qitemId.replace(/^qitem-runner-permission-/, "").replace(/^qitem-/, "")}`;
setImmediate(() => {
// The request the person answered is what they'd judge (convention C3: a note to a person names its evidence).
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
evidenceRef: `rig queue show ${root.qitemId}`,
summary: `Your answer went to ${decider} to decide; ${root.sourceSession} is waiting`,
body: [`Your answer to ${root.sourceSession}'s permission request ${root.qitemId} went to ${decider}, which decides it while ${root.sourceSession} waits. Nothing runs until it decides.`,
...(chosen?.describe ? [`Receiving agent: ${chosen.describe}`] : []),
@@ -4323,6 +4325,7 @@ export class QueueRepository {
// After the answer's own write settles: one receipt per request (its id is the request's), told to who answered.
setImmediate(() => {
void this.create({ qitemId: receiptId, sourceSession: requester, destinationSession: person, humanIntent: "update",
evidenceRef: `rig queue show ${item.qitemId}`,
summary: `Your answer to ${requester}'s permission request is kept; the tool hasn't run`,
body: [`Your answer to ${requester}'s permission request ${item.qitemId} is kept, and the request is still waiting: ${unreachable ? `it couldn't be passed to ${unreachable}, the receiving agent, just now (answer again to retry)` : `no receiving agent on this host can read it before the tool runs (none is set, or the only one is ${requester} itself)`}. The tool hasn't run, and nothing was permitted or refused.`,
`To decide now: rig seat decide-permission ${item.qitemId} --option <id>${options.length ? ` (options: ${options.map((o) => `${o.id}, ${o.label}`).join("; ")})` : ""}. Or set up a receiving agent that isn't ${requester}: rig config set agents.operator_session <seat>@<rig>.`,
@@ -331,8 +331,15 @@ export class RunnerPermissions {
const changed = new Promise<void>((resolve) => { release = resolve; });
let liveId = askId; // after a route or handoff, the colleague's row is where the answer lands
const unsubscribe = this.deps.eventBus.subscribe((event) => {
const changedId = (event as { type?: string; qitemId?: string }).type === "queue.updated" ? (event as { qitemId?: string }).qitemId : undefined;
if (changedId === askId || changedId === liveId) release();
const { type, qitemId } = event as { type?: string; qitemId?: string };
// A handoff while this read waits (the person's answer going to the receiving agent) moves the request to a new
// row: follow it, so the decision recorded there wakes this read rather than its next one.
if (type === "queue.handed_off" && (qitemId === askId || qitemId === liveId)) {
const asked = this.deps.queueRepo.getById(askId);
liveId = (asked ? this.liveEnd(asked)?.qitemId : undefined) ?? liveId;
return;
}
if (type === "queue.updated" && (qitemId === askId || qitemId === liveId)) release();
});
try {
const now = this.withWords(askId, this.read(seat, askId));
@@ -12,6 +12,7 @@ import { SessionAddressConflict, type SessionRegistry } from "./session-registry
import type { DiscoveryRepository } from "./discovery-repository.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { shellQuote } from "../adapters/shell-quote.js";
import type { TmuxOptionDefaultsApplier } from "./tmux-option-defaults.js";
import { SeatStatusService, type SeatStatus, type SeatStatusResult } from "./seat-status-service.js";
import { SeatHandoverPlanner, parseHandoverSource, SEAT_HANDOVER_SOURCE_CAPABILITIES, type SeatHandoverPlan, type SeatHandoverSource } from "./seat-handover-planner.js";
@@ -118,7 +119,9 @@ export type SeatHandoverResult =
| { ok: false; code: "missing_reason" | "invalid_source" | "successor_creation_not_implemented" | "source_not_supported" | "resume_token_unavailable" | "fork_source_not_found"; message: string; guidance: string }
| { ok: false; code: "address_conflict" | "current_occupant_required" | "discovered_not_active" | "successor_tmux_absent" | "successor_already_managed" | "successor_is_current" | "runtime_mismatch"; message: string; guidance: string }
| { ok: false; code: "discovered_not_found"; message: string; guidance: string }
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "successor_create_failed" | "context_delivery_failed"; message: string; guidance: string }
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "context_delivery_failed"; message: string; guidance: string }
// #2985: once the successor was launched in the respawned pane, the failure says so and names the pane.
| { ok: false; code: "successor_create_failed"; message: string; guidance: string; replacementStarted?: true; successorPane?: string }
| Extract<SeatStatusResult, { ok: false }>;
type ClaudeRestorePacketObservation =
@@ -588,6 +591,24 @@ export class SeatHandoverService {
},
});
if (!launch.ok) {
// #2985: the harness was launched into the respawned pane and only readiness failed. The previous occupant is
// already ended and the successor may come up (or already be working) there, but the handover never commits,
// so its generation is never registered. Retrying the handover would end it, so the guidance says what may be
// in the pane instead of "retry". It claims only what holds for every runtime (a Codex successor's hooks are
// not held back by generation_uncommitted, which is Claude-only).
if (launch.step === "start_agent" && launch.replacementStarted && launch.successorLaunched) {
const pane = launch.tmuxPane ?? "unknown";
const session = latestSession.session_name;
const quoted = shellQuote(session);
return {
ok: false,
code: "successor_create_failed",
message: `Handover failed at step "${launch.step}": ${launch.message}`,
replacementStarted: true,
...(launch.tmuxPane ? { successorPane: launch.tmuxPane } : {}),
guidance: `The handover was not committed: the seat's registry binding is unchanged and OpenRig has not registered the successor as the seat's occupant. The previous occupant was already ended, and a successor was launched in pane "${pane}" of ${session}; it may still be starting there or already working. Look at the pane before acting: rig capture ${quoted}. Another handover, or rig seat launch ${quoted} --fresh --stop --reason "<why>", ends that successor if it goes ahead. Shell-looking output alone does not confirm that the successor has stopped; check the launch and process state before another handover.`,
};
}
return {
ok: false,
code: "successor_create_failed",
@@ -833,12 +854,12 @@ export class SeatHandoverService {
}
// #981: a composer-launched successor respawns into the seat's own pane, whose discovery row the
// previous handover claimed for this same node, and a rescan keeps that claim (#237). That claim is
// this seat's own, so commit re-claims it. A row claimed by any other node, or a discovered-source
// successor, still needs an active, unclaimed record.
// this seat's own, so commit re-claims it. #2861: a deleted owner or a terminal owner in an archived
// earlier rig can also leave that pane claimed. Other owners and discovered-source successors still refuse.
const ownPaneClaim = input.reportedSource.mode !== "discovered"
&& discovered.status === "claimed"
&& discovered.claimedNodeId === input.node.id
&& discovered.tmuxSession === input.latestSession.session_name;
&& discovered.tmuxSession === input.latestSession.session_name
&& (discovered.claimedNodeId === input.node.id || this.isStalePaneClaimOwner(discovered.claimedNodeId));
if (discovered.status !== "active" && !ownPaneClaim) {
return fail({
ok: false,
@@ -1256,6 +1277,15 @@ export class SeatHandoverService {
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
}
private isStalePaneClaimOwner(nodeId: string | null): boolean {
if (nodeId === null) return true; // ON DELETE SET NULL retains the claim after its owner is removed.
return this.db.prepare(`
SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NOT NULL
AND (SELECT status FROM sessions WHERE node_id = n.id ORDER BY id DESC LIMIT 1) IN ('superseded', 'detached', 'exited')
`).pluck().get(nodeId) === 1;
}
private commit(input: {
seatRef: string;
status: SeatStatus;
@@ -1277,6 +1307,15 @@ export class SeatHandoverService {
}): SeatHandoverResult {
const handoverAt = this.now().toISOString();
const tx = this.db.transaction(() => {
// The presence probe awaited after validation. Reclaim only the same still-stale foreign claim,
// inside the transaction that assigns it, so a resumed owner or a new claimant cannot be overwritten.
if (input.discovered.status === "claimed" && input.discovered.claimedNodeId !== input.node.id) {
const current = this.discoveryRepo.getDiscoveredSession(input.discovered.id);
if (current?.status !== "claimed" || current.claimedNodeId !== input.discovered.claimedNodeId
|| !this.isStalePaneClaimOwner(current.claimedNodeId)) {
return null;
}
}
const rows = this.db.prepare(
"SELECT id FROM sessions WHERE node_id = ? AND status NOT IN ('superseded', 'detached', 'exited') ORDER BY id"
).all(input.node.id) as Array<{ id: string }>;
@@ -1392,7 +1431,16 @@ export class SeatHandoverService {
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean };
try {
committed = tx();
const outcome = tx();
if (outcome === null) {
return {
ok: false,
code: "discovered_not_active",
message: `Discovery record "${input.discovered.id}" no longer has a stale owner claim.`,
guidance: "Inspect the current discovery owner and successor state before retrying.",
};
}
committed = outcome;
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
// oracle sees the swap as its own event, keyed by the durable node id, identified
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
@@ -73,7 +73,8 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
/**
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
* a viewing window detaches from the tmux session and leaves it running.
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
@@ -85,9 +86,13 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
}
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
/** Why `rig down <rig>` isn't the route for an adopted session. */
function rigDownLeavesIt(rigName: string): string {
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
@@ -1381,6 +1386,8 @@ export class SeatLifecycleService {
ok: false,
code: "unmanaged_session_collision",
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
+ "then launch again. Closing only its viewing window doesn't end the session.",
};
}
if (!stop) {
@@ -1424,8 +1431,10 @@ export class SeatLifecycleService {
return {
ok: false, code: "claimed_session",
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
+ (externalCli
? "end that process and close its terminal, then launch fresh."
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
};
}
const binding = this.sessionRegistry.getBindingForNode(nodeId);
@@ -1,6 +1,6 @@
import type Database from "better-sqlite3";
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, namesAnotherClientVersion, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
@@ -129,6 +129,9 @@ export class SeatStructuralActivityService {
return null;
}
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
// Redraws from an unrecorded Codex layout cannot establish work or completed idle.
// Keep positive work and attention visible; native hooks are resolved separately by the activity fold.
const unrecognizedVersion = runtime === "codex" && c.state !== "attention" && c.state !== "agent_active" && namesAnotherClientVersion(content, "codex");
const at = observedAt ?? this.now();
const latest = this.latestBySession.get(sessionName);
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
@@ -146,8 +149,8 @@ export class SeatStructuralActivityService {
...(advanced ? { previousCaptureStartedAt: previous!.codexWorkingClock!.captureStartedAt } : {}) }
: priorProof;
const obs: StructuralObservation = {
state: c.state,
reason: c.reason,
state: unrecognizedVersion ? "unknown" : c.state,
reason: unrecognizedVersion ? "unrecognized_client_version" : c.reason,
evidence: c.evidence,
observedAt: at.toISOString(),
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
@@ -167,7 +170,7 @@ export class SeatStructuralActivityService {
// Null/failed, blank and unrecognized captures cannot establish recovery.
if (runtime === "codex" && this.bannerEmitter) {
const banner = detectCodexLimitBanner(content);
if (banner || (content.trim().length > 0 && c.state !== "unknown")) {
if (banner || (content.trim().length > 0 && obs.state !== "unknown")) {
this.bannerEmitter(sessionName, banner, at);
}
}
@@ -1835,8 +1835,9 @@ const SEAT_IDLE_SHAPES: {
},
},
codex: {
// The hint row under the status line of an empty box (product-path-idle.J, idle).
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
// Empty-box hints: 0.160 product-path-idle.J/idle, and the captured 0.167 completed frame.
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/,
/^← for agents · \? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
// The last non-blank row above the empty box: the completion row Codex adds when a turn completes
// (after-enter-15s.J; its grammar is CODEX_COMPLETION_ROW above), or the greeting under the session header
// before any turn (idle, product-path-idle.J, cleared-without-enter.J). A running or streaming turn draws rows
@@ -1909,7 +1910,7 @@ const VISIBLE_CLIENT_VERSION: Record<"claude-code" | "codex", RegExp[]> = {
};
/** Whether a frame visibly names a client version other than the recorded one (see RECORDED_CLIENT_VERSION). */
function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
export function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
return VISIBLE_CLIENT_VERSION[runtime].some((pattern) =>
[...pane.matchAll(pattern)].some((match) => match[1] !== RECORDED_CLIENT_VERSION[runtime]));
}
@@ -116,6 +116,11 @@ export type SuccessorLaunchResult =
message: string;
/** True once the predecessor process has been replaced in its pane. */
replacementStarted: boolean;
/** The pane the successor was being started in, once it is resolved (start_agent failures). */
tmuxPane?: string;
/** True when the harness launch was sent into the pane and only readiness failed (#2985): the successor may
* still come up there, and it is not adopted. */
successorLaunched?: boolean;
};
export class SuccessorSessionLauncher {
@@ -293,11 +298,14 @@ export class SuccessorSessionLauncher {
// 3. Route the successor through REAL runtime startup (launchHarness send-keys + readiness) so it
// becomes a LIVE, READY agent in the reused pane BEFORE it can be committed. UNWIND INVARIANT:
// on ANY launch/readiness failure we do NOT killSession the preserved seat — that would destroy
// the retiree's recoverable state. We return the structured failure and leave the re-wakeable
// shell in the pane; commit never runs, so the binding is not repointed.
// the retiree's recoverable state. We return the structured failure; commit never runs, so the binding is
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
// can tell the operator that a retry would end it.
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
if (!started.ok) {
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true };
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
}
const discovered = this.discoveryRepo.upsertDiscoveredSession({
@@ -386,7 +394,7 @@ export class SuccessorSessionLauncher {
forkSource?: ForkSource,
launchGeneration?: string | null,
staged?: StagedLaunch,
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string }> {
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
if (!adapter) {
return {
@@ -472,9 +480,9 @@ export class SuccessorSessionLauncher {
// createSession already succeeded (adapter/socket/permission errors rethrow).
// A thrown exception must NOT reject createSuccessor before its kill/unwind
// runs (that would LEAK the unmanaged successor + surface an unstructured 500).
// Catch it here and return a STRUCTURED ok:false so the caller kills the
// just-created session (killBestEffort) and fails loudly at step=start_agent —
// exactly like a returned readiness failure.
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
// pane (unwind invariant above): the launched successor may still be running there.
let readiness: ReadinessResult;
try {
readiness = await this.waitForReady(adapter, binding);
@@ -483,6 +491,7 @@ export class SuccessorSessionLauncher {
ok: false,
code: "successor_readiness_failed",
message: `Successor readiness probe threw: ${err instanceof Error ? err.message : String(err)}`,
launched: true,
};
}
if (!readiness.ready) {
@@ -490,6 +499,7 @@ export class SuccessorSessionLauncher {
ok: false,
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
message: `Successor did not become a ready agent: ${readiness.reason ?? "readiness timeout"}`,
launched: true,
};
}
+3
View File
@@ -651,6 +651,9 @@ export interface AgentActivity {
sourceSequence?: number | null;
/** How often the emitter promises to report while idle; an idle report older than two of these no longer holds. */
livenessSeconds?: number | null;
/** #99: when a Claude usage limit (StopFailure/rate_limit) says it resets, read by the relay from Claude's own
* message. Only the instant is kept, never the message. */
resetsAt?: string | null;
rawEvent?: string | null;
rawSubtype?: string | null;
runtime?: string | null;
+12
View File
@@ -116,6 +116,16 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
const hookEvidenceSeq = new Map<string, number>();
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
const workingLiveness = new WorkingLivenessGate();
/** #99: the reset instant the relay read from a Claude usage limit, as a canonical ISO timestamp, or null. The store
* keeps it only on the typed StopFailure/rate_limit row; the provider lane decides whether it is still ahead. */
function limitResetsAt(body: Record<string, unknown>): string | null {
if (typeof body.resetsAt !== "string" || body.resetsAt.length > 40) return null;
const ms = Date.parse(body.resetsAt);
// The relay's eight-day horizon, enforced at ingress too: no sender can park a pool for longer than the relay could.
return Number.isFinite(ms) && ms <= Date.now() + LIMIT_RESET_MAX_MS ? new Date(ms).toISOString() : null;
}
const LIMIT_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
function sourceSequence(body: Record<string, unknown>): number | null {
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
}
@@ -727,6 +737,8 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
resetsAt: limitResetsAt(body),
sessionId: stringOrNull(body.sessionId),
});
if (!result.ok) {
+7 -33
View File
@@ -50,11 +50,10 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { randomUUID, randomInt } from "node:crypto";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/hosts-registry-writer.js";
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } from "../domain/hosts/hosts-registry-reader.js";
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
@@ -332,41 +331,16 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
// POINTERS by construction) + `selected` + a bounded coarse `status`.
// ---------------------------------------------------------------------
// Where ssh would connect for this host, from `ssh -G` (reads ssh config, no connection); null when
// ssh can't resolve it or a ProxyJump/ProxyCommand sits in between. Twin of the CLI's resolveSshEndpoint.
function resolveSshEndpoint(host: SshHostEntry, timeoutMs: number): Promise<{ hostname: string; port: number } | null> {
// `--` before the target, the same separator the CLI twin and the executor pass: a
// destination that begins with `-` is read as the host, not as an option.
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
async function probeHost(host: HostEntry, timeoutMs = 1500): Promise<"reachable" | "unreachable" | "unknown"> {
try {
let target: string;
let port: number;
if (host.transport === "ssh") {
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
} else {
const u = new URL(host.url);
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
target = u.hostname.replace(/^\[(.*)\]$/, "$1");
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
// Passive SSH rows have no authority to read SSH config or probe sshd.
return "unknown";
}
const u = new URL(host.url);
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
const target = u.hostname.replace(/^\[(.*)\]$/, "$1");
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
return new Promise((resolve) => {
const sock = connect({ host: target, port, timeout: timeoutMs });
sock.on("connect", () => { sock.destroy(); resolve("reachable"); });
@@ -16,6 +16,8 @@ const oldIdle = { state: "idle", reason: "stop_hook", evidenceSource: "runtime_h
const CNAME = "review-r1@sample-rig";
const cframe = readFileSync(new URL("./fixtures/input-box/claude-code-2.1.296/openrig-seat-accept-edits-idle.txt", import.meta.url), "utf8");
const codexFrame = readFileSync(new URL("./fixtures/input-box/codex-0.160.0/after-enter-15s.J.txt", import.meta.url), "utf8");
const codexCompleted = readFileSync(new URL("./fixtures/input-box/codex-0.167.0/completed.txt", import.meta.url), "utf8");
const codexWorking = readFileSync(new URL("./fixtures/input-box/codex-0.167.0/working.txt", import.meta.url), "utf8");
const busyFrame = cframe.replace(/^✻ Churned for 4s · done 5:24 PM$/m, "✻ Working… (3s · thinking)");
async function adapterFold(frame: string, opts: { age?: number; sourceName?: string; sourceRuntime?: "claude-code" | "codex"; runtime?: string; hook?: object | null; lastWriteAge?: number; motion?: boolean } = {}) {
const capturedAt = new Date(NOW.getTime() - (opts.age ?? 0));
@@ -57,6 +59,63 @@ describe("issue824 existing strict adapter idle frame (behavioral red-first)", (
const streaming = codexFrame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer");
expect((await adapterFold(streaming, { sourceRuntime: "codex" })).activity.state).toBe("running");
});
it.each(["0.167.0", "99.0.0"])("an unrecorded Codex %s frame stays unknown under redraw motion", async (version) => {
// Synthetic version edits of the recorded 0.160 fixture, not native newer-version captures.
const frame = codexFrame.replaceAll("0.160.0", version);
for (const pane of [frame, frame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer")]) {
expect(readSeatBeforePaste(pane, "codex")).toBe("unknown");
const result = await adapterFold(pane, { sourceRuntime: "codex", hook: null, lastWriteAge: 2000 });
expect(result.activity.state).toBe("unknown");
expect(result.activity.reason).not.toBe("window_activity_motion");
}
});
it("the captured Codex agents hint preserves completed idle despite redraws", async () => {
expect.soft(readSeatBeforePaste(codexCompleted, "codex")).toBe("idle");
for (const hook of [null, oldIdle]) {
const result = await adapterFold(codexCompleted, { sourceRuntime: "codex", hook });
expect.soft(result.activity.state).toBe("idle");
expect(result.activity.evidenceSource).toBe("pane_heuristic");
}
});
it("the captured working frame remains running without motion", async () => {
expect(readSeatBeforePaste(codexWorking, "codex")).toBe("not-idle");
expect((await adapterFold(codexWorking, { sourceRuntime: "codex", hook: null, motion: false })).activity.state).toBe("running");
});
it("the agents footer alone cannot prove idle while output is unfinished", async () => {
// Synthetic spinner-hidden stream, not a native streaming capture.
const stream = codexCompleted.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer");
expect(readSeatBeforePaste(stream, "codex")).toBe("unknown");
expect((await adapterFold(stream, { sourceRuntime: "codex", hook: null })).activity.state).toBe("running");
const badHint = codexCompleted.replace("← for agents", "unknown hint");
expect(readSeatBeforePaste(badHint, "codex")).toBe("unknown");
});
it("an unsupported Codex frame cannot turn an idle or stale hook into running, while positive work and attention remain", async () => {
const frame = codexFrame.replaceAll("0.160.0", "99.0.0");
for (const hook of [oldIdle, { ...oldIdle, state: "unknown", stale: true }]) {
expect((await adapterFold(frame, { sourceRuntime: "codex", hook })).activity.state).toBe("unknown");
}
for (const state of ["running", "needs_input"]) {
expect((await adapterFold(frame, { sourceRuntime: "codex", hook: { ...oldIdle, state } })).activity.state).toBe(state);
}
});
it.each([" >_ OpenAI Codex (v99.0.0)", "Update available! 99.0.0 -> 99.1.0"])("a fresh idle hook wins without motion on a version-named screen: %s", async (header) => {
const frame = `${header}\n${codexCompleted}`;
const hook = { ...oldIdle, sampledAt: NOW.toISOString() };
expect(readSeatBeforePaste(frame, "codex")).toBe("unknown");
const still = await adapterFold(frame, { sourceRuntime: "codex", hook, motion: false });
expect(still.observation).toMatchObject({ state: "unknown", reason: "unrecognized_client_version" });
expect(still.activity).toEqual(hook);
const moving = await adapterFold(frame, { sourceRuntime: "codex", hook, motion: true });
expect(moving.activity).toMatchObject({ state: "unknown", reason: "unrecognized_client_version" });
});
it.each([false, true])("positive pane work survives a version header with motion=%s", async (motion) => {
// Synthetic version header over the recorded working frame.
const frame = ` >_ OpenAI Codex (v99.0.0)\n${codexWorking}`;
expect(readSeatBeforePaste(frame, "codex")).toBe("not-idle");
const result = await adapterFold(frame, { sourceRuntime: "codex", hook: null, motion });
expect(result.observation).toMatchObject({ state: "agent_active", reason: "mid_work_pattern" });
expect(result.activity).toMatchObject({ state: "running", evidenceSource: "pane_heuristic", reason: "mid_work_pattern" });
});
it("a newer redraw after an old idle capture keeps D2 running", async () => {
expect((await adapterFold(cframe, { age: 2000 })).activity.state).toBe("running");
// The write and capture are in the same second: ordering is unknown,
@@ -1,5 +1,6 @@
// Reuses the released handover fixture. Process startup and pane bytes are modeled; no native Claude.
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { verifyClaudePaneProcess } from "../src/domain/native-process-lineage.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -385,6 +386,92 @@ describe("Claude fresh in-place handover readiness", () => {
expect(killSession).not.toHaveBeenCalled();
});
// #2985: a rebuild handover whose readiness window (30 s) closes before the new Claude is proven in the pane. The
// retiree is already ended and the pane respawned, so the failure leaves the successor that comes up a few seconds
// later running, with the binding on the previous conversation and its generation uncommitted. The result must say
// so instead of "retry", which would end that successor.
it("2985 rebuild: readiness times out at 30 s, the successor comes up after it and keeps running unadopted; the failure says so", async () => {
const startedAtMs = Date.parse("2026-11-10T16:21:00Z");
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(startedAtMs);
const { node, sessionId } = seedSeat({ runtime: "claude-code" });
const previousToken = "11111111-1111-4111-8111-111111111111";
sessionRegistry.updateResumeToken(sessionId!, "claude_id", previousToken, "hook");
const successorToken = "22222222-2222-4222-8222-222222222222";
const nativeAt = 33_000; // the issue: the new Claude was seen about 3 s after the 30 s window closed
let elapsed = 0, requested = false;
const running = () => requested && elapsed >= nativeAt;
const oldFrame = "Claude Code v2.1.322\n❯ predecessor finished turn\n ⏵⏵ accept edits on (shift+tab to cycle)";
// The new Claude's frame is drawn from the launch on, so readiness waits on the process proof
// ("Waiting to confirm the new Claude process in this pane."), which the 30 s window never sees.
const freshFrame = "───────── dev-impl@seat-rig ─\n❯ \n─────────\n ⏵⏵ accept edits on (shift+tab to cycle)";
getPaneCommand.mockImplementation(async () => running() ? "claude" : "zsh");
capturePaneScreen.mockImplementation(async () => requested ? freshFrame : `${oldFrame}\n❯ `);
const capturePaneContent = vi.fn(async () => requested ? freshFrame : `${oldFrame}\n❯ `);
const target = { ...tmux(), capturePaneContent, getPanePid: vi.fn(async () => 100),
sendShellCommand: async (session: string, text: string) => sendText(session, text) } as unknown as TmuxAdapter;
const startedAt = "Tue Nov 10 16:21:00 2026";
const listProcesses = vi.fn(() => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: running() ? 102 : 100, command: "-zsh", executableName: "zsh", startedAt },
...(running() ? [{ pid: 102, ppid: 100, pgid: 102, tpgid: 102,
command: `claude --session-id ${successorToken} --name dev-impl@seat-rig`, executableName: "claude", startedAt }] : []),
]);
sendText.mockImplementation(async (_session: string, text: string) => {
if (text.includes("claude ") && text.includes(`--session-id ${successorToken}`)) requested = true;
return { ok: true };
});
const fsError = () => { throw new Error("unexpected filesystem access"); };
const claude = new ClaudeCodeAdapter({ tmux: target, fsOps: { exists: () => false, readFile: fsError,
writeFile: fsError, mkdirp: fsError, copyFile: fsError }, listProcesses, sleep: async () => {}, sessionIdFactory: () => successorToken });
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
tmuxAdapter: target, now: () => new Date("2026-11-10T16:21:00Z"), newSuccessorId: () => "01SUCCID0",
runtimeAdapters: { "claude-code": claude as unknown as RuntimeAdapter },
contextUsageStore: { readSidecar } as never, resumeTokenCapturer: { captureCodexThreadId } as never,
occupantInvalidator: { invalidateRetiringOccupant }, activityOracle: { declareOccupantSwap },
predecessorRecapResolver: resolvePredecessorRecap as never, readinessTimeoutMs: 30_000,
sleep: async ms => { elapsed += ms; vi.setSystemTime(startedAtMs + elapsed); },
});
const sessionsBefore = db.prepare("SELECT session_name,status,resume_token FROM sessions ORDER BY id").all();
const result = await handover.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "context-wall" });
// The real case: the in-place respawn ran, readiness timed out at 30 s, and the handover failed at start_agent.
expect(result).toMatchObject({ ok: false, code: "successor_create_failed" });
if (result.ok) throw new Error("expected failure");
expect(result.message).toContain('Handover failed at step "start_agent"');
expect(result.message).toContain("readiness timeout after 30s: Waiting to confirm the new Claude process in this pane.");
expect(elapsed).toBeLessThan(nativeAt);
expect(respawnPane).toHaveBeenCalledTimes(1);
expect(killSession).not.toHaveBeenCalled();
// The binding is unchanged: the previous conversation is still the resume target, nothing completed, and the
// successor's reserved generation was never committed (its hooks are answered generation_uncommitted).
expect(db.prepare("SELECT session_name,status,resume_token FROM sessions ORDER BY id").all()).toEqual(sessionsBefore);
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxSession).toBe("dev-impl@seat-rig");
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'seat.handover_completed'").get()).toEqual({ n: 0 });
const generation = (respawnPane.mock.calls[0]?.[2] as { env?: Record<string, string> } | undefined)?.env?.OPENRIG_OCCUPANT_GENERATION;
expect(generation).toBeTruthy();
expect(sessionRegistry.isOccupantGenerationRegistered(node.id, generation!)).toBe(false);
// The fixture then models what the issue observed: a few seconds after the window closed, the successor is proven
// in the pane under its own session id, while the seat still holds the previous one (asserted above).
elapsed = nativeAt + 1000;
expect(await verifyClaudePaneProcess({ target: "%9", tmux: target, listProcesses, expectedToken: successorToken })).not.toBeNull();
expect(await verifyClaudePaneProcess({ target: "%9", tmux: target, listProcesses, expectedToken: previousToken })).toBeNull();
// The fix: the failure says a successor was launched and may be running unadopted, names its pane, and warns that
// a retry or a fresh launch with --stop ends it, instead of telling the operator to retry.
expect(result).toMatchObject({ replacementStarted: true, successorPane: "%9" });
expect(result.guidance).not.toMatch(/\bretry\b/i);
expect(result.guidance).toContain("binding is unchanged");
expect(result.guidance).toContain('a successor was launched in pane "%9"');
expect(result.guidance).toContain("OpenRig has not registered the successor as the seat's occupant");
expect(result.guidance).toContain("rig capture 'dev-impl@seat-rig'");
expect(result.guidance).toContain(`Another handover, or rig seat launch 'dev-impl@seat-rig' --fresh --stop --reason "<why>", ends that successor if it goes ahead`);
// A readiness failure doesn't prove the process exited: Claude can still be starting behind shell-looking output.
expect(result.guidance).not.toContain("no successor is running");
expect(result.guidance).toContain("Shell-looking output alone does not confirm that the successor has stopped; check the launch and process state before another handover.");
});
});
afterAll(() => {
@@ -23,11 +23,14 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
const require = createRequire(import.meta.url);
const relay = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
buildOpenRigPayload: (payload: Record<string, unknown>, env: Record<string, string>, now: () => Date) => Record<string, unknown>;
claudeLimitResetAt: (text: string, now: Date) => string | null;
};
const SESSION = "dev-worker@limit-fixture";
const NODE = "node-limit-fixture";
const GENERATION = "generation-current";
const T0 = "2025-01-01T12:00:00.000Z";
// The seat's own Claude session: its resume token, which a hook from the seat's Claude reports as session_id.
const OWN_CLAUDE_SESSION = "11111111-1111-4111-8111-111111111111";
const identity = { OPENRIG_SESSION_NAME: SESSION, OPENRIG_NODE_ID: NODE, OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: GENERATION };
describe("Claude provider interruptions (#99)", () => {
@@ -48,7 +51,8 @@ describe("Claude provider interruptions (#99)", () => {
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-limit', 'limit-fixture')").run();
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES ('pod-limit', 'rig-limit', 'dev', 'Dev')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, pod_id) VALUES (?, 'rig-limit', 'dev.worker', 'claude-code', 'pod-limit')").run(NODE);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status) VALUES ('session-limit', ?, ?, 'running', 'ready')").run(NODE, SESSION);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, resume_type, resume_token) VALUES ('session-limit', ?, ?, 'running', 'ready', 'claude_id', ?)")
.run(NODE, SESSION, OWN_CLAUDE_SESSION);
db.prepare("INSERT INTO bindings (id, node_id, tmux_session) VALUES ('binding-limit', ?, ?)").run(NODE, SESSION);
const eventBus = new EventBus(db);
liveGeneration = GENERATION;
@@ -125,6 +129,124 @@ describe("Claude provider interruptions (#99)", () => {
expect(store.getLatestForNode({ sessionName: SESSION })).toMatchObject({ state: "unknown", reason: "usage_limit" });
});
describe("the reset a Claude limit states (#99)", () => {
const WEEKLY = "You've hit your weekly limit · resets Jan 2, 4pm (America/Sao_Paulo)";
const reportStated = (text: string, field = "last_assistant_message", sessionId: string | null = OWN_CLAUDE_SESSION) =>
report({ hook_event_name: "StopFailure", error: "rate_limit", [field]: text, ...(sessionId ? { session_id: sessionId } : {}) });
const latestResetsAt = () => (store.getLatestForNode({ sessionName: SESSION }) as { resetsAt?: string | null } | null)?.resetsAt;
it.each([
// Real messages from a Claude Code 2.1.296 limit, and the older formats.
[WEEKLY, "2025-01-02T19:00:00.000Z"],
["5-hour limit reached ∙ resets 8am (UTC)", "2025-01-02T08:00:00.000Z"],
["5-hour limit reached ∙ resets 1:30pm (UTC)", "2025-01-01T13:30:00.000Z"],
["rate limit · resets in 2h 30m", "2025-01-01T14:30:00.000Z"],
["Claude AI usage limit reached|1735740000", "2025-01-01T14:00:00.000Z"],
])("reads %j", (text, expected) => {
expect(relay.claudeLimitResetAt(text, new Date(T0))).toBe(expected);
});
it.each([
"API Error: Rate limit reached",
"resets 3",
"resets 4pm (Not/AZone)",
"resets 4pm (Not/AZone", // a truncated zone is not the seat's local time
"resets 4pm ()",
"resets in 2 months", // not two minutes
"resets in 2h 30x",
"resets in 2h, 30x", // a separator doesn't end the fragment
"resets in 2h and 30x",
"resets in soon",
"resets Feb 30, 4pm (UTC)",
"resets Mar 1, 4pm (UTC)", // more than a week away: not a usage window
"Claude AI usage limit reached|1735730000", // already past
])("states no reset for %j", text => {
expect(relay.claudeLimitResetAt(text, new Date(T0))).toBeNull();
});
it.each([
// Close to the impossible date, where only a calendar check (not the distance cap) can reject it.
["2026-02-28T12:00:00.000Z", "resets Feb 30, 4pm (UTC)", null],
["2026-02-28T12:00:00.000Z", "resets Feb 29, 4pm (UTC)", null], // 2026 is not a leap year
["2026-02-28T12:00:00.000Z", "resets Mar 2, 4pm (UTC)", "2026-03-02T16:00:00.000Z"],
["2028-02-28T12:00:00.000Z", "resets Feb 29, 4pm (UTC)", "2028-02-29T16:00:00.000Z"],
["2026-04-29T12:00:00.000Z", "resets Apr 31, 4pm (UTC)", null],
// A wall time a DST change repeats or skips names no single instant: no reset rather than one a day late.
["2025-11-02T05:00:00.000Z", "resets 1:30am (America/New_York)", null], // 1:30 happens twice that night
["2025-11-02T05:00:00.000Z", "resets Nov 2, 1:30am (America/New_York)", null],
["2025-03-09T06:00:00.000Z", "resets 2:30am (America/New_York)", null], // 2:30 never happens that night
["2025-03-09T06:00:00.000Z", "resets 3:30am (America/New_York)", "2025-03-09T07:30:00.000Z"],
["2025-11-02T05:00:00.000Z", "resets 3am (America/New_York)", "2025-11-02T08:00:00.000Z"],
])("at %s reads %j as %j", (now, text, expected) => {
expect(relay.claudeLimitResetAt(text, new Date(now))).toBe(expected);
});
it("relays only the instant, never the message", () => {
const payload = reportStated(WEEKLY);
expect(payload).toMatchObject({ subtype: "rate_limit", resetsAt: "2025-01-02T19:00:00.000Z" });
expect(JSON.stringify(payload)).not.toContain("weekly");
expect(reportStated(WEEKLY, "error_details")).toMatchObject({ resetsAt: "2025-01-02T19:00:00.000Z" });
});
it.each([
{ hook_event_name: "StopFailure", error: "overloaded", last_assistant_message: WEEKLY },
{ hook_event_name: "Stop", last_assistant_message: WEEKLY },
{ hook_event_name: "Notification", notification_type: "rate_limit", message: WEEKLY },
])("reads no reset off another event: %j", payload => {
expect(report(payload)).not.toHaveProperty("resetsAt");
});
it("keeps a stated reset only from the seat's own Claude session, not a helper's", async () => {
// A helper Claude started from the seat's shell posts as the seat, but under its own session (maybe another login).
reportStated(WEEKLY, "last_assistant_message", "22222222-2222-4222-8222-222222222222");
expect(latestResetsAt()).toBeUndefined();
const model = await service.getReadModel();
expect(deriveUsageLimitPools({ signals: model.signals, bindings: model.bindings, now: new Date(), fallbackSeconds: 300 }))
.toEqual([expect.objectContaining({ poolKey: "claude:local", source: "config-fallback",
expiresAt: new Date(Date.parse(T0) + 300_000).toISOString() })]);
reportStated(WEEKLY, "last_assistant_message", null);
expect(latestResetsAt()).toBeUndefined();
reportStated(WEEKLY);
expect(latestResetsAt()).toBe("2025-01-02T19:00:00.000Z");
});
it("drops a stated reset past the eight-day cap from any sender", async () => {
const result = store.recordHookEvent({ runtime: "claude-code", sessionName: SESSION, hookEvent: "StopFailure", subtype: "rate_limit",
occurredAt: T0, generation: GENERATION, sessionId: OWN_CLAUDE_SESSION, resetsAt: "2025-01-10T12:00:00.000Z" });
expect(result.ok && result.activity.resetsAt).toBe("2025-01-10T12:00:00.000Z");
expect(await events()).toEqual([expect.not.objectContaining({ resetsAt: expect.anything() })]);
});
it("keeps a stated reset only on the typed limit row", () => {
const result = store.recordHookEvent({ runtime: "claude-code", sessionName: SESSION, hookEvent: "Stop",
resetsAt: "2025-01-02T19:00:00.000Z", generation: GENERATION, sessionId: OWN_CLAUDE_SESSION });
expect(result.ok && result.activity).not.toHaveProperty("resetsAt");
});
it("parks pending work until the stated reset instead of the bounded retry", async () => {
reportStated(WEEKLY);
const model = await service.getReadModel();
expect(model.signals.filter(signal => signal.sourceClass === "provider_event"))
.toEqual([expect.objectContaining({ provider: "claude", seatSession: SESSION, resetsAt: "2025-01-02T19:00:00.000Z" })]);
expect(deriveUsageLimitPools({ signals: model.signals, bindings: model.bindings, now: new Date(), fallbackSeconds: 300 }))
.toEqual([{ poolKey: "claude:local", provider: "claude", seatSessions: [SESSION], expiresAt: "2025-01-02T19:00:00.000Z", source: "provider-reset" }]);
queue.attachWatchdogJobsRepository(new WatchdogJobsRepository(db, () => new Date()));
queue.attachOutbox(new OutboxHandler(db));
const source = await queue.create({ sourceSession: "sender@limit-fixture", destinationSession: "relay@limit-fixture", body: "work", nudge: false });
const { created: baton } = await queue.handoff({ qitemId: source.qitemId, fromSession: "relay@limit-fixture", toSession: SESSION, nudge: false });
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = 'failed:provider limit' WHERE qitem_id = ?")
.run(new Date(Date.parse(T0) - 600_000).toISOString(), baton.qitemId);
const result = await runWakeLadderTick({ db, queueRepo: queue, attemptWake: vi.fn(async () => "typed"), resolveOrchestrator: () => null,
retryIntervalSeconds: 300, usageLimitJitterSeconds: 0, getProviderReadModel: () => service.getReadModel(), log: () => {} });
expect(result.actions).toContainEqual({ qitemId: baton.qitemId, action: "park-usage-limit" });
const blocker = queue.getById(baton.qitemId)!.blockedOn!;
expect(queue.getParkWakeStatus(blocker)).toMatchObject({ kind: "timer", live: true, expiresAt: "2025-01-02T19:00:00.000Z" });
expect(queue.transitionLog.listForQitem(blocker).some(row => row.transitionNote?.includes("cause=provider-reset"))).toBe(true);
});
});
it("reports a fresh same-occupant limit without inventing a Claude account, usage meter or reset", async () => {
reportLimit();
const model = await service.getReadModel();
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi } from "vitest";
import {
detectCodexLimitBanner,
@@ -497,6 +498,11 @@ describe("codex-limit-banner — store mapping and seat status", () => {
it.each([
{ name: "whitespace-only capture", frame: " \n\t\n " },
{ name: "unrecognized non-empty capture", frame: "unclassified output" },
{
name: "unsupported client version on a completed layout",
frame: " >_ OpenAI Codex (v99.0.0)\n" + readFileSync(
new URL("./fixtures/input-box/codex-0.167.0/completed.txt", import.meta.url), "utf8"),
},
{ name: "banner above draft composer", frame: `${BANNER}\n› Continue the task` },
{ name: "banner above indented empty composer", frame: `${BANNER}\n › Ask Codex to do anything` },
])("does not clear a current limit for $name", async ({ frame }) => {
@@ -533,6 +539,35 @@ describe("codex-limit-banner — store mapping and seat status", () => {
} finally { db.close(); }
});
it("positive work on a version-named frame clears a prior limit durably", async () => {
const db = createFullTestDb();
try {
const { sessionName } = seedCodexSeat(db);
let clock = NOW;
const deps = { db, eventBus: new EventBus(db), now: () => clock, resolveOccupantGeneration: () => "current-generation" };
const store = new AgentActivityStore(deps);
const capturePaneContent = vi.fn(async () => BANNER);
const structuralActivity = new SeatStructuralActivityService({ capturePaneContent }, () => clock);
structuralActivity.attachCodexLimitBanner((sessionName, banner, at) => {
recordCodexLimitBanner({ store, resolveGeneration: () => "current-generation", sessionName, banner, now: () => at });
});
await structuralActivity.pollSeat(sessionName, null, "codex");
expect(store.getLatestForNode({ sessionName })).toMatchObject({ reason: "usage_limit" });
capturePaneContent.mockResolvedValue(" >_ OpenAI Codex (v99.0.0)\n" + readFileSync(
new URL("./fixtures/input-box/codex-0.167.0/working.txt", import.meta.url), "utf8"));
for (let poll = 1; poll <= 2; poll++) {
clock = new Date(NOW.getTime() + poll * 1000);
expect((await structuralActivity.pollSeat(sessionName, null, "codex"))?.state).toBe("agent_active");
expect(new AgentActivityStore(deps).getLatestForNode({ sessionName })).toMatchObject({ reason: "usage_limit_cleared" });
const out = await attachAgentActivity(entries(sessionName), {
tmuxAdapter: { capturePaneContent } as never, activityStore: store, structuralActivity, now: clock,
});
expect(out[0]!.agentActivity).toMatchObject({ state: "running", reason: "mid_work_pattern" });
}
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get()).toEqual({ n: 2 });
} finally { db.close(); }
});
it.each([
{ name: "submitted prompt followed by actual work", draft: "› Continue the task\n◦ Working (2s • esc to interrupt)", limit: false },
...["Explain this example", ""].map(first => ({ name: `unsent nested draft: ${first || "blank first line"}`,
+4
View File
@@ -5,6 +5,10 @@ Real frames of Claude Code 2.1.296 (`claude-code-2.1.296/`) and Codex 0.160.0 (`
(`claude-code-2.1.296/openrig-seat-*`, see below). `texts/` holds the texts that were sent.
`input-box-reading.test.ts` and `session-transport-input-box.test.ts` read them.
`codex-0.167.0/` adds redacted completed and working tails from a managed terminal.
Its README records their provenance and the native streaming evidence still missing.
The activity tests use these tails; they do not establish a staged-input/send sequence.
## Versions and their limits
Every frame here is of one client version, named by its folder; the idle allow-list and the box readers were built
@@ -0,0 +1,18 @@
# Codex 0.167.0 activity frames
Captured from an OpenRig-managed Codex terminal on Linux, at 120 columns. The
runtime reported `codex-cli 0.167.0` separately; neither captured tail displays
the client version. These frames establish the drawn layout, not a general
allow-list for every screen of that version.
- `completed.txt`: another agent captured the terminal at the completed prompt
with `rig capture`, before waking it, on 2026-11-11. The transcript was replaced
by a generic response, and the local path and task title were redacted. The
completion, blank rows, composer, model and hint/warning rows are retained.
- `working.txt`: captured during a tool turn with `rig capture`. Command text,
output, local path and task title were redacted. The working row, tip, blank
rows, composer and footer are retained.
No spinner-hidden streaming frame was captured. Tests that replace the completion
row with unfinished response text are synthetic controls, not native captures.
No fresh-session header, staged input or submit sequence is represented here.
@@ -0,0 +1,10 @@
• Finished the task.
Worked for 50s • 12:32 AM
› Ask Codex to do anything
GPT-6.1-Sol high · ~/example/work · Finish task
← for agents · ? for shortcuts ⚠ 1 warning · f2 to view
@@ -0,0 +1,12 @@
• Ran a command
└ Command output
• Working (4m 34s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6.1-Sol high · ~/example/work · Finish task
← for agents · ? for shortcuts ⚠ 1 warning · f2 to view
@@ -151,26 +151,9 @@ describe("POST /api/hosts/add — the narrow named add seam", () => {
}
});
it("GET /api/hosts dials where ssh resolves an ssh host (alias and port), and reads unknown behind a ProxyJump", async () => {
const server = net.createServer((sock) => sock.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const bin = mkdtempSync(join(tmpdir(), "hosts-ssh-bin-"));
const savedPath = process.env["PATH"];
try {
const { port } = server.address() as net.AddressInfo;
// A stand-in `ssh -G`: the alias resolves to this listener; the other host sits behind a jump.
writeFileSync(join(bin, "ssh"), `#!/bin/sh\ncase "$*" in\n *devbox*) printf 'hostname 127.0.0.1\\nport ${port}\\n' ;;\n *) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;\nesac\n`, { mode: 0o755 });
process.env["PATH"] = `${bin}:${savedPath}`;
writeFileSync(join(home, "hosts.yaml"), "hosts:\n - id: devbox\n transport: ssh\n target: devbox\n - id: jumpy\n transport: ssh\n target: jumpy\n");
const res = await app.request("/api/hosts", { headers: auth });
const body = (await res.json()) as { hosts: Array<{ id: string; status: string }> };
expect(body.hosts.map((h) => [h.id, h.status])).toEqual([["devbox", "reachable"], ["jumpy", "unknown"]]);
} finally {
process.env["PATH"] = savedPath;
rmSync(bin, { recursive: true, force: true });
await new Promise((resolve) => server.close(resolve));
}
});
// hosts-list-ssh-probe-inert.test.ts pins that passive SSH rows start no
// process and open no socket. Keep excluding a loopback characterization that
// marks a directly resolved SSH host as reachable.
it("writes a valid entry through the writer twin and the CLI-visible registry file", async () => {
const res = await app.request("/api/hosts/add", {
@@ -309,17 +292,38 @@ describe("POST /api/hosts/add — the narrow named add seam", () => {
});
it("POST /api/hosts/remove deletes only the registry entry, returns previous entry", async () => {
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-b", transport: "http", url: "http://vm-b" }) });
const probeServer = net.createServer((socket) => socket.destroy());
try {
const port = await new Promise<number>((resolve, reject) => {
probeServer.once("error", reject);
probeServer.listen(0, "127.0.0.1", () => {
const address = probeServer.address();
if (!address || typeof address === "string") {
reject(new Error("loopback probe fixture did not expose an address"));
return;
}
resolve(address.port);
});
});
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
expect(res.status).toBe(200);
const body = await res.json() as { entry: { id: string } };
expect(body.entry).toMatchObject({ id: "vm-a", transport: "ssh" });
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-b", transport: "http", url: `http://127.0.0.1:${port}` }) });
const loaded = await app.request("/api/hosts", { headers: auth });
const body2 = await loaded.json() as { hosts: Array<{ id: string }> };
expect(body2.hosts.map(h => h.id)).toEqual(["vm-b"]);
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
expect(res.status).toBe(200);
const body = await res.json() as { entry: { id: string } };
expect(body.entry).toMatchObject({ id: "vm-a", transport: "ssh" });
const loaded = await app.request("/api/hosts", { headers: auth });
const body2 = await loaded.json() as { hosts: Array<{ id: string }> };
expect(body2.hosts.map(h => h.id)).toEqual(["vm-b"]);
} finally {
if (probeServer.listening) {
await new Promise<void>((resolve, reject) => {
probeServer.close((error) => error ? reject(error) : resolve());
});
}
}
});
it("POST /api/hosts/remove refuses an unknown id", async () => {
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { hostsRoutes } from "../src/routes/hosts.js";
const { execFileMock, connectMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
connectMock: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, execFile: execFileMock };
});
vi.mock("node:net", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:net")>();
return { ...actual, connect: connectMock };
});
type SocketEvent = "connect" | "timeout" | "error";
interface SocketTrace {
events: string[];
writes: number;
ends: number;
destroys: number;
}
interface ProbeTrace {
execFileCalls: Array<{ command: string; args: string[]; timeout: number | undefined }>;
connects: Array<{ host: string | undefined; port: number | undefined; timeout: number | undefined }>;
sockets: SocketTrace[];
}
class FakeSocket extends EventEmitter {
constructor(private readonly trace: SocketTrace) {
super();
}
destroy(): this {
this.trace.events.push("destroy");
this.trace.destroys += 1;
return this;
}
write(..._args: unknown[]): boolean {
this.trace.writes += 1;
return true;
}
end(..._args: unknown[]): this {
this.trace.ends += 1;
return this;
}
}
let home: string;
let savedHome: string | undefined;
let trace: ProbeTrace;
let socketEvent: SocketEvent;
function app(): Hono {
const result = new Hono();
result.route("/api/hosts", hostsRoutes());
return result;
}
function writeRegistry(hosts: unknown[]): void {
writeFileSync(join(home, "hosts.yaml"), JSON.stringify({ hosts }));
}
function singleStatus(body: { hosts: Array<{ status: string }> }): string | undefined {
return body.hosts[0]?.status;
}
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "ssh-list-inert-"));
savedHome = process.env["OPENRIG_HOME"];
process.env["OPENRIG_HOME"] = home;
trace = { execFileCalls: [], connects: [], sockets: [] };
socketEvent = "connect";
execFileMock.mockReset();
execFileMock.mockImplementation((
command: string,
args: string[],
options: { timeout?: number },
callback: (error: Error | null, stdout: string) => void,
) => {
trace.execFileCalls.push({ command, args: [...args], timeout: options.timeout });
queueMicrotask(() => callback(null, ""));
});
connectMock.mockReset();
connectMock.mockImplementation((options: { host?: string; port?: number; timeout?: number }) => {
trace.connects.push({ host: options.host, port: options.port, timeout: options.timeout });
const lifecycle: SocketTrace = { events: [], writes: 0, ends: 0, destroys: 0 };
trace.sockets.push(lifecycle);
const socket = new FakeSocket(lifecycle);
queueMicrotask(() => {
lifecycle.events.push(socketEvent);
socket.emit(socketEvent, ...(socketEvent === "error" ? [new Error("inert socket error")] : []));
});
return socket;
});
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = savedHome;
});
describe("GET /api/hosts status probes at inert external boundaries", () => {
it("reports SSH entries unknown without invoking a process or opening a TCP socket", async () => {
writeRegistry([
{ id: "devbox", transport: "ssh", target: "devbox", user: "ops" },
{ id: "another", transport: "ssh", target: "host.example" },
]);
const response = await app().request("/api/hosts");
const body = await response.json() as { hosts: Array<{ status: string }> };
const observed = {
httpStatus: response.status,
statuses: body.hosts.map((host) => host.status),
execFileCalls: trace.execFileCalls,
connects: trace.connects,
sockets: trace.sockets,
};
expect(observed).toEqual({
httpStatus: 200,
statuses: ["unknown", "unknown"],
execFileCalls: [],
connects: [],
sockets: [],
});
});
it.each([
{ label: "HTTP default port", url: "http://example.test", host: "example.test", port: 80 },
{ label: "HTTP explicit port", url: "http://example.test:7433", host: "example.test", port: 7433 },
{ label: "HTTPS default port", url: "https://example.test", host: "example.test", port: 443 },
{ label: "bracketed IPv6 HTTPS", url: "https://[2001:db8::7]:9443", host: "2001:db8::7", port: 9443 },
])("preserves $label TCP status behavior using only the fake socket", async ({ url, host, port }) => {
writeRegistry([{ id: "web", transport: "http", url }]);
const response = await app().request("/api/hosts");
const body = await response.json() as { hosts: Array<{ status: string }> };
expect({
httpStatus: response.status,
status: singleStatus(body),
execFileCalls: trace.execFileCalls,
connects: trace.connects,
sockets: trace.sockets,
}).toEqual({
httpStatus: 200,
status: "reachable",
execFileCalls: [],
connects: [{ host, port, timeout: 1500 }],
sockets: [{ events: ["connect", "destroy"], writes: 0, ends: 0, destroys: 1 }],
});
});
it.each(["timeout", "error"] as const)("keeps HTTP %s results unreachable and destroys the fake socket", async (event) => {
socketEvent = event;
writeRegistry([{ id: "web", transport: "http", url: "http://example.test:7433" }]);
const response = await app().request("/api/hosts");
const body = await response.json() as { hosts: Array<{ status: string }> };
expect({ status: singleStatus(body), sockets: trace.sockets }).toEqual({
status: "unreachable",
sockets: [{ events: [event, "destroy"], writes: 0, ends: 0, destroys: 1 }],
});
});
});
@@ -90,7 +90,8 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
// Hook + skills wiring
expect(manifest["skills"]).toBe("./skills");
expect(manifest["hooks"]).toBe("./hooks/claude.json");
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
expect(manifest["license"]).toBeDefined();
});
@@ -0,0 +1,424 @@
// Hermetic tests for the pane compositor: the bottom live region that keeps the
// operator's draft out of the transcript, pins the latest diagnostic as a copy
// (never the only one), flushes a held streamed tail when input goes away, and
// follows terminal resizes. No live pi.
import { describe, it, expect, vi } from "vitest";
import { PassThrough } from "node:stream";
import {
composeLayout, createPaneCompositor, createRunnerInput, RunnerCore, type RunnerIo,
} from "../src/adapters/pi-runner.js";
function paneOut(columns: number | null = 40) {
let buf = "";
const output = Object.assign(new PassThrough(), typeof columns === "number" ? { isTTY: true, columns } : {});
output.on("data", (chunk: Buffer) => { buf += chunk.toString(); });
return { output: output as unknown as NodeJS.WriteStream, text: () => buf };
}
// ── One physical layout model ────────────────────────────────────────────────
describe("compose layout", () => {
it("counts plain text as one row and places the caret at its end", () => {
expect(composeLayout("ab", 40)).toEqual({ rows: 1, caretRow: 0, caretColumn: 2 });
});
it("wraps wide characters whole: a CJK glyph that does not fit starts the next row", () => {
// Width 4: "abc" fills columns 0-2, 漢 needs two cells and only one is left, so it
// wraps — three terminal rows would be wrong; the terminal uses two.
expect(composeLayout("abc漢", 4)).toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
});
it("lays a 90-glyph CJK draft at width 40 into five rows", () => {
expect(composeLayout("寛".repeat(90), 40).rows).toBe(5);
});
it("measures a ZWJ family emoji as one two-cell grapheme at the wrap column", () => {
// Width 4: "abc" fills columns 0-2; the family emoji is one grapheme taking two
// cells, does not fit the last column, and wraps whole — two rows, not three.
expect(composeLayout("abc\u{1F468}\u200D\u{1F469}\u200D\u{1F467}", 4))
.toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
});
it("advances a tab to the next tab stop, not by zero cells", () => {
expect(composeLayout("ab\tc", 40)).toEqual({ rows: 1, caretRow: 0, caretColumn: 9 });
});
it("starts a new row at an explicit newline and puts the caret on that row", () => {
expect(composeLayout("line1\nab", 40, 6)).toEqual({ rows: 2, caretRow: 1, caretColumn: 0 });
expect(composeLayout("line1\nab", 40, 8)).toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
});
});
/** A terminal double that models tmux's reflow: soft-wrapped rows join on widening,
* paragraphs split on narrowing, and the cursor stays on its character. An ASCII-width
* model, which is all these tests need. */
export class ReflowScreen {
private rows: { text: string; soft: boolean }[] = [{ text: "", soft: false }];
private row = 0;
private col = 0;
columns: number;
private handlers = new Set<() => void>();
private view: object | null = null;
constructor(width: number) { this.columns = width; }
terminal(): NodeJS.WriteStream {
if (this.view) return this.view as unknown as NodeJS.WriteStream;
const self = this;
this.view = {
isTTY: true,
write: (data: string | Buffer) => self.write(typeof data === "string" ? data : data.toString("utf8")),
on: (_event: string, handler: () => void) => { self.handlers.add(handler); },
get columns() { return self.columns; },
};
return this.view as unknown as NodeJS.WriteStream;
}
private ensure(row: number, soft: boolean) {
while (this.rows.length <= row) this.rows.push({ text: "", soft: false });
if (row > 0) this.rows[row]!.soft = soft;
}
private place(ch: string) {
if (this.col >= this.columns) { this.row += 1; this.col = 0; }
// A row reached by wrapping continues the paragraph above; one reached by a
// newline was created hard by the \n handler.
while (this.rows.length <= this.row) this.rows.push({ text: "", soft: this.row > 0 });
const line = this.rows[this.row]!;
this.rows[this.row] = { ...line, text: line.text.padEnd(this.col, " ").slice(0, this.col) + ch + line.text.slice(this.col + 1) };
this.col += 1;
}
write(text: string) {
for (let i = 0; i < text.length; i += 1) {
const ch = text[i]!;
if (ch === "\u001b") {
const match = /^\u001b\[\??(\d*)([A-Za-z])/.exec(text.slice(i));
if (match) {
const count = match[1] ? Number(match[1]) : 1;
if (match[2] === "A") this.row = Math.max(0, this.row - count);
else if (match[2] === "B") this.row += count;
else if (match[2] === "C") this.col += count;
else if (match[2] === "K" && count === 2) this.rows[this.row] = { ...this.rows[this.row]!, text: "" };
i += match[0].length - 1;
} else i += 1;
continue;
}
if (ch === "\r") { this.col = 0; continue; }
if (ch === "\n") { this.row += 1; this.ensure(this.row, false); this.col = 0; continue; }
this.place(ch);
}
}
/** Reflow to a new width the way tmux does, keeping the cursor on its character,
* then tell the compositor the terminal resized. */
reflowTo(width: number) {
let start = this.row;
while (start > 0 && this.rows[start]?.soft) start -= 1;
let paragraph = 0;
for (let i = 0; i < start; i += 1) if (!this.rows[i]?.soft) paragraph += 1;
let offset = this.col;
for (let i = start; i < this.row; i += 1) offset += this.rows[i]?.text.length ?? 0;
const paragraphs: string[] = [];
let joined: string | null = null;
for (const line of this.rows) {
if (line.soft && joined !== null) joined += line.text;
else { if (joined !== null) paragraphs.push(joined); joined = line.text; }
}
if (joined !== null) paragraphs.push(joined);
const rendered: { text: string; soft: boolean }[] = [];
const startRow: number[] = [];
paragraphs.forEach(text => {
startRow.push(rendered.length);
if (text === "") { rendered.push({ text: "", soft: false }); return; }
for (let i = 0; i < text.length; i += width) rendered.push({ text: text.slice(i, i + width), soft: i > 0 });
});
this.rows = rendered;
const text = paragraphs[paragraph] ?? "";
const clamped = Math.min(offset, text.length);
this.row = (startRow[paragraph] ?? 0) + Math.floor(clamped / width);
this.col = clamped % width;
this.columns = width;
for (const handler of this.handlers) handler();
}
lines(): string[] { return this.rows.map(line => line.text.replace(/ +$/, "")); }
}
// ── The compositor's transcript contract ─────────────────────────────────────
describe("pane compositor", () => {
it("keeps draftless streaming byte-identical to the bare renderer", () => {
const { output, text } = paneOut();
const pane = createPaneCompositor(output);
pane.transcript("The quick ");
pane.transcript("brown fox\n");
pane.line("jumps over");
expect(text()).toBe("The quick brown fox\njumps over\n");
});
it("writes complete streamed lines above a draft exactly once and holds the unfinished tail", () => {
const { output, text } = paneOut();
const pane = createPaneCompositor(output);
pane.attach(() => "my draft", () => output.write("my draft"), () => 0);
pane.transcript("streamed words here\npartial");
const raw = text();
expect(raw.match(/streamed words here/g)).toHaveLength(1);
expect(raw).not.toContain("partial");
pane.transcript(" tail\n");
expect(text().match(/partial tail\n/g)).toHaveLength(1);
});
it("pins diagnostics to a status line, as a copy, and drops the pin on clearStatus", () => {
const { output, text } = paneOut(60);
const pane = createPaneCompositor(output);
pane.attach(() => "draft", () => output.write("draft"), () => 0);
pane.line("transcript line");
pane.line("[pi-runner] ERROR quota reached, with long trailing advice for the operator");
pane.diagnostic("[pi-runner] ERROR quota reached, with long trailing advice for the operator");
expect(text()).toContain("[pi-runner] ERROR quota reached");
pane.diagnostic("[pi-runner] second notice");
expect(text().match(/\[pi-runner\] second notice/g)).toHaveLength(1);
pane.detach();
pane.clearStatus();
const before = text().length;
pane.transcript("plain tail\n");
expect(text().slice(before)).toBe("plain tail\n");
});
it("prints nothing itself when the width is unknown: the caller's transcript copy stands alone", () => {
const { output, text } = paneOut(null);
const pane = createPaneCompositor(output);
pane.attach(() => "draft", () => output.write("draft"), () => 0);
pane.line("[pi-runner] ERROR no width to pin into");
pane.diagnostic("[pi-runner] ERROR no width to pin into");
expect(text()).toBe("[pi-runner] ERROR no width to pin into\n");
});
it("keeps READY and EXITED markers in the transcript, verbatim", () => {
const { output, text } = paneOut();
const pane = createPaneCompositor(output);
pane.line("[pi-runner] READY session=/state/s.jsonl");
pane.line("[pi-runner] EXITED pi exited (code 0)");
expect(text()).toBe("[pi-runner] READY session=/state/s.jsonl\n[pi-runner] EXITED pi exited (code 0)\n");
});
it("narrows without leaving a stale wrapped status behind, and keeps the transcript above", () => {
const term = new ReflowScreen(120);
const pane = createPaneCompositor(term.terminal());
pane.line("SENTINEL-ABOVE");
pane.diagnostic("S".repeat(110)); // a 110-cell status: one row at 120, two at 80
term.reflowTo(80); // tmux reflows; the cursor keeps its character
const lines = term.lines();
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
// The stale first-80-cells copy of the wrapped status must be gone.
expect(lines.some(line => line === "S".repeat(80))).toBe(false);
// The repainted pin is clipped to the new width instead.
expect(lines.some(line => line === "S".repeat(79) + "\u2026")).toBe(true);
});
it("keeps the sentinel when a long pinned status is resized twice", () => {
const term = new ReflowScreen(120);
const pane = createPaneCompositor(term.terminal());
pane.line("SENTINEL-ABOVE");
pane.diagnostic("S".repeat(110));
term.reflowTo(80); // the pin is repainted clipped to 80 cells
term.reflowTo(40); // sized from what is on screen (80 cells = 2 rows), not the 110-cell text
const lines = term.lines();
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
expect(lines.some(line => line === "S".repeat(40))).toBe(false);
});
it("widens a wrapped draft without erasing the transcript line above it", () => {
const term = new ReflowScreen(40);
const input = Object.assign(new PassThrough(), { isTTY: true, isRaw: false,
setRawMode: vi.fn(function (this: { isRaw: boolean }, value: boolean) { this.isRaw = value; }) });
const output = term.terminal();
const pane = createPaneCompositor(output);
const editor = createRunnerInput(input as unknown as NodeJS.ReadStream, output, () => {}, pane);
try {
pane.line("SENTINEL-ABOVE");
input.write("d".repeat(90)); // three draft rows at width 40
input.write("\u001b[D".repeat(45)); // caret mid-draft
term.reflowTo(120); // the draft joins into one row; the caret keeps its character
const lines = term.lines();
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
// One draft row repainted at the new width — not three, and not the sentinel.
expect(lines.filter(line => line.replace(/ /g, "").startsWith("dddd")).length).toBe(1);
} finally { editor.close(); }
});
});
// ── The compose line through the real line editor ────────────────────────────
describe("compose line through the real editor", () => {
function terminal(columns = 80) {
const input = Object.assign(new PassThrough(), { isTTY: true, isRaw: false,
setRawMode: vi.fn(function (this: { isRaw: boolean }, value: boolean) { this.isRaw = value; }) });
const output = Object.assign(new PassThrough(), { isTTY: true, columns });
const blocks: string[] = [];
let screen = "";
output.on("data", chunk => { screen += chunk.toString(); });
const pane = createPaneCompositor(output as unknown as NodeJS.WriteStream);
const editor = createRunnerInput(input as unknown as NodeJS.ReadStream,
output as unknown as NodeJS.WriteStream, s => { blocks.push(s); }, pane);
return { input, output, editor, pane, blocks, screen: () => screen };
}
it("submits the full draft once, on readline's decoded line, with no partial copy left", () => {
const t = terminal();
try {
t.input.write("hello compose");
t.input.write("\r");
expect(t.blocks).toEqual(["hello compose"]);
const screen = t.screen();
expect(screen).toContain("hello compose");
expect(screen).toContain("\u001b[2K");
expect(screen).not.toContain("hello compose\n");
} finally { t.editor.close(); }
});
it("streams around an active draft without fusing", () => {
const t = terminal();
try {
t.input.write("draft line");
t.pane.line("streamed model line");
const screen = t.screen();
expect(screen.match(/streamed model line\n/g)).toHaveLength(1);
expect(screen).not.toContain("model linedraft");
t.input.write("\r");
expect(t.blocks).toEqual(["draft line"]);
} finally { t.editor.close(); }
});
it("measures CJK drafts in terminal cells, not UTF-16 characters", () => {
const t = terminal(40);
try {
t.input.write("寛".repeat(90));
t.input.write("x");
expect(t.screen().match(/\u001b\[2K/g)).toHaveLength(5);
} finally { t.editor.close(); }
});
it("leaves meta-return to readline: no submit, together or split", () => {
const together = terminal();
try {
together.input.write("abc");
together.input.write("\u001b\r");
expect(together.blocks).toEqual([]);
} finally { together.editor.close(); }
const split = terminal();
try {
split.input.write("abc");
split.input.write("\u001b");
split.input.write("\r");
expect(split.blocks).toEqual([]);
} finally { split.editor.close(); }
});
it("clears the edit history with the submit: undo does not resurrect the draft", () => {
const t = terminal();
try {
t.input.write("abc\r");
expect(t.blocks).toEqual(["abc"]);
t.input.write("\u001f"); // Ctrl-_ on the now-empty line
t.input.write("\r");
expect(t.blocks).toEqual(["abc"]);
} finally { t.editor.close(); }
});
it("flushes a held streamed tail verbatim when the draft is killed (Ctrl-U)", () => {
const t = terminal();
try {
t.input.write("abc");
t.pane.transcript("an unfinished streamed tail");
expect(t.screen()).not.toContain("an unfinished streamed tail");
t.input.write("\u0015"); // kill line: the draft goes away, the tail is written as-is
expect(t.screen()).toContain("an unfinished streamed tail");
} finally { t.editor.close(); }
});
it("joins a flushed tail with the next delta instead of splitting mid-word", () => {
const t = terminal();
try {
t.input.write("abc");
t.pane.transcript("Hello wor");
t.input.write("\u0015");
t.pane.transcript("ld\n");
expect(t.screen()).toContain("Hello world");
} finally { t.editor.close(); }
});
it("keeps coalesced edit keys and text ordered with the compositor attached", () => {
const t = terminal();
try {
t.input.write("ac\u001b[Db\u00e9\u{1F98A}\r");
expect(t.blocks).toEqual(["ab\u00e9\u{1F98A}c"]);
} finally { t.editor.close(); }
});
it("keeps the flushed tail line open so new typing starts on its own line", () => {
const t = terminal();
try {
t.input.write("abc");
t.pane.transcript("Hello wor");
t.input.write("\u0015"); // flush "Hello wor" verbatim, no newline
t.input.write("x"); // new draft, no newline
t.input.write("y"); // the next keystroke must not erase the tail line
const screen = t.screen();
expect(screen).toContain("Hello wor\n");
expect(screen).not.toContain("Hello worx");
} finally { t.editor.close(); }
});
it("flushes a held streamed tail at EOF", () => {
const t = terminal();
t.input.write("x");
t.pane.transcript("tail at exit");
t.editor.close();
expect(t.screen()).toContain("tail at exit\n");
});
});
// ── Diagnostics reach the transcript, not only the status pin ────────────────
describe("runner diagnostics", () => {
function coreWith() {
const lines: string[] = [];
const pins: string[] = [];
const io: RunnerIo = {
sendRpc: () => {},
mirrorLine: (line) => lines.push(line),
mirrorAppend: () => {},
postActivity: () => {},
writeSidecar: () => {},
now: () => "2026-11-10T00:00:00Z",
diagnostic: (text) => pins.push(text),
clearStatus: () => pins.push(""),
};
const core = new RunnerCore(io, { sessionName: "seat-a@some-rig", nodeId: "node-1", launchId: "launch-1" });
core.start();
return { core, lines, pins };
}
it("writes every diagnostic to the transcript and only pins the latest", () => {
const { core, lines, pins } = coreWith();
core.handlePiLine(JSON.stringify({
type: "response", id: "pi-runner-get-state", success: false,
error: "boom: the full provider setup advice follows at length",
}));
expect(lines.some(line => line.startsWith("[pi-runner] ERROR"))).toBe(true);
expect(pins.at(-1)).toContain("the full provider setup advice follows at length");
core.handlePiLine(JSON.stringify({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "second failure with different advice" } }));
// The first failure's full text is still in the transcript history.
expect(lines.some(line => line.includes("the full provider setup advice follows at length"))).toBe(true);
expect(lines.some(line => line.includes("second failure with different advice"))).toBe(true);
});
it("keeps the ERROR marker inside the pane's last 40 lines after later traffic", () => {
const { core, lines } = coreWith();
core.handlePiLine(JSON.stringify({
type: "response", id: "pi-runner-get-state", success: false, error: "quota",
}));
for (let i = 0; i < 5; i += 1) core.handlePiLine(JSON.stringify({ type: "message_update", delta: "later traffic " }));
const tail = lines.slice(-40);
expect(tail.some(line => line.includes("[pi-runner] ERROR"))).toBe(true);
});
});
@@ -719,7 +719,7 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
const askId = String((await f.submit()).body.askId);
// The result says what became of the answer (the TUI prints it), never that the request closed.
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run.` } } });
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.` } } });
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
@@ -1023,3 +1023,58 @@ describe("a direct answer with no notify envelope (the Slack fallback)", () => {
]);
});
});
// A person answering from the TUI answers as the host's human seat, and a person-addressed row to a human seat must
// carry an evidence ref (convention C3). Without one, both notes to the person were refused and never written.
describe("the person's note about a runner answer is written under the queue's own conventions", () => {
const HUMAN_SEAT = "human@host";
const answerAsHost = (f: ReturnType<typeof fixture>, askId: string, choice: { optionId?: string; decision?: string }) =>
f.contract.act({ verb: "answer", qitemId: askId, actorSession: HUMAN_SEAT, authority: "primary-user",
...(choice.optionId ? { questionId: "permission", optionId: choice.optionId } : {}), ...(choice.decision ? { decision: choice.decision } : {}) } as never);
const notesTo = (f: ReturnType<typeof fixture>) => f.queueRepo.list({ limit: 100 })
.filter((row) => row.destinationSession === HUMAN_SEAT && row.humanIntent === "update");
it("with no agent that can decide, the kept-answer note is written once, linked to the request, with the direct route", async () => {
const f = fixture({ decider: null });
const askId = String((await f.submit()).body.askId);
await answerAsHost(f, askId, { optionId: "allow" });
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
const [note] = notesTo(f);
expect(note).toMatchObject({ qitemId: `qitem-runner-permission-kept-${askId.replace(/^qitem-runner-permission-/, "")}`,
evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
expect(note!.body).toContain("The tool hasn't run, and nothing was permitted or refused.");
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
// The answer itself is kept on the request, and the request still waits.
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending", humanAnswers: { permission: "allow" } });
// Answering again keeps the new answer and writes no second note.
await answerAsHost(f, askId, { decision: "only the test file" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(notesTo(f)).toHaveLength(1);
expect(f.permits()).toHaveLength(0);
});
it("when the answer goes to an agent, the who-decides note is written once, linked to the request, with the agent's state", async () => {
const f = fixture({ receiver: "dev-other@perm-rig" });
const askId = String((await f.submit()).body.askId);
await answerAsHost(f, askId, { decision: "only the test file" });
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
const [note] = notesTo(f);
expect(note).toMatchObject({ evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
expect(note!.body).toContain(`went to dev-other@perm-rig, which decides it while ${SEAT} waits. Nothing runs until it decides.`);
expect(note!.body).toContain("Receiving agent: dev-other@perm-rig, chosen in agents.operator_session; its session is recorded as running.");
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: "dev-other@perm-rig" });
});
it("a runner already waiting when the answer is handed to the agent reads the agent's decision at once, not at its read's end", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const started = Date.now();
const waiting = f.read(askId, 5);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(await f.answer(askId, { optionId: "allow" })).toMatchObject({ status: 200 });
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { state: "answered" } });
expect(await waiting).toMatchObject({ body: { state: "answered", optionId: "allow" } });
expect(Date.now() - started).toBeLessThan(2500);
});
});
@@ -499,7 +499,8 @@ describe("SeatLifecycleService.launchFresh", () => {
code: "claimed_session",
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
};
const before = db.serialize();
@@ -686,6 +687,11 @@ describe("SeatLifecycleService.launchFresh", () => {
alive.add(seat.sessionName);
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
expect(result).toMatchObject({
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
+ "then launch again. Closing only its viewing window doesn't end the session.",
});
expect(killed).toEqual([]);
expect(alive.has(seat.sessionName)).toBe(true);
});
@@ -446,6 +446,11 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxSession).toBe("dev-impl@seat-rig");
expect(nodeRow(node.id).handover_result).not.toBe("complete");
expect(result.guidance).toMatch(/re-wakeable|binding is unchanged/);
// #2985: the harness launch itself failed, so nothing was sent into the pane: the failure keeps the generic
// guidance and doesn't claim a successor was launched.
expect(result).not.toHaveProperty("replacementStarted");
expect(result).not.toHaveProperty("successorPane");
expect(result.guidance).not.toContain("a successor was launched");
});
it("rebuild: an induced priming-delivery failure unwinds the successor candidate and reports the step — binding unchanged", async () => {
@@ -519,9 +524,143 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
expect(sendKeys).not.toHaveBeenCalled();
});
it("a seat-pane discovery row claimed by another node still refuses the handover (#237 holds)", async () => {
function seedForeignPaneClaim(opts: { archived: boolean; status: "running" | "superseded" | "detached" | "exited" }) {
const rig = rigRepo.createRig("seat-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "codex", cwd: "/project" });
const session = sessionRegistry.registerSession(node.id, "dev-impl@seat-rig");
sessionRegistry.updateStatus(session.id, opts.status);
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9" });
const discovered = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
discoveryRepo.markClaimed(discovered.id, node.id);
if (opts.archived) rigRepo.archiveRig(rig.id);
return { rig, node, session, discovered };
}
it.each(["fresh", "fork:dev-impl@seat-rig", "rebuild"])("%s reclaims the pane from an exited owner in an archived rig (#2861)", async (source) => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
const { node, sessionId } = seedSeat({ resumeToken: "native-abc" });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
expect(result).toMatchObject({ ok: true, result: { mutated: true, currentStatus: { handoverResult: "complete" } } });
expect(liveSessionId(node.id)).not.toBe(sessionId);
expect(sessionRegistry.getBindingForNode(node.id)).toMatchObject({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9" });
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
});
it.each(["superseded", "detached"] as const)("reclaims the pane from a %s owner in an archived rig (#2861)", async (status) => {
const previous = seedForeignPaneClaim({ archived: true, status });
const { node } = seedSeat();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
});
it("reclaims a pane claim whose owner node was deleted (#2861)", async () => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
db.prepare("DELETE FROM nodes WHERE id = ?").run(previous.node.id);
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: null });
const { node } = seedSeat();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
});
it("reclaims a pane recorded as claimed without a claimant (#2861)", async () => {
const { node } = seedSeat();
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
db.prepare("UPDATE discovered_sessions SET status = 'claimed' WHERE id = ?").run(row.id);
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
expect(discoveryRepo.getDiscoveredSession(row.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
});
it("an archived claim owner with a newer running session still refuses (#2861)", async () => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
const running = sessionRegistry.registerSession(previous.node.id, "dev-impl@seat-rig");
sessionRegistry.updateStatus(running.id, "running");
const { node, sessionId } = seedSeat();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
expect(liveSessionId(node.id)).toBe(sessionId);
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
});
it("an exited owner outside an archived rig still refuses (#2861)", async () => {
const { rig, node, sessionId } = seedSeat();
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex" });
const exited = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
sessionRegistry.updateStatus(exited.id, "exited");
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
discoveryRepo.markClaimed(row.id, other.id);
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
expect(liveSessionId(node.id)).toBe(sessionId);
expect(discoveryRepo.getDiscoveredSession(row.id)?.claimedNodeId).toBe(other.id);
});
it("discovered-source adoption does not reclaim an archived owner's pane (#2861)", async () => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
const { node, sessionId } = seedSeat();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "adopt", source: `discovered:${previous.discovered.id}` });
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
expect(liveSessionId(node.id)).toBe(sessionId);
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
});
it("does not reclaim when the archived owner resumes during the presence probe (#2861)", async () => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
const { node, sessionId } = seedSeat();
hasSession.mockImplementation(async () => {
sessionRegistry.updateStatus(previous.session.id, "running");
return true;
});
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
expect(liveSessionId(node.id)).toBe(sessionId);
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
});
it.each([false, true])("does not overwrite a new claim during the presence probe; original owner deleted=%s (#2861)", async (deleted) => {
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
if (deleted) db.prepare("DELETE FROM nodes WHERE id = ?").run(previous.node.id);
const { rig, node, sessionId } = seedSeat();
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex" });
const running = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
sessionRegistry.updateStatus(running.id, "running");
hasSession.mockImplementation(async () => {
discoveryRepo.markClaimed(previous.discovered.id, other.id);
return true;
});
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
expect(liveSessionId(node.id)).toBe(sessionId);
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(other.id);
});
it.each([false, true])("a foreign pane claim refuses with a running session present=%s (#237 holds)", async (runningSession) => {
const { rig, node, sessionId } = seedSeat();
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex", cwd: "/project" });
if (runningSession) {
const running = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
sessionRegistry.updateStatus(running.id, "running");
}
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
discoveryRepo.markClaimed(row.id, other.id);
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
// even in a one-seat rig.
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
const probed = seatFixture("s5-rig", "dev.flaky");
tmux.failProbeFor(probed.sessionName);
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
if (shared.ok) throw new Error("expected refusal");
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
});
// ---- P5 / P6 — clean ----
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
// OpenRig didn't start running, so it isn't offered as the way to end this one.
const guidance = (claimed as { guidance?: string }).guidance ?? "";
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
expect(guidance).not.toMatch(/run rig down/);
});
@@ -320,6 +320,8 @@ describe("SuccessorSessionLauncher", () => {
expect(res).toMatchObject({ ok: false, step: "start_agent", code: "successor_launch_failed" });
expect((res as { message: string }).message).toContain("codex binary not found");
// #2985: the launch itself failed, so no successor was sent into the pane.
expect(res).not.toHaveProperty("successorLaunched");
// The preserved seat's session is NEVER killed on unwind (retiree state stays recoverable).
expect(killSession).not.toHaveBeenCalled();
// No discovery candidate was committed for a failed successor.
@@ -330,6 +332,8 @@ describe("SuccessorSessionLauncher", () => {
checkReady.mockResolvedValue({ ready: false, reason: "harness not interactive" });
const res = await launcher().createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "a@r" });
expect(res).toMatchObject({ ok: false, step: "start_agent", code: "successor_not_ready" });
// #2985: the harness launch was sent and only readiness failed: the successor may still come up in this pane.
expect(res).toMatchObject({ replacementStarted: true, successorLaunched: true, tmuxPane: "%7" });
expect(killSession).not.toHaveBeenCalled();
expect(discoveryRepo.listDiscovered()).toHaveLength(0);
});
@@ -463,6 +467,8 @@ describe("SuccessorSessionLauncher", () => {
checkReady.mockRejectedValue(new Error("tmux socket closed"));
const res = await launcher().createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "a@r" });
expect(res).toMatchObject({ ok: false, step: "start_agent", code: "successor_readiness_failed" });
// #2985: the launch was sent before the probe threw, so the successor may still be in the pane.
expect(res).toMatchObject({ replacementStarted: true, successorLaunched: true, tmuxPane: "%7" });
expect((res as { message: string }).message).toContain("tmux socket closed");
expect(checkReady).toHaveBeenCalledTimes(1);
expect(killSession).not.toHaveBeenCalled();
@@ -473,6 +479,7 @@ describe("SuccessorSessionLauncher", () => {
checkReady.mockResolvedValue({ ready: false, code: "trust_gate", reason: "trust prompt" });
const res = await launcher().createSuccessor({ node: { id: "n", runtime: "codex", cwd: "/w" }, departingSessionName: "a@r" });
expect(res).toMatchObject({ ok: false, step: "start_agent", code: "successor_attention_required" });
expect(res).toMatchObject({ replacementStarted: true, successorLaunched: true, tmuxPane: "%7" });
expect(checkReady).toHaveBeenCalledTimes(1);
expect(killSession).not.toHaveBeenCalled();
expect(discoveryRepo.listDiscovered()).toHaveLength(0);
+4 -2
View File
@@ -688,7 +688,7 @@ export async function hydrateSnapshot(
// answer where there is one, else the registry's probe. Only a drilled-into remote host's rigs are read, through this
// daemon's read-only forwarding: their names and states, never their seats. A registry entry for this host is this host.
const registered = (hostsRead?.hosts ?? []).flatMap((h) => typeof h.id === "string" && h.id && h.id !== "local"
? [{ id: h.id, reachable: h.status === "reachable" }] : []);
? [{ id: h.id, reachable: h.status === "reachable" ? true : h.status === "unreachable" ? false : null }] : []);
const aggStatus = new Map(aggHosts.filter((h) => h.hostId !== "local").map((h) => [h.hostId, h.status]));
const remoteIds = [...new Set([...aggStatus.keys(), ...registered.map((h) => h.id)])].filter((id) => id !== localHost.name);
const drilledRemote = remoteDrill && drilledHost && remoteIds.includes(drilledHost) ? drilledHost : null;
@@ -698,7 +698,9 @@ export async function hydrateSnapshot(
const remoteHosts: HostNode[] = remoteIds.map((id) => ({
id,
name: id,
reachable: aggStatus.has(id) ? aggStatus.get(id) === "ok" : registered.some((h) => h.id === id && h.reachable),
reachable: aggStatus.has(id)
? aggStatus.get(id) === "ok" ? true : aggStatus.get(id) === "unreachable" ? false : null
: registered.find((h) => h.id === id)?.reachable ?? null,
rigs: id === drilledRemote
? (remoteRigs ?? []).map((rig) => ({ id: rig.id, name: rig.name, lifecycleState: rig.lifecycleState, hasLiveAgents: rig.hasLiveAgents ?? null, pods: [], remoteHost: id })).sort(byRigName)
: [],
+1 -1
View File
@@ -103,7 +103,7 @@ function agentOf(parsed: KeyParts, snap: FleetSnapshot): AgentRow | null {
/** Use structured identity/presence, never the possibly clipped status suffix. */
function rigPresence(parsed: KeyParts, snap: FleetSnapshot): boolean | null {
const host = snap.hosts.find(h => h.name === parsed.parts[0]);
if (!host?.reachable) return null;
if (host?.reachable !== true) return null;
return host.rigs.find(r => r.name === parsed.parts[1])?.hasLiveAgents ?? null;
}
+2 -2
View File
@@ -30,7 +30,7 @@ import { buildLedgerExplorer } from "./crash-cart/ledger-explorer.js";
import type { CrashCartModel } from "./crash-cart/crash-cart-model.js";
import type { DaemonState, DaemonUnverifiedEvidence } from "./crash-cart/contract.js";
import { runtimeMarkSegs } from "./topology/runtime-marks.js";
import { hostLabel } from "./topology/host-label.js";
import { hostLabel, hostReachabilityLabel } from "./topology/host-label.js";
import { barCells, flashActive, reducedMotion, spinnerFrame } from "./motion.js";
import { explorerWidth, MOTION_FRAME_MS } from "./visual-layout.js";
import type { ColorMode, Token } from "./theme.js";
@@ -788,7 +788,7 @@ function instanceContentLines(
...lines,
healthSummaryLine(snap, scope, contentWidth),
...wrapDetailLines([
{ text: `host ${host.name} · ${host.reachable ? "reachable" : "not reachable"}` },
{ text: `host ${host.name} · ${hostReachabilityLabel(host)}` },
{ text: `Its seats aren't read in this view: rig ps --host ${host.name} --nodes -A` },
], contentWidth),
{ text: "" },
+2 -2
View File
@@ -26,7 +26,7 @@ import { SECTION_REGISTRY, SYSTEM_SECTIONS } from "./sections.js";
import { scopesExplorerRows } from "./scopes/scopes-model.js";
import { GRAPH_STYLE_NAMES } from "./topology/render-graph.js";
import { rowStatusGlyph } from "./topology/glyphs.js";
import { hostLabel } from "./topology/host-label.js";
import { hostLabel, hostReachabilitySuffix } from "./topology/host-label.js";
export function defaultSections(): SectionDef[] {
return SECTION_REGISTRY.map((section) => ({ ...section }));
@@ -672,7 +672,7 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
const expanded = new Set(state.expanded);
for (const host of snap.hosts) {
rows.push({
label: ` ▾ ${hostLabel(host)}${host.reachable ? "" : " (unreachable)"}`,
label: ` ▾ ${hostLabel(host)}${hostReachabilitySuffix(host)}`,
action: { type: "drill", resource: "host", name: host.name },
key: `host:${host.name}`,
});
+4
View File
@@ -48,6 +48,10 @@ function paintExplorerBody(text: string, s: Style): string {
const at = text.indexOf("(unreachable)");
return text.slice(0, at) + s.paint("error", "(unreachable)") + text.slice(at + "(unreachable)".length);
}
if (text.includes("(unknown)")) {
const at = text.indexOf("(unknown)");
return text.slice(0, at) + s.paint("dim", "(unknown)") + text.slice(at + "(unknown)".length);
}
if (/\((recoverable|degraded|stopped|attention_required)\)/.test(text)) {
return text.replace(/\((recoverable|degraded|stopped|attention_required)\)/, (m) => s.paint("warn", m));
}
+9 -1
View File
@@ -19,9 +19,17 @@ export function hostLabel(host: HostNode): string {
return name ? `${name} (${host.name})` : host.name;
}
export function hostReachabilityLabel(host: HostNode): "reachable" | "unreachable" | "unknown" {
return host.reachable === true ? "reachable" : host.reachable === false ? "unreachable" : "unknown";
}
export function hostReachabilitySuffix(host: HostNode): string {
return host.reachable === true ? "" : ` (${hostReachabilityLabel(host)})`;
}
/** Keep the command identity first, with room for the existing reachability suffix. */
export function hostExplorerLabel(host: HostNode, width: number): string {
const suffix = host.reachable ? "" : " (unreachable)";
const suffix = hostReachabilitySuffix(host);
const name = displayName(host);
const room = width - host.name.length - suffix.length - 3; // " · "
if (!name || room < 1) return host.name + suffix;
+2 -1
View File
@@ -94,7 +94,8 @@ export interface HostNode {
name: string;
/** Operator-configured name for presentation, never a routing key. */
displayName?: string;
reachable: boolean;
/** true/false only when a probe answered; null keeps missing or unknown distinct. */
reachable: boolean | null;
rigs: RigNode[];
}
@@ -4,6 +4,8 @@ import { hydrateSnapshot } from "../src/hydrate.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { renderScreen } from "../src/render.js";
import { parseCommand } from "../src/grammar.js";
import { createStyle } from "../src/theme.js";
import { stylizeLines } from "../src/stylize.js";
import type { FleetSnapshot, RecentTransitionSnap } from "../src/types.js";
// #2049: a registered remote host stays in the topology tree on every tab and through drill-in, with no feed
@@ -207,6 +209,55 @@ describe("a registered remote host in the topology tree (#2049)", () => {
expect(snap.hosts.map((host) => [host.name, host.reachable])).toEqual([["mac", true], ["mm2", true], ["vps", false]]);
});
it.each(["unsupported-transport", "auth-failed"] as const)(
"keeps unknown reachability distinct from unreachable in status, detail, and color for aggregate %s",
async (aggregateStatus) => {
const fake: Fake = {
calls: [],
hosts: [
{ id: "ssh-unknown", transport: "ssh", selected: false, status: "unknown" },
{ id: "ssh-down", transport: "ssh", selected: false, status: "unreachable" },
VPS,
],
aggregateHosts: [
{ hostId: "agg-u", status: aggregateStatus },
{ hostId: "agg-d", status: "unreachable" },
{ hostId: "vps", status: "ok" },
],
};
const overview = await page(fake, "tab pulse");
const statuses = Object.fromEntries(overview.snap.hosts.map((host) => [host.name, host.reachable]));
expect(statuses).toEqual({
mac: true,
"ssh-unknown": null,
"ssh-down": false,
vps: true,
"agg-u": null,
"agg-d": false,
});
expect(overview.text).toContain("ssh-unknown (unknown)");
expect(overview.text).not.toContain("ssh-unknown (unreachable)");
expect(overview.text).toContain("agg-u (unknown)");
expect(overview.text).toContain("ssh-down (unreachable)");
expect(overview.text).toContain("agg-d (unreachable)");
const style = createStyle("truecolor");
const overviewScreen = renderScreen(overview.view.get(), overview.snap, { cols: 160, rows: 50, colorMode: "truecolor" });
const styledOverview = stylizeLines(overviewScreen, style);
const unknownRow = overviewScreen.explorerRows.find((row) => row.key === "host:agg-u");
const unreachableRow = overviewScreen.explorerRows.find((row) => row.key === "host:agg-d");
expect(unknownRow).toBeDefined();
expect(unreachableRow).toBeDefined();
expect(overviewScreen.lines[unknownRow!.y - 1]!).not.toMatch(/^[▶◆]/);
expect(overviewScreen.lines[unreachableRow!.y - 1]!).not.toMatch(/^[▶◆]/);
expect(styledOverview[unknownRow!.y - 1]!).toContain(style.paint("dim", "(unknown)"));
expect(styledOverview[unreachableRow!.y - 1]!).toContain(style.paint("error", "(unreachable)"));
const detail = await page(fake, "host ssh-unknown");
expect(detail.text).toContain("host ssh-unknown · unknown");
},
);
it("does not list this host twice when the registry names it", async () => {
const { snap } = await page({ calls: [], hosts: [VPS, { ...VPS, id: "mac", url: "http://mac.example:7433" }] });
expect(snap.hosts.map((host) => host.name)).toEqual(["mac", "vps"]);
+2 -2
View File
@@ -26,7 +26,7 @@
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
"core/watchdog/SKILL.md": "88e097e7fe7321e166541b9eac42016fa7a3d362a86fde8946e666f22409f145",
"core/working-with-an-openrig-team/SKILL.md": "01b0aaeb3454bbb4b881dba058022d5a80e62da15516be7db8390b281dd3a228",
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
@@ -131,7 +131,7 @@
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
"core/watchdog/SKILL.md": "88e097e7fe7321e166541b9eac42016fa7a3d362a86fde8946e666f22409f145",
"core/working-with-an-openrig-team/SKILL.md": "01b0aaeb3454bbb4b881dba058022d5a80e62da15516be7db8390b281dd3a228",
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
+5
View File
@@ -94,6 +94,11 @@ pools and missing edge artifacts.
## Active surface
For a worked `periodic-reminder` YAML spec, full registration command and required
flags, read `rig context get reference/keeping-a-watch.md#recurring-reminders-for-agents`.
This is an agent's recurring reminder. A person's watch uses the one-shot queue
pattern in `rig context get reference/keeping-a-watch.md#setting-a-watch`.
Daemon `rig watchdog` is the active host coordination surface for new
work. History records only loud evaluations (`sent` / `terminal`);
quiet skip reasons are NOT recorded — POC parity so agents are not