Compare commits

..
Author SHA1 Message Date
review50-r3andClaude Opus 5.5 1a52040b6b perf(daemon): name the queue's synchronous reads in the slow-operation log
An event-loop gap in the slow-operation log carries no site, so a stall during
one of the queue's synchronous reads couldn't be told from any other. Name
three of them:

- queue.handoff: a whole handoff, which runs synchronously (runSyncSite).
- queue.linked_successors.build: the stuck sweep's linked-successor read
  (runSyncSite).
- queue.decision_revision.replies: decisionRevision's read of a row's replies,
  which runs on every row read and on each row of the attention read. A begin
  and an end record per call would crowd the bounded log, so it goes through a
  new runSyncSiteWhenSlow, which records only a call at or above the existing
  slow threshold and writes no begin record. The recorder gains
  recordSlowSync for it; a failing record never replaces the call's own result.

Tests pin each site through a fake recorder, the real recorder's threshold and
outcome for recordSlowSync, and the new labels in the accepted-site list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 20:53:59 +00:00
Edward Bowie 4270bb3c14 fix(daemon): resolve Codex hook folders without following their files (#3078) (#3079)
For #3078. When a person's Codex home folder, config.toml or hooks.json is a symbolic link (as with dotfile managers), OpenRig keyed its pre-written activity-hook trust by the linked file's target, while Codex 0.167.2 keys trust by the file's name under the resolved home folder, so the trust never applied and the hook review showed on every launch. Hook provisioning, the migration of a person's inline disable, and JSON state cleanup now resolve the containing folder and keep the file's own name, the key Codex builds. For an unlinked file the key is unchanged. A key that doesn't match can only leave the review in place, since Codex trusts a hook only when its stored hash matches the current handler.
2026-11-12 12:29:34 -08:00
Mike Schwarz 26a93a3c23 docs: teach four 0.7.11 capabilities, log F4 and Pi case 1c, and correct two 0.7.12 claims (#3005)
Docs only. The capability map now names four capabilities that shipped in 0.7.11 where an agent looks for them: the relay gateway connector beside the answer and pairing text, a Pi seat's account switch beside the account-home move, on_restore: follow_source beside rig skill loadout (with keep_saved's published limit that a skill whose source changed after rig down comes back missing), and daemon.seat_poll_interval_ms beside the daemon start notes. The arteries regression log gains three entries: F4 (0.7.11, a draft-aware delivery under Claude's bare update banner pasted the message and withheld Enter), Pi case 1c (0.7.11, a Pi seat with its own login and a declared PI_CODING_AGENT_DIR stopped at launch), and 0.7.12's tracked AGENTS.md write on a Codex snapshot restore or rig up --existing, repaired by #3039 on main. Two 0.7.12 teaching claims are corrected in getting-started and tmux-servers, and the CHANGELOG keeps main's corrected 0.7.12 line.
2026-11-12 12:15:37 -08:00
Mike Schwarz 9e8f53d5b4 fix(tmux): raise open-file limit for new servers (#3070)
Busy hosts could hit tmux's inherited soft open-file limit of 256 as panes and viewers accumulated. Calls that can start a tmux server (new-session, start-server, attach-session or -D) now first run one fixed shell prefix that raises the child's soft limit to the lower of the hard limit and 8192, never lowering a higher soft limit or changing the hard limit. On the argv paths the original tmux executable and its arguments reach that script as positional parameters, and the daemon's existing shell launches and the systemd user scope put the same prefix ahead of their already-quoted command. This covers the daemon's launches, control clients, the systemd user scope and the CLI's viewer, chatroom and attachment starts; ordinary capture and send calls keep their direct path. A failed raise keeps the launch and prints one advisory per process. Existing servers keep their limits and processes, and Windows keeps its current path. The cost is a shell on these infrequent calls, and a data argument equal to one of those verbs gets the same harmless wrapper.
2026-11-12 11:23:18 -08:00
Mike Schwarz cb725a4ed0 fix(transport): an ordinary send doesn't type into an open model picker (#3083)
With Claude Code's or Codex's own /model picker open, an ordinary rig send used to paste into the picker and press Enter, which could confirm or change a selection, and the message never reached the model. Now a positively identified current picker returns the existing target_needs_input before any input, the same as a permission prompt, also when the seat's last hook says it is running, starting or unknown. A seat that is busy or unknown with no picker on screen still delivers with its advisory, and text in the conversation history is not treated as a picker. rig send --dangerously-interact --reason stays the deliberate way to type into a dialog. Fixtures are the real Claude Code 2.1.282 and 2.1.322 and Codex 0.167.2 screens.
2026-11-12 11:02:47 -08:00
Mike Schwarz 8f48a8065c fix(queue): restore standing notices after typing holds (#3082)
A typing hold could make the stuck sweep treat pending work as resolved: a standing notice closed during the hold kept those rows in its held-back list, and because their pending time doesn't move, they stayed held back with no open notice after the hold ended. Now a typing-held, pending, unclaimed row is released from the notice it leaves, through the existing routed-away path, so it can appear in a notice again once the hold clears; this covers a seat's own notice and its delegation parent's per-child notice. On the per-row finding path, the hold counts as resolution only for an undelivered wake, so an unclaimed row's own finding stays open across the hold and is refreshed, not re-filed. Dismissals still hold back only their own rows until newer evidence, and an unreadable list still keeps a notice open. The cost is that a replacement notice can wake its recipient once the hold ends.
2026-11-12 10:42:31 -08:00
36 changed files with 839 additions and 80 deletions

No files matched your search

+4 -3
View File
@@ -78,9 +78,10 @@ Known limits.
- Queue content can be erased for closed-work retention and account deletion,
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
- Each send records what's known about its delivery in one
- A send records what's known about its delivery in one
`transport.delivery_evidence` event: what was admitted, what reached the
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
input, what the screen check showed, and for which occupant. The record is
best effort, so a missing one doesn't show there was no send. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- `rig queue create` advises on an unresolved scope, slice lists show observed
queue counts, a proof policy can require every listed judge, the execution
ladder shows each slice's recorded status, `rig queue show` names a handed-off
@@ -168,7 +169,7 @@ Known limits.
It doesn't keep a stored host or boot identity, doesn't guard against another
program running as you, and a daemon restart between losing the tmux server
and the stop wasn't exercised. For an adopted session, such as a seat joined
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
with `rig attach --self` from a tmux pane, `rig seat stop` still suggests `rig down`, which
leaves that terminal running too. Release it with `rig unclaim <session>`
(that leaves its process running), then end that process. If it runs in tmux,
also end the underlying tmux session, including any remaining shell; closing
+3
View File
@@ -116,3 +116,6 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
- 2026-11-10, F4 (0.7.11, #2662 leg (a)), delivery (draft-aware, under Claude's update banner): beneath Claude's bare update banner, a draft-aware delivery pasted the message and withheld Enter, leaving it in the composer, where 0.7.10 held it. Caught at the cut by a native delivery run and accepted with disclosure, so it reached 0.7.11. Missed by footer-grammar fixtures that had no paste hint beside the update notice. Fixed in #2786 for 0.7.12.
- 2026-11-10, Pi case 1c (0.7.11, #2630), launch (Pi account home): a Pi seat with its own Pi login and a declared absolute `PI_CODING_AGENT_DIR` stopped at launch even with an `auth.json` there, with a misleading missing-account-file error, where 0.7.10 started it. Found at the cut by source and function-level tests, not natively, and accepted with disclosure, so it reached 0.7.11. Not fixed: #2803 (0.7.12) makes the launch error name the real cause, and the stop stands. Scenario owed: a Pi seat with its own login and a declared account home.
- 2026-11-12, a tracked `AGENTS.md` (0.7.12, repaired by #3039), restore and relaunch (Codex guidance blocks): on a Codex seat, a snapshot `rig restore`, and `rig down` followed by `rig up --existing`, each wrote OpenRig's managed guidance blocks back into the person's tracked `AGENTS.md` (249 B to 17,316 B), leaving the worktree modified. Seen at the cut by a native Codex run through restore and `up --existing`, and accepted with disclosure, so it reached 0.7.12: its release text says to check `git status` after either. Snapshot restore was already a disclosed 0.7.11 limit; `up --existing` was newly observed, and whether it regressed isn't established. The check that would tell, the same `up --existing` run on the previous release, hasn't run. #3039 merged after 0.7.13's cut, so the repair is on main for 0.7.14, not in 0.7.13. Scenario owed: a Codex seat in a repository with a tracked `AGENTS.md`, checked with `git status` after a restore and after `up --existing`.
+2 -2
View File
@@ -880,8 +880,8 @@ What it costs:
## Stop your teams
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
is left running: `rig down` names it with the commands to stop it by hand, and
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`
from a tmux pane, is left running: `rig down` names it with the commands to stop it by hand, and
exits 2.
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
`rig down` also prints the rig's recorded agent sessions and their activity
+3 -7
View File
@@ -58,13 +58,9 @@ waits for any other change to this enrollment to finish, within the same 30 seco
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
that key takes the old one's place and the bindings made through the old installation end, in one step:
- If that step fails after the account accepted the code, the enrollment's record here doesn't change, and the error
says so. The key is put back too, but moving key files happens outside that record's rollback. If putting it back also
fails (on a full or read-only disk, for example), the old key is left as `key.json.prev` beside `key.json` in the
installation's folder under `network-secrets/` in the OpenRig home: move it back to `key.json` before you go on. A
`key.json.next` there is a staged new key, and the next replace removes it. The old enrollment keeps working only
while the account keeps the old installation, and the account now has a new installation whose key nothing here
holds: remove it on the account page, then enroll again with a new code.
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
enrollment keeps working only while the account keeps the old installation, and the account now has a new
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
+8 -2
View File
@@ -61,6 +61,12 @@ Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
When OpenRig creates a tmux server on macOS or Linux, its creating client tries
to raise the soft open-file limit to 8192, bounded by the inherited hard limit.
An already higher soft limit is preserved. A failed raise produces an advisory
and the start continues with the inherited limit. Connecting to an existing
server does not change that server's limit or restart its sessions.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
@@ -110,8 +116,8 @@ To move a team that runs on an older server:
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
OpenRig didn't start, such as a seat joined with `rig attach --self` from a tmux
pane, is left running and `rig down` exits 2; stop it with the commands it prints, or it stays on
the old server.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
+7 -3
View File
@@ -4,7 +4,8 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execFileSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
@@ -193,8 +194,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
const sessionName = `chatroom@${rig}`;
try {
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
const command = tmuxServerStartCommand("tmux", chatroomWatchTmuxArgs(rig));
const result = spawnSync(command.file, command.args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
reportTmuxServerLimit(result.stderr);
if (result.error || result.status !== 0) throw result.error ?? new Error("tmux session creation failed");
const created = result.stdout.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
+12 -1
View File
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
@@ -43,7 +44,17 @@ export function defaultWindowDeps(): WindowDeps {
const run = promisify(execFile);
return {
platform: process.platform, env, exists: existsSync,
exec: async (file, args, timeoutMs = 10_000) => (await run(file, args, { env, encoding: "utf8", timeout: timeoutMs })).stdout,
exec: async (file, args, timeoutMs = 10_000) => {
const command = path.basename(file) === "tmux" ? tmuxServerStartCommand(file, args) : { file, args };
try {
const result = await run(command.file, command.args, { env, encoding: "utf8", timeout: timeoutMs });
reportTmuxServerLimit(result.stderr);
return result.stdout;
} catch (error) {
reportTmuxServerLimit((error as { stderr?: unknown }).stderr);
throw error;
}
},
launch: (file, args) => new Promise((resolve, reject) => {
const child = spawn(file, args, { env, detached: true, stdio: "ignore" });
child.once("error", reject);
+3 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync, realpathSync } from "node:fs";
import { hostname, networkInterfaces } from "node:os";
import path from "node:path";
import type { DaemonClient } from "./client.js";
import { tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
export interface TmuxAttachment {
session: string;
@@ -86,7 +87,8 @@ export function attachTmux(target: TmuxAttachment): Promise<number> {
const env = { ...process.env };
delete env.TMUX;
delete env.TMUX_PANE;
const child = spawn("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`],
const command = tmuxServerStartCommand("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`]);
const child = spawn(command.file, command.args,
{ stdio: "inherit", env, windowsHide: true });
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultWindowDeps } from "../src/terminal-window.js";
const calls = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
vi.mock("node:child_process", async importOriginal => {
const actual = await importOriginal<typeof import("node:child_process")>();
const { promisify } = await import("node:util");
const execFile = Object.assign(vi.fn(), {
[promisify.custom]: async (file: string, args: string[]) => {
calls.push({ file, args });
return { stdout: "fixture output", stderr: "" };
},
});
return { ...actual, execFile };
});
afterEach(() => { calls.length = 0; });
describe.skipIf(process.platform === "win32")("default window execution with a resolved tmux", () => {
it("raises the creating child's limit and preserves the absolute executable and literal argv", async () => {
const binary = "/fixture binaries/tmux";
const args = ["-S", "/socket with spaces", "new-session", "-d", "-s", "viewer ' $HOME ;"];
await expect(defaultWindowDeps().exec(binary, args)).resolves.toBe("fixture output");
expect(calls).toHaveLength(1);
expect(calls[0]!.file).toBe("/bin/sh");
expect(calls[0]!.args[1]).toContain("ulimit -S -n");
expect(calls[0]!.args.slice(2)).toEqual([binary, ...args]);
});
it("keeps a non-creating capture on that executable direct", async () => {
const args = ["capture-pane", "-p", "-t", "%1"];
await defaultWindowDeps().exec("/fixture binaries/tmux", args);
expect(calls).toEqual([{ file: "/fixture binaries/tmux", args }]);
});
it("does not wrap another executable whose data names a tmux startup verb", async () => {
const args = ["new-session"];
await defaultWindowDeps().exec("/fixture binaries/other", args);
expect(calls).toEqual([{ file: "/fixture binaries/other", args }]);
});
});
+6 -2
View File
@@ -14,9 +14,13 @@ describe("tmux attachment", () => {
const child = new EventEmitter();
vi.mocked(spawn).mockReturnValue(child as ReturnType<typeof spawn>);
const result = attachTmux({ session: "-seat ' quoted", serverArgs: ["-S", "/private/instance socket"], readOnly });
expect(spawn).toHaveBeenCalledWith("tmux", [
const [file, args, options] = vi.mocked(spawn).mock.calls[0]!;
expect(file).toBe(process.platform === "win32" ? "tmux" : "/bin/sh");
expect(process.platform === "win32" ? args : args!.slice(2)).toEqual([
...(process.platform === "win32" ? [] : ["tmux"]),
"-S", "/private/instance socket", "attach-session", ...(readOnly ? ["-r"] : []), "-t", "=-seat ' quoted",
], expect.objectContaining({ stdio: "inherit" }));
]);
expect(options!.stdio).toBe("inherit");
const env = vi.mocked(spawn).mock.calls[0]![2]!.env!;
expect(env.TMUX).toBeUndefined();
expect(env.TMUX_PANE).toBeUndefined();
@@ -148,9 +148,10 @@ unseen — which makes messages the one delivery channel that never gets skipped
A seat's typing guard and `--wait-for-idle` retain their checks. Managed compaction follows the
ordinary draft default and explicit operator protections; it separately reconciles its own
uncertain `/compact` write before sending restore input. Draft detection isn't complete.
**Each send records one `transport.delivery_evidence` event**: what was admitted, what reached
the input, what the screen check showed, and for which occupant. Read it as what was known about
the delivery, not proof the agent read it; where the input landed can read unknown.
**Each send records one `transport.delivery_evidence` event**, best effort: what was admitted, what
reached the input, what the screen check showed, and for which occupant. Read it as what was known
about the delivery, not proof the agent read it; where the input landed can read unknown, and a
missing record doesn't show there was no send.
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
@@ -685,7 +686,7 @@ not have to tear one down to change it, and you rarely have to start from nothin
someone edited it.
**Since 0.7.12 it stops only a tmux session that carries the seat's own `OPENRIG_NODE_ID`**, on the
server and session it read. A terminal OpenRig didn't create, a mismarked or replaced one, or a seat
joined with `rig attach --self` is left running, and `rig down` exits 2 naming it and the manual way
joined with `rig attach --self` from a tmux pane is left running, and `rig down` exits 2 naming it and the manual way
to stop it. Teardown is incomplete for those terminals: if one is yours, look at it and stop it
yourself, then run `rig down` again.
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
@@ -791,6 +792,9 @@ version, from checking the environment to a useful report to the OpenRig team.
`Daemon starting: launch reserved by pid N since T; not listening yet` in `rig daemon status`, not stopped.
A start reservation left from an earlier boot is archived by the next start; don't clear it by
hand.
- **`rig config set daemon.seat_poll_interval_ms <1000-2000>`** slows how often the daemon reads each seat's
activity (default 1000), trading activity freshness for daemon CPU on a large fleet. It's read at daemon start,
so restart the daemon to apply it. Near 2000, a slow tmux read can let a working Codex seat briefly read idle.
- **A busy daemon isn't a down one.** The CLI refuses only when the daemon is confirmed down
(stopped, stale, a refused probe), its probe is blocked, or it's a sibling home; a busy daemon
gets the real request. A write that then times out says its outcome is **unknown**: check its
@@ -1271,6 +1275,11 @@ reaching them is ordinary work rather than an escalation.
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
stays the default, and a person's connector binding can be unset.
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
- **`rig gateway channel add <id> --display-name <name> --binding
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
@@ -1440,6 +1449,12 @@ your circumstances is configuration, and the ones that are not, another agent ca
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
--account-home <dir> --reason "<why>"` (preview with `--dry-run`). It starts a fresh conversation with a handover
and retains the seat's address and work. `--clear-account-home --reason "<why>"` returns to configured defaults.
- **A Pi seat switches accounts** with `rig provider switch --seat <pi seat> --to-account <name> --account-home <dir>
--reason "<why>"` (`--dry-run` shows the plan), through a confirmed successor that keeps its model, effort and
wrapper. A Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its `env:` uses that folder as its account
home on launch, restart, resume and restore, and needs a readable `auth.json` there, or the launch stops; remove
the declaration to keep the managed seat login. A seat that has its own Pi login as well stops at launch even with
an `auth.json` there, and the error says why (for example, that the existing login isn't a managed account link).
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
@@ -1581,6 +1596,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
selected plugin's skills there (into `.claude/skills/` or `.agents/skills/` only for a seat
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
is missing. TDD is no longer a default skill.
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
+1
View File
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
"./tmux-server-start": { "types": "./dist/tmux-server-start-surface.d.ts", "import": "./dist/tmux-server-start-surface.js" },
"./bundle-behaviour": { "types": "./dist/bundle-behaviour-surface.d.ts", "import": "./dist/bundle-behaviour-surface.js" },
"./router-line": { "types": "./dist/router-line-surface.d.ts", "import": "./dist/router-line-surface.js" },
"./spec-validation": {
@@ -668,7 +668,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
const command = `node "${relay}"`;
const keySource = canonicalCodexHookSource(hooksPath);
const keySource = codexHookSourcePath(hooksPath);
const stripped = upsertCodexFeaturesHooksEnabled(stripCodexActivityHooks(existing, command));
let before: unknown = null;
let expected: { hooks?: { state?: CodexHookState } } | null = null;
@@ -678,7 +678,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
if (!expected) return;
const inlineSource = canonicalCodexHookSource(configPath);
const inlineSource = codexHookSourcePath(configPath);
let config = stripped;
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
const groups = document.hooks[event] ?? [];
@@ -714,22 +714,13 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/**
* OPR.0.4.3.33 — compute + splice Codex's `[hooks.state."<key>"] trusted_hash` for our 4
* authored activity hooks into `content`. `key_source` is the canonicalized config path
* (Codex keys trust by `std::fs::canonicalize(config.toml).display()`); we best-effort
* `realpathSync` the config path to match — on a miss (file not yet on real disk, or a mock
* fs in tests) we fall back to the plain absolute path, and any resulting key mismatch just
* degrades to the launch-time trust gate (Layer-2 floor), never a broken run. The command
* authored activity hooks into `content`. Codex 0.167.2 resolves the source folder but
* keeps the file name, including a linked file, as returned by app-server hooks/list. The command
* string is the value Codex deserializes from our TOML literal `'node "<relay>"'` — i.e.
* `node "<relay>"` WITHOUT the outer TOML quote delimiters. Timeout=5, matcher/status None.
*/
private applyCodexActivityHookTrust(content: string, configPath: string, relay: string): string {
let keySource = configPath;
try {
keySource = fs.realpathSync.native(configPath);
} catch {
// config.toml not on the real filesystem (first write / unit-test mock fs) — the plain
// absolute path is the honest best guess; a canonicalization delta is fail-safe (gate reappears).
}
const keySource = codexHookSourcePath(configPath);
const command = `node "${relay}"`;
// upsertCodexActivityHooks has just written our marked block. Codex keys by the
// group's position among this event's groups, not its position inside our block.
@@ -772,7 +763,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
try {
const original = this.fs.readFile(hooksPath);
const document = parseCodexHooksJson(original);
const keySource = canonicalCodexHookSource(hooksPath);
const keySource = codexHookSourcePath(hooksPath);
let config = updated ?? "";
const expected = parseToml(config) as { hooks?: { state?: CodexHookState } };
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
@@ -2451,9 +2442,10 @@ function tomlSections(text: string, current?: string): TomlSection[] {
return sections;
}
function canonicalCodexHookSource(path: string): string {
// Codex keys hook trust by the canonical source path; a file not on the real filesystem keeps its plain path.
try { return fs.realpathSync.native(path); } catch { return path; }
function codexHookSourcePath(file: string): string {
// Resolve a linked home folder, but retain a linked config.toml/hooks.json file's own name.
try { return nodePath.join(fs.realpathSync.native(nodePath.dirname(file)), nodePath.basename(file)); }
catch { return file; } // The folder may not exist yet, including with an injected filesystem.
}
/** A hooks.json document as Codex reads it: `hooks` maps each event to matcher groups, each with a `hooks` list. */
+38 -8
View File
@@ -3,6 +3,7 @@ import { accessSync, constants, statSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import type { ArgvExecFn, ExecFn } from "./tmux.js";
import { mayStartTmuxServer, reportTmuxServerLimit, TMUX_SERVER_LIMIT_PREFIX, tmuxServerStartCommand } from "../tmux-server-start.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -93,19 +94,31 @@ function withExecOutput(err: unknown): unknown {
*/
export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
try {
const { stdout } = await execAsync(cmd, { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
const { stdout, stderr } = await execAsync(tmuxStartShell(cmd), { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
reportTmuxServerLimit(stderr);
return stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
throw withExecOutput(err);
}
};
function tmuxStartShell(cmd: string, replaceShell = false): string {
const creating = process.platform !== "win32" && /^tmux\s/.test(cmd)
&& /(?:^|\s)(?:new-session|start-server|attach-session|-D)(?:\s|$)/.test(cmd.replaceAll("'", ""))
? TMUX_SERVER_LIMIT_PREFIX : "";
return creating + (replaceShell && /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd);
}
/** Replace the shell only for tmux clients; other adapter commands keep shell builtins such as kill. */
export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
const started = performance.now();
try {
return (await execFileAsync("/bin/sh", ["-c", /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd], { ...tmuxExecOptions, env: tmuxClientEnvironment() })).stdout;
const result = await execFileAsync("/bin/sh", ["-c", tmuxStartShell(cmd, true)], { ...tmuxExecOptions, env: tmuxClientEnvironment() });
reportTmuxServerLimit(result.stderr);
return result.stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
// Tokens are diagnostic labels only; the command is executed by the shell without rewriting its arguments.
const labels = (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^(['"])(.*)\1$/, "$2"));
warnTmuxDeadline(err, started, labels);
@@ -115,10 +128,10 @@ export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
/**
* Production ArgvExecFn for TmuxAdapter.
* Runs argv[0] directly via execFile: no shell, no quoting layer. On POSIX
* argv[0] is started by its resolved path: with a bare name, libuv tries each
* PATH entry with a spawn on the event loop. psmux ships a `tmux` alias on
* PATH, so Windows passes the bare name to the spawn.
* Ordinary commands run directly via execFile. Server-creating POSIX commands
* use a child shell to raise only their inherited soft file limit, then exec the
* unchanged argv. Other POSIX commands use a resolved path to avoid libuv's
* repeated PATH spawns on the event loop. Windows keeps the psmux alias on PATH.
*/
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
@@ -127,7 +140,14 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
// Nothing ran when no file was found: a shell also finds tmux through an entry the lookup skips, such as a literal
// `~/bin`, and reports a missing one as the shell transport does.
const opts = { ...tmuxExecOptions, env: tmuxClientEnvironment() };
const run = (file: string | null) => file ? execFileAsync(file, args, opts) : execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts);
const run = async (file: string | null) => {
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? bin : file ?? bin, args);
const result = command.file !== "/bin/sh" && !file
? await execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts)
: await execFileAsync(command.file, command.args, opts);
reportTmuxServerLimit(result.stderr);
return result;
};
try {
if (process.platform === "win32") return (await execFileAsync(bin, args, opts)).stdout;
const file = resolvedOnPath(bin);
@@ -141,6 +161,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
return (await run(null)).stdout;
}
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
warnTmuxDeadline(err, started, argv);
throw withExecOutput(err);
}
@@ -154,7 +175,16 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
export async function spawnTmuxClient(args: string[]): Promise<ChildProcessWithoutNullStreams> {
const opts = { stdio: "pipe" as const, windowsHide: true, env: tmuxClientEnvironment() };
const started = (file: string | null) => new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
const child = file ? spawn(file, args, opts) : spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts);
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? "tmux" : file ?? "tmux", args);
const child = command.file !== "/bin/sh" && !file
? spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts) : spawn(command.file, command.args, opts);
if (mayStartTmuxServer(args)) {
let notice = "";
child.stderr.on("data", data => {
notice = (notice + String(data)).slice(-512);
reportTmuxServerLimit(notice);
});
}
child.once("error", reject);
child.once("spawn", () => {
child.off("error", reject);
+2 -1
View File
@@ -10,6 +10,7 @@ import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-s
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
import type { TmuxControlExec } from "./tmux-control.js";
import { tmuxOnDaemonServer } from "./tmux-server.js";
import { TMUX_SERVER_LIMIT_PREFIX } from "../tmux-server-start.js";
export type ExecFn = (cmd: string) => Promise<string>;
@@ -732,7 +733,7 @@ export class TmuxAdapter {
this.scopeSkipLogged = true;
return fallback(unavailable);
}
const server = posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
const server = TMUX_SERVER_LIMIT_PREFIX + posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
// systemd-run 254+ expands ${VAR} in the command's arguments (by default from 258, also for --scope): the
// socket path must reach /bin/sh exactly as given, so expansion is turned off where the option exists.
const scoped = (oomPolicy: boolean, literal: boolean) => posixJoinArgv(["systemd-run", "--user", "--scope",
@@ -0,0 +1,33 @@
/** The program's own model picker (`/model`), open now at the bottom of the pane. A positive reading establishes a
* dialog for the native send guard, never permission to answer or cancel it: an Enter there confirms the highlighted
* model. A later composer, quoted or fenced text, a partial menu or an unknown footer is not this surface. */
const CLAUDE_MODEL_FOOTERS = ["Enter to set as default · s to use this session only · Esc to cancel"];
const CODEX_MODEL_FOOTERS = ["enter select · esc back", "enter default · s session · esc back"];
/** The choice rows between a picker's instruction and its footer hold exactly one highlighted, numbered row. */
function oneHighlightedChoice(body: string[], pointer: string): boolean {
const choice = new RegExp(`^(?:[↑↓]\\s*)?(?:${pointer}\\s*)?\\d+\\.\\s+\\S`);
const highlighted = new RegExp(`^(?:[↑↓]\\s*)?${pointer}\\s*\\d+\\.\\s+\\S`);
return body.some(line => choice.test(line)) && body.filter(line => highlighted.test(line)).length === 1;
}
/** Claude Code's current model picker (as drawn by 2.1.282 and 2.1.322). */
export function currentClaudeModelMenu(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
// OpenRig-launched Claude may draw its named seat rule below the menu footer.
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(lines.at(-1) ?? "")) lines.pop();
if (!CLAUDE_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.lastIndexOf("Select model");
if (header < 1 || !/^─{3,}$/.test(lines[header - 1]!) || !lines[header + 1]?.startsWith("Switch between Claude models.")) return null;
return oneHighlightedChoice(lines.slice(header + 2, -1), "❯") ? lines[header]! : null;
}
/** Codex's current model picker and the reasoning-level step it leads to (as drawn by 0.167.2). */
export function currentCodexModelPicker(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (!CODEX_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.map(line => line === "Select Model and Effort" || /^Select Reasoning Level for \S.*$/.test(line)).lastIndexOf(true);
if (header < 0) return null;
return oneHighlightedChoice(lines.slice(header + 1, -1), "›") ? lines[header]! : null;
}
+11 -4
View File
@@ -105,6 +105,7 @@ import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, r
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
import { runSyncSite, runSyncSiteWhenSlow } from "./sync-site-wrap.js";
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
function humanAnswerId(askId: string, episodeKey?: string): string {
@@ -821,14 +822,14 @@ export function createLinkedSuccessorLookup(db: Database.Database, sourceQitemId
const startedAt = performance.now();
const map = new Map<string, Array<{ qitemId: string; destination: string | null }>>();
const ids = JSON.stringify([...sources]);
const rows = db.prepare(
const rows = runSyncSite("queue.linked_successors.build", () => db.prepare(
`SELECT s.qitem_id, s.destination_session, s.handed_off_from AS source FROM queue_items s
WHERE s.handed_off_from IN (SELECT value FROM json_each(?))
UNION
SELECT s.qitem_id, s.destination_session, j.value AS source FROM queue_items s, json_each(s.chain_of_record) j
WHERE s.chain_of_record IS NOT NULL AND json_valid(s.chain_of_record)
AND j.value IN (SELECT value FROM json_each(?))`,
).all(ids, ids) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
).all(ids, ids)) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
for (const r of rows) {
const list = map.get(r.source);
const entry = { qitemId: r.qitem_id, destination: r.destination_session };
@@ -2634,8 +2635,13 @@ export class QueueRepository {
}
/** {@link handoff}'s whole act, synchronously: its transaction commits before this returns, and the wake is delivered
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. */
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. Named in
* the slow-operation log, so a stall during a handoff is attributed to it. */
private handoffNow(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
return runSyncSite("queue.handoff", () => this.handoffWithin(input));
}
private handoffWithin(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
const source = this.getById(input.qitemId);
if (!source) {
throw new QueueRepositoryError(
@@ -4563,8 +4569,9 @@ export class QueueRepository {
typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0
? mcOperationTag(opts.excludeOperationReply)
: null;
// Named only when slow: it runs on every row read and on each row of the attention read.
const replies = this.hasReplyToColumn
? (excludeOp
? runSyncSiteWhenSlow("queue.decision_revision.replies", () => excludeOp
? (this.db
.prepare(
"SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid",
@@ -540,7 +540,8 @@ function deliveryResolutionEvidence(
if (kind === "unclaimed-obligation" && !quiet && deps.queueRepo.isRegisteredHumanUpdate(row) && !deps.queueRepo.deliveryOutcomeFor(qitemId)) {
return "informational update to a registered human requires no claim";
}
if (kind !== "undelivered-human-notice" && row.lastNudgeResult === "retained:typing_guard") {
// A temporary hold resolves a failed wake, but unclaimed work still needs its existing standing finding.
if (kind === "undelivered-wake" && row.lastNudgeResult === "retained:typing_guard") {
return "source wake held by the seat typing guard";
}
return null;
@@ -1765,7 +1766,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
AND NOT (json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?))`)
.all(seat, onlyFrom, onlyFrom, STUCK_SWEEP_FINDING_TAG) as Array<{ qitem_id: string }>).map((r) => r.qitem_id));
// #2988: rows this notice lets go of only because something else stands for them now (a finding of their own,
// another parent's or the seat's own notice, or this pass's route for them). Nothing resolved them, so a notice the
// another parent's or the seat's own notice, a temporary typing hold, or this pass's route for them). Nothing resolved them, so a notice the
// sweep closes doesn't hold them back: they leave its list before it closes.
const routedAway = new Set<string>();
// Read within the sweep's bounded yield (a rebuilt list can be long); a notice changed meanwhile is left for the next pass.
@@ -1778,7 +1779,14 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
}
const row = indexed(() => deps.queueRepo.getById(id, { waiting: false }));
// Gone, someone else's now, or resolved at its source.
if (!row || row.destinationSession !== seat || deliveryResolutionEvidence(deps, "unclaimed-obligation", id, now)) continue;
if (!row || row.destinationSession !== seat) continue;
// A typing hold is temporary custody, not a dismissal of this work. Let the closed notice release the row:
// when the hold clears, it can qualify again even without a newer pending timestamp or wake attempt.
if (row.state === "pending" && !row.claimedAt && row.lastNudgeResult === "retained:typing_guard") {
routedAway.add(id);
continue;
}
if (deliveryResolutionEvidence(deps, "unclaimed-obligation", id, now)) continue;
// Held by a finding or recovery of its own (one actually written, so a create that failed this pass leaves the row
// here): that finding holds it back from now on, a dismissed one too. The notice lets go of it rather than also
// holding it once closed: a fold into a parent's notice (#2988) can release that finding's custody, and the row
@@ -1821,8 +1829,8 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
state: "done",
closureReason: "no-follow-on",
transitionNote: parent
? `stuck-sweep resolved: unclaimed-backlog on ${seat} for ${parent}: every row it stood for is claimed, parked, closed, has its own finding or no longer goes to ${parent}`
: `stuck-sweep resolved: unclaimed-backlog on ${seat}: every row it stood for is claimed, parked, closed, has its own finding or is in its delegation parent's notice`,
? `stuck-sweep resolved: unclaimed-backlog on ${seat} for ${parent}: every row it stood for is claimed, parked, closed, temporarily held, has its own finding or no longer goes to ${parent}`
: `stuck-sweep resolved: unclaimed-backlog on ${seat}: every row it stood for is claimed, parked, closed, temporarily held, has its own finding or is in its delegation parent's notice`,
});
othersMayHaveRun();
findings.push({ kind: "unclaimed-backlog", qitemId: key, findingQitemId: last.qitem_id, action: "closed" });
@@ -4,6 +4,7 @@ import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { currentClaudeThemeMenu } from "./claude-theme-menu.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "./native-model-picker.js";
import { currentClaudeResumePicker } from "./native-resume-probe.js";
import stringWidth from "string-width";
import { CODEX_LIVE_WORKING_FRAME_PATTERN, CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
@@ -842,7 +843,10 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
const claudePicker = options.runtime === "codex" || options.runtime === "pi" ? null : currentClaudeResumePicker(paneContent);
const themeMenu = options.runtime === undefined || options.runtime === "claude-code"
? currentClaudeThemeMenu(paneContent) : null;
const selectionPromptEvidence = themeMenu ?? claudePicker ?? currentClaudeMcpMenu(paneContent) ?? findCurrentClaudeQuestion(paneContent) ??
// The program's own /model picker: its highlighted row can sit above the scan window, and Enter there confirms it.
const modelPicker = options.runtime === "codex" ? currentCodexModelPicker(paneContent)
: options.runtime === undefined || options.runtime === "claude-code" ? currentClaudeModelMenu(paneContent) : null;
const selectionPromptEvidence = modelPicker ?? themeMenu ?? claudePicker ?? currentClaudeMcpMenu(paneContent) ?? findCurrentClaudeQuestion(paneContent) ??
currentComposerSelection ?? (options.currentClaudeDialogueOnly ? null
: findPatternEvidence(selectionLines, [COMPOSER_SELECTION_SCAN_PATTERN]));
if (selectionPromptEvidence) {
@@ -4197,7 +4201,8 @@ export class SessionTransport {
// dialog. Opening a picker or a permission selector after Stop fires no new hook, so a fresh idle
// hook gets the same current-dialog check as a fresh running one.
if (this.hookFreshForSend(hookActivity, now)) {
if ((hookActivity.state === "running" || hookActivity.state === "idle") && input.runtime === "claude-code") {
// A fresh SessionStart reads unknown, and a picker can open after it just as after Stop.
if ((hookActivity.state === "running" || hookActivity.state === "idle" || hookActivity.state === "unknown") && input.runtime === "claude-code") {
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
@@ -4214,6 +4219,24 @@ export class SessionTransport {
if (paneVeto.state === "needs_input" && (hookActivity.state === "running"
|| paneVeto.reason === "selection_prompt" || paneVeto.reason === "permission_prompt")) return paneVeto;
}
// Codex's /model picker fires no hook either, and Codex opens it during a running turn too. After any fresh
// hook short of a permission request, only a current selector recognized in full refuses (the dialogue-only
// read skips the numbered-row scan, so history, drafts and a busy pane with no picker still deliver).
if ((hookActivity.state === "idle" || hookActivity.state === "running" || hookActivity.state === "unknown") && input.runtime === "codex") {
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
currentClaudeDialogueOnly: true,
sessionName: input.sessionName,
runtime: input.runtime,
attachmentType: input.attachmentType as "tmux" | "external_cli" | null | undefined,
tmuxAdapter: this.tmuxAdapter,
now,
captureObserver: privateInput ? undefined : this.captureObserver,
binding,
});
if (paneVeto.state === "needs_input" && paneVeto.reason === "selection_prompt") return paneVeto;
}
return hookActivity;
}
// OPR.0.4.3.28 Part A — a stale-but-latest `idle` hook (older than the 15s
@@ -48,6 +48,8 @@ export interface SlowOperationInstrumentation {
classify?: (value: T) => "ok" | "failed",
): Promise<T>;
recordRequest?(site: string, durationMs: number): void;
/** A synchronous site's duration, recorded only at or above the slow threshold (runSyncSiteWhenSlow). */
recordSlowSync?(site: string, durationMs: number, outcome: "ok" | "failed"): void;
/** Bounded nonblocking diagnostic append; false means observation was dropped. */
recordDiagnostic?(record: Record<string, unknown>): boolean;
snapshot?(): SlowOperationSnapshot;
@@ -262,6 +264,11 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
this.recordMeasurement(`request:${site}`, durationMs);
}
recordSlowSync(site: string, durationMs: number, outcome: "ok" | "failed" = "ok"): void {
if (!Number.isFinite(durationMs) || durationMs < this.slowThresholdMs) return;
this.recordMeasurement(site, durationMs, outcome);
}
recordDiagnostic(record: Record<string, unknown>): boolean {
this.diagnostic.offered++;
const coverage = { ...this.diagnostic, pending: this.diagnosticIds.size,
@@ -10,6 +10,26 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
return recorder?.runSync ? recorder.runSync(site, fn) : fn();
}
/**
* runSyncSite for a site on a hot path (per request, per row), where a begin and an end record per call would crowd
* the bounded log: it records only a call that took at least the recorder's slow threshold, and writes no begin
* record, so a call that wedges the loop is named once it ends rather than while it runs.
*/
export function runSyncSiteWhenSlow<T>(site: string, fn: () => T): T {
const record = recorder?.recordSlowSync?.bind(recorder);
if (!record) return fn();
const startedAt = performance.now();
let outcome: "ok" | "failed" = "failed";
try {
const value = fn();
outcome = "ok";
return value;
} finally {
// Measurement only: a failing record never replaces fn's own result or error.
try { record(site, performance.now() - startedAt, outcome); } catch { /* the recorder reports its own health */ }
}
}
/**
* Async twin of runSyncSite: instruments a NON-blocking site through the
* recorder's async `runStage` seam (falls back to a bare call when no recorder
@@ -0,0 +1 @@
export * from "./tmux-server-start.js";
+37
View File
@@ -0,0 +1,37 @@
/** tmux 3.3a–3.6 inherits its creating client's limits. This runs in that child only:
* never lower an already higher soft limit, change the hard limit, or restart a server. */
export const TMUX_SERVER_LIMIT_WARNING = "[tmux] Could not raise the open-file limit for a new terminal server; continuing with the inherited limit.";
export const TMUX_SERVER_LIMIT_PREFIX = `
openrig_tmux_nofile() {
openrig_tmux_soft=$(ulimit -S -n) || return 1
case "$openrig_tmux_soft" in unlimited) return 0;; ''|*[!0-9]*) return 1;; esac
openrig_tmux_hard=$(ulimit -H -n) || return 1
openrig_tmux_target=8192
case "$openrig_tmux_hard" in
unlimited) ;;
''|*[!0-9]*) return 1;;
*) if [ "$openrig_tmux_hard" -lt "$openrig_tmux_target" ]; then openrig_tmux_target=$openrig_tmux_hard; fi;;
esac
[ "$openrig_tmux_soft" -ge "$openrig_tmux_target" ] || ulimit -S -n "$openrig_tmux_target"
}
openrig_tmux_nofile 2>/dev/null || printf '%s\\n' '${TMUX_SERVER_LIMIT_WARNING}' >&2
`;
/** Production callers use full command names. A matching data argument merely gets the same harmless child limit. */
export function mayStartTmuxServer(args: readonly string[]): boolean {
return args.some(arg => ["new-session", "start-server", "attach-session", "-D"].includes(arg));
}
export function tmuxServerStartCommand(file: string, args: string[]): { file: string; args: string[] } {
if (process.platform === "win32" || !mayStartTmuxServer(args)) return { file, args };
return { file: "/bin/sh", args: ["-c", TMUX_SERVER_LIMIT_PREFIX + 'exec "$0" "$@"', file, ...args] };
}
let warned = false;
/** execFile captures stderr even on success; surface this advisory once per process. */
export function reportTmuxServerLimit(stderr: unknown): void {
if (!warned && String(stderr ?? "").includes(TMUX_SERVER_LIMIT_WARNING)) {
warned = true;
console.warn(TMUX_SERVER_LIMIT_WARNING);
}
}
@@ -160,8 +160,9 @@ describe("ordinary transport under current and historical questions", () => {
const staleWaiting = hook.name === "send_stale_permission" || hook.name === "display_stale_permission";
const currentQuestion = ["picker160", "picker100", "picker12", "picker13"].includes(shape)
|| shape === "footer_in_multiline_draft";
// A fresh running or idle hook reads the pane once for a current dialog: a picker opened after Stop fires no hook.
const freshDialogCheck = hook.name === "fresh_running" || hook.name === "fresh_idle";
// A fresh running, idle or unknown (SessionStart) hook reads the pane once for a current dialog: a picker
// opened after Stop or SessionStart fires no hook.
const freshDialogCheck = hook.name === "fresh_running" || hook.name === "fresh_idle" || hook.name === "fresh_unknown";
const freshCurrentQuestion = freshDialogCheck && currentQuestion;
const sent = hook.name === "fresh_permission" || freshCurrentQuestion ? false
: hook.name.startsWith("fresh_") ? true : state !== "attention" && !(staleWaiting && state === "unknown");
+40 -8
View File
@@ -9,11 +9,12 @@
// values THIS reproduction emits and the perturbation tests prove the identity fields are folded in
// — but the DEFINITIVE correctness check is a byte-for-byte read-back of a real Codex `[hooks.state]`
// after `/hooks`→"Trust all" (the QA VM proof). Until that read-back confirms them, the exact
// key_source (canonicalized config path) and positional indices are PROVISIONAL. A mismatch is
// key_source (resolved folder plus file name) and positional indices are PROVISIONAL. A mismatch is
// fail-safe (gate reappears + Layer-2 keystroke floor), never a false-trusted run.
import { describe, it, expect, vi } from "vitest";
import nodeFs from "node:fs";
import nodePath from "node:path";
import nodeOs from "node:os";
import { parse as parseToml } from "smol-toml";
import {
CodexRuntimeAdapter,
@@ -29,8 +30,8 @@ const COMMAND = `node "${RELAY}"`;
const EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"] as const;
// PIN TO VM READ-BACK (qa gate) — PROVISIONAL from open-source reproduction (PR #20321 / commits
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). keySource here is the NON-canonical
// mock path `/home/test/.codex/config.toml`; the real key_source is std::fs::canonicalize(config.toml).
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). Codex0.167.2 hooks/list confirms
// the source folder is resolved, while the config file keeps its own name even when it is linked.
const PROVISIONAL_FIXTURE: Record<(typeof EVENTS)[number], { key: string; hash: string }> = {
SessionStart: {
key: `${CONFIG}:session_start:0:0`,
@@ -267,22 +268,53 @@ describe("OPR.0.4.3.33 — provisioning-seam coupling (ensureCodexActivityHooks
expect(fs._store[CONFIG]).toBe(before);
});
it("uses native canonical path casing for Codex hook trust keys", () => {
const canonicalConfigPath = "C:\\Users\\Test\\.codex\\config.toml";
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalConfigPath);
it("resolves the source folder while retaining the config file name", () => {
const canonicalHome = nodePath.join("/resolved", "codex");
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalHome);
try {
const fakeFs = mockCodexFs({ [RELAY]: "// relay" });
makeAdapter(fakeFs).ensureCodexActivityHooks();
const configPath = Object.keys(fakeFs._store).find((path) => path.endsWith("config.toml"))!;
const config = fakeFs._store[configPath]!;
expect(nativeRealpath).toHaveBeenCalledWith(configPath);
expect(config).toContain(`[hooks.state.${JSON.stringify(`${canonicalConfigPath}:session_start:0:0`)}]`);
expect(config).toContain(`[hooks.state.${JSON.stringify(`${nodePath.join(canonicalHome, "config.toml")}:session_start:0:0`)}]`);
expect(config).not.toContain(`[hooks.state.${JSON.stringify(`${configPath}:session_start:0:0`)}]`);
} finally {
nativeRealpath.mockRestore();
}
});
it.skipIf(process.platform === "win32").each([
{ json: false, linkedHome: false }, { json: true, linkedHome: false },
{ json: false, linkedHome: true }, { json: true, linkedHome: true },
])("trusts file names inside the resolved home (JSON: $json, linked home: $linkedHome)", ({ json, linkedHome }) => {
const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "codex-linked-trust-"));
try {
const home = nodePath.join(root, "home"); nodeFs.mkdirSync(home);
const selectedHome = linkedHome ? nodePath.join(root, "linked-home") : home;
if (linkedHome) nodeFs.symlinkSync(home, selectedHome);
const source = nodePath.join(root, "source.toml"); nodeFs.writeFileSync(source, 'model = "fixture"\n');
nodeFs.symlinkSync(source, nodePath.join(home, "config.toml"));
const relay = nodePath.join(root, "relay.cjs"); nodeFs.writeFileSync(relay, "// never executed");
if (json) {
const hooks = nodePath.join(root, "source-hooks.json"); nodeFs.writeFileSync(hooks, '{"hooks":{}}\n');
nodeFs.symlinkSync(hooks, nodePath.join(home, "hooks.json"));
}
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), activityRelayPath: relay, fsOps: {
exists: nodeFs.existsSync, readFile: p => nodeFs.readFileSync(p, "utf8"),
writeFile: (p,c) => nodeFs.writeFileSync(p,c), mkdirp: p => { nodeFs.mkdirSync(p,{ recursive:true }); },
} });
adapter.ensureCodexActivityHooks(selectedHome);
const hooks = parseToml(nodeFs.readFileSync(source,"utf8")).hooks;
if (!hooks || typeof hooks !== "object") throw new Error("missing hook table");
const states: unknown = Reflect.get(hooks, "state");
if (!states || typeof states !== "object") throw new Error("missing trust table");
expect(Object.keys(states).sort()).toEqual(["permission_request", "session_start", "stop", "user_prompt_submit"]
.map(event => `${nodePath.join(nodeFs.realpathSync.native(home), json ? "hooks.json" : "config.toml")}:${event}:0:0`).sort());
expect(nodeFs.lstatSync(nodePath.join(home, "config.toml")).isSymbolicLink()).toBe(true);
} finally { nodeFs.rmSync(root,{recursive:true,force:true}); }
});
it("is idempotent — re-running produces byte-identical config (no duplicate trust tables)", () => {
const fs = mockCodexFs({ [RELAY]: "// relay" });
const adapter = makeAdapter(fs);
+19
View File
@@ -0,0 +1,19 @@
❯ /model
────────────────────────────────────────────────────────── worker-a@demo ─
────────────────────────────────────────────────────────────────────────────────
Select model
Switch between Claude models. Your pick becomes the default for new
sessions. For other/previous model names, specify with --model.
↑ 2. Sonnet Sonnet 5 · Efficient for routine tasks
3. Fable Fable 5.1 · Most capable for your hardest and
longest-running tasks
4. Opus Opus 5.5 · Best for everyday, complex tasks
5. Haiku Haiku 4.5 · Fastest for quick answers
❯ 6. claude-haiku-5-5 ✔ Custom model
… +1 model
● High effort (default) ←/→ to adjust
Enter to set as default · s to use this session only · Esc to cancel
+23
View File
@@ -0,0 +1,23 @@
❯ /model
─────────────────────────────────────────────────────────────────── p-seat@dlv ─
────────────────────────────────────────────────────────────────────────────────
Select model
Switch between Claude models. Your pick becomes the default for new
sessions. For other/previous model names, specify with --model.
❯ 1. Default (recommended) ✔ Use the default model (currently Opus 5.5) ·
$4/$20 per Mtok
2. Opus Opus 5.5 · Best for everyday, complex tasks ·
$4/$20 per Mtok
3. Fable Fable 5.2 · Most capable for your hardest and
longest-running tasks · $8/$40 per Mtok
4. Sonnet Sonnet 5.5 · Efficient for routine tasks ·
$2/$10 per Mtok
5. Haiku Haiku 5.5 · Fastest for quick answers ·
$0.10/$0.50 per Mtok ($0.50/$2.50 for prompts
over 100k)
◐ Medium effort (default) ←/→ to adjust
Enter to set as default · s to use this session only · Esc to cancel
+24
View File
@@ -0,0 +1,24 @@
OpenRig: this seat's skills. This list replaces any earlier OpenRig skill
list in this conversation.
No OpenRig skills are kept for this seat.
• ack (no marker)
Worked for <1s • 16:36
Select Model and Effort
› 1. GPT-6.1-Sol Latest workhorse model for coding and everyday work.
2. GPT-6-Astra Frontier intelligence for the most demanding work.
3. GPT-6-Sol Previous generation workhorse model.
4. GPT-6-Luna Fast and affordable model for easier tasks.
5. GPT-5.6-Sol Older generation workhorse model.
6. GPT-6.1 Terra GPT-6.1 Terra Codex model.
7. GPT-5.6-Terra Older balanced model for straightforward work.
8. GPT-5.6-Luna Older fast and efficient model.
↓
enter select · esc back
@@ -0,0 +1,24 @@
lists them all.
End of generated identity context.
OpenRig: this seat's skills. This list replaces any earlier OpenRig skill
list in this conversation.
No OpenRig skills are kept for this seat.
• ack (no marker)
Worked for <1s • 16:36
Select Reasoning Level for GPT-6.1-Sol
› 1. Low (default) Fast responses with lighter reasoning
2. Medium Balances speed and reasoning depth for everyday tasks
3. High Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
5. More reasoning… Max and Ultra consume usage limits faster
enter default · s session · esc back
@@ -0,0 +1,133 @@
// The program's own /model picker, from real captures of OpenRig seats on a stand-in model: Claude Code 2.1.282 (the
// transcript above it removed, a synthetic seat label) and 2.1.322, and Codex 0.167.2's model and reasoning-level steps.
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { classifyPaneActivity, SessionTransport } from "../src/domain/session-transport.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "../src/domain/native-model-picker.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const fixture = (name: string) => readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
const claude = fixture("claude-model-2.1.282.txt");
// The highlight on the first model, with wrapped descriptions: its row is above the last 12 lines a generic scan reads.
const claudeDefault = fixture("claude-model-2.1.322.txt");
const codexModel = fixture("codex-model-0.167.2.txt");
const codexReasoning = fixture("codex-reasoning-0.167.2.txt");
const rule = "─".repeat(60);
const claudeComposer = `${rule}\n❯ \n${rule}\n ⏵⏵ accept edits on (shift+tab to cycle)`;
const codexComposer = "› Ask Codex to do anything\n\n gpt-6.1-sol default · ~/work";
describe("the program's own model picker", () => {
it.each([
["Claude 2.1.282, as captured", claude, "claude-code", "Select model"],
["Claude 2.1.322, the highlight above the scan window", claudeDefault, "claude-code", "Select model"],
["Codex 0.167.2, the model popup", codexModel, "codex", "Select Model and Effort"],
["Codex 0.167.2, the reasoning-level step", codexReasoning, "codex", "Select Reasoning Level for GPT-6.1-Sol"],
] as const)("recognizes %s as a current selector, dialogue-only reads included", (_name, pane, runtime, header) => {
expect(classifyPaneActivity(pane, { runtime })).toMatchObject({ state: "attention", reason: "selection_prompt", evidence: header });
expect(classifyPaneActivity(pane, { runtime, currentClaudeDialogueOnly: true })).toMatchObject({ reason: "selection_prompt", evidence: header });
});
it.each([
["a later empty composer", claude + claudeComposer, codexModel + codexComposer],
["a later draft", claude + claudeComposer.replace("❯ ", "❯ keep my draft"), codexModel + codexComposer.replace("Ask Codex to do anything", "keep my draft")],
["quoted", claude.split("\n").map(line => "> " + line).join("\n"), codexModel.split("\n").map(line => "> " + line).join("\n")],
["fenced output", "```text\n" + claude + "```", "```text\n" + codexModel + "```"],
["an unknown footer", claude.replace("Enter to set as default", "Enter to approve"), codexModel.replace("enter select · esc back", "enter approve · esc back")],
["no header", claude.replace("Select model", "Select tools"), codexModel.replace("Select Model and Effort", "Select Tools")],
["no highlighted row", claude.replace("❯ 6.", " 6."), codexModel.replace("› 1.", " 1.")],
["two highlighted rows", claude.replace(" 4. Opus", " ❯ 4. Opus"), codexModel.replace(" 4. GPT-6-Luna", "› 4. GPT-6-Luna")],
])("doesn't read %s as a current picker", (_name, claudePane, codexPane) => {
expect(currentClaudeModelMenu(claudePane)).toBeNull();
expect(currentCodexModelPicker(codexPane)).toBeNull();
});
it("reads each program's picker only on that program's pane", () => {
expect(classifyPaneActivity(codexModel, { runtime: "claude-code", currentClaudeDialogueOnly: true }).reason).not.toBe("selection_prompt");
expect(classifyPaneActivity(claude, { runtime: "codex", currentClaudeDialogueOnly: true }).reason).not.toBe("selection_prompt");
});
});
type Hook = "Stop" | "UserPromptSubmit" | "SessionStart";
async function send(runtime: "claude-code" | "codex", pane: string, hook: Hook | null) {
const db = createFullTestDb();
try {
const repo = new RigRepository(db), registry = new SessionRegistry(db), rig = repo.createRig("picker");
const node = repo.addNode(rig.id, "worker.a", { runtime, role: "worker" });
const name = "worker-a@picker";
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name });
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const now = new Date("2026-11-12T17:00:00Z");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => now,
resolveOccupantGeneration: (id) => registry.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare(
"SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
});
if (hook) expect(store.recordHookEvent({ runtime, sessionName: name, hookEvent: hook, generation,
occurredAt: new Date(now.getTime() - 1_000).toISOString() }).ok).toBe(true);
const paste = vi.fn(async () => ({ ok: true as const })), keys = vi.fn(async () => ({ ok: true as const }));
const adapter = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
getPaneCommand: async () => (runtime === "codex" ? "codex" : "claude"), getPanePid: async () => null, listPanes: async () => [],
hasSessionEnv: async () => false, capturePaneContent: async () => pane, sendText: paste, sendKeys: keys,
} as unknown as TmuxAdapter;
const transport = new SessionTransport({ db, rigRepo: repo, sessionRegistry: registry, tmuxAdapter: adapter,
agentActivityStore: store, now: () => now, sleep: async () => undefined, activityEndpointFile: () => null });
const result = await transport.send(name, "Ordinary synthetic peer message");
return { result, written: paste.mock.calls.length + keys.mock.calls.length };
} finally { db.close(); }
}
describe("an ordinary send never types into an open model picker", () => {
it.each([
["Claude, no hooks", "claude-code", claudeDefault, null],
["Claude, a fresh Stop hook", "claude-code", claudeDefault, "Stop"],
["Codex, no hooks", "codex", codexModel, null],
["Codex, a fresh Stop hook", "codex", codexModel, "Stop"],
["Codex, the reasoning-level step after a fresh Stop hook", "codex", codexReasoning, "Stop"],
// Codex opens /model during a running turn too, and a fresh SessionStart reads unknown on both programs.
["Codex, a fresh UserPromptSubmit hook (a running turn)", "codex", codexModel, "UserPromptSubmit"],
["Codex, a fresh SessionStart hook", "codex", codexModel, "SessionStart"],
["Claude, a fresh SessionStart hook", "claude-code", claudeDefault, "SessionStart"],
["Claude, a fresh UserPromptSubmit hook (a running turn)", "claude-code", claudeDefault, "UserPromptSubmit"],
] as const)("%s: refused before any input", async (_name, runtime, pane, hook) => {
const sent = await send(runtime, pane, hook);
// One assertion, so a failure shows both what the send answered and what it wrote.
expect({ ok: sent.result.ok, reason: (sent.result as { reason?: string }).reason, written: sent.written })
.toEqual({ ok: false, reason: "target_needs_input", written: 0 });
expect(sent.result).toMatchObject({ sent: false });
});
it.each([
["Codex", "an empty composer", "codex", codexComposer],
["Codex", "an empty composer under a numbered message in the history", "codex", "› 1. check the build\n• ok\n\n" + codexComposer],
["Codex", "a picker's rows in the history, under a later composer", "codex", codexModel + "\n• Model changed\n\n" + codexComposer],
["Claude", "an empty composer", "claude-code", claudeComposer],
["Claude", "a picker's rows in the history, under a later composer", "claude-code", claudeDefault + "\n⎿ Set model to Opus 5.5\n\n" + claudeComposer],
] as const)("%s after a fresh Stop hook still delivers to %s", async (_program, _name, runtime, pane) => {
const sent = await send(runtime, pane, "Stop");
expect(sent.result.ok).toBe(true);
expect(sent.written).toBeGreaterThan(0);
});
it.each([
["Codex, a fresh SessionStart hook, an empty composer", "codex", codexComposer, "SessionStart"],
["Claude, a fresh SessionStart hook, an empty composer", "claude-code", claudeComposer, "SessionStart"],
] as const)("%s: still delivers", async (_name, runtime, pane, hook) => {
const sent = await send(runtime, pane, hook);
expect(sent.result.ok).toBe(true);
expect(sent.written).toBeGreaterThan(0);
});
it("Codex mid-turn with no picker still delivers, with the busy advisory, as before", async () => {
const sent = await send("codex", "• Working (4s • esc to interrupt)\n\n" + codexComposer, "UserPromptSubmit");
expect(sent.result).toMatchObject({ ok: true });
expect((sent.result as { warning?: string }).warning).toContain("mid-task");
expect(sent.written).toBeGreaterThan(0);
});
});
@@ -244,6 +244,46 @@ describe("stuck sweep: unclaimed rows routed to a delegation parent (#2988)", ()
expect(openFor(q).length).toBeGreaterThan(0);
});
it.each(["parent", "own"] as const)("a readable %s notice reports its row again after a transient typing hold clears", async (kind) => {
if (kind === "parent") lane("dev1"); else seat("lane", "pool.dev1", "dev1@lane");
const to = kind === "parent" ? "owner@lane" : "dev1@lane";
const row = await work("dev1@lane", 90);
await sweep();
const [notice] = findingsTo(to);
expect(notice!.body).toContain(`Every row this notice stands for: ["${row.qitemId}"]`);
const before = queue.getById(row.qitemId)!;
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(queue.getById(notice!.qitemId)).toMatchObject({ state: "done" });
expect(findingsTo(to).filter(isOpen)).toHaveLength(0);
// No new row transition, timestamp or wake attempt: ending the temporary custody is the new fact.
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
await sweep();
const open = findingsTo(to).filter(isOpen);
expect(open).toHaveLength(1);
expect(open[0]!.body).toContain(row.qitemId);
expect(open[0]!.qitemId).not.toBe(notice!.qitemId);
expect(queue.getById(row.qitemId)).toMatchObject({ state: "pending", claimedAt: null,
tsUpdated: before.tsUpdated, lastNudgeAttempt: before.lastNudgeAttempt });
});
it.each(["parent", "own"] as const)("a dismissed %s notice still holds back its row across a transient typing hold", async (kind) => {
if (kind === "parent") lane("dev1"); else seat("lane", "pool.dev1", "dev1@lane");
const to = kind === "parent" ? "owner@lane" : "dev1@lane";
const row = await work("dev1@lane", 90);
await sweep();
const [notice] = findingsTo(to);
await queue.update({ qitemId: notice!.qitemId, actorSession: to, state: "done",
closureReason: "no-follow-on", transitionNote: "seen" });
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(findingsTo(to)).toHaveLength(1);
expect(findingsTo(to).filter(isOpen)).toHaveLength(0);
});
// Review of #3049: the route is rechecked at the fold, not only the finding's state: a parent resolution that changes
// or becomes ambiguous while the notice is being written leaves the per-row finding open and unmarked.
it.each(["ambiguous", "gone"] as const)("a fold rechecks the route: a parent that becomes %s while the notice is written folds nothing", async (change) => {
@@ -121,6 +121,34 @@ describe("stuck sweep: unclaimed rows of a seat with no escalation route (#2430)
expect(queue.getById(notice!.qitemId)).toMatchObject({ state: "pending" });
});
it.each([false, true])("a per-row finding keeps its open or dismissed disposition across a typing hold (dismissed: %s)", async (dismissed) => {
const door = seat("front", "door.root", "root@front");
seat("ops", "watch.desk", "desk@ops");
rigs.addEdge(door.rig.id, door.node.id, "desk@ops", "escalates_to");
let sends = 0;
queue.attachTransport({ send: async () => { sends++; return { ok: true, verified: false }; } });
const row = await work("root@front");
await sweep();
const [finding] = findingsTo("desk@ops");
expect(finding!.tags).toContain(`stuck-sweep:unclaimed-obligation:${row.qitemId}`);
if (dismissed) await queue.update({ qitemId: finding!.qitemId, actorSession: "desk@ops", state: "done",
closureReason: "no-follow-on", transitionNote: "seen" });
const expected = dismissed ? "done" : "pending";
const before = queue.getById(row.qitemId)!;
const sent = sends;
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(queue.getById(finding!.qitemId)?.state).toBe(expected);
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
await sweep();
expect(findingsTo("desk@ops").map((f) => ({ id: f.qitemId, state: f.state })))
.toEqual([{ id: finding!.qitemId, state: expected }]);
expect(sends).toBe(sent);
expect(queue.getById(row.qitemId)).toMatchObject({ state: "pending", claimedAt: null,
tsUpdated: before.tsUpdated, lastNudgeAttempt: before.lastNudgeAttempt });
});
it("with an escalates_to route, each unclaimed row's finding goes to that seat, never to the seat itself", async () => {
const door = seat("front", "door.root", "root@front");
seat("ops", "watch.desk", "desk@ops");
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository, createLinkedSuccessorLookup } from "../src/domain/queue-repository.js";
import { configureSyncSiteRecorder, runSyncSiteWhenSlow } from "../src/domain/sync-site-wrap.js";
// The queue's synchronous reads named in the slow-operation log, so an event-loop stall during one is attributed to it.
// A fake recorder takes every call; the real recorder's slow threshold is pinned in slow-op-recorder.test.ts.
describe("queue reads named in the slow-operation log", () => {
let db: Database.Database;
let repo: QueueRepository;
const sync: string[] = [];
const slow: Array<{ site: string; outcome: string }> = [];
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db));
configureSyncSiteRecorder({
runSync<T>(site: string, fn: () => T): T { sync.push(site); return fn(); },
recordSlowSync(site: string, _durationMs: number, outcome: "ok" | "failed"): void { slow.push({ site, outcome }); },
});
});
afterEach(() => { configureSyncSiteRecorder(undefined); sync.length = 0; slow.length = 0; db.close(); });
it("names a handoff", async () => {
const source = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
sync.length = 0;
const { created } = await repo.handoff({ qitemId: source.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false });
expect(created.destinationSession).toBe("worker@r");
expect(sync).toContain("queue.handoff");
});
it("names decisionRevision's reply read through the slow-only site, once per revision", async () => {
const ask = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
slow.length = 0;
const revision = repo.decisionRevision(repo.getById(ask.qitemId)!);
expect(typeof revision).toBe("string");
expect(slow).toEqual([{ site: "queue.decision_revision.replies", outcome: "ok" }]);
});
it("names the stuck sweep's linked-successor build when it is built", () => {
const lookup = createLinkedSuccessorLookup(db, ["qitem-source"]);
expect(sync).toEqual([]);
expect(lookup.has("qitem-source", "worker@r")).toBe(false);
expect(lookup.has("qitem-source", "other@r")).toBe(false);
expect(sync).toEqual(["queue.linked_successors.build"]);
});
it("keeps the wrapped call's result and error, and records each outcome", () => {
expect(runSyncSiteWhenSlow("test.site", () => 7)).toBe(7);
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
expect(slow).toEqual([{ site: "test.site", outcome: "ok" }, { site: "test.site", outcome: "failed" }]);
});
it("never lets a failing record replace the wrapped call's result", () => {
configureSyncSiteRecorder({ recordSlowSync(): void { throw new Error("recorder gone"); } });
expect(runSyncSiteWhenSlow("test.site", () => "value")).toBe("value");
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
});
it("runs the call bare when no recorder is configured", () => {
configureSyncSiteRecorder(undefined);
expect(runSyncSiteWhenSlow("test.site", () => "bare")).toBe("bare");
expect(slow).toEqual([]);
});
});
@@ -21,6 +21,7 @@ const expectedSites = new Map<string, string[]>([
["domain/codex-thread-id.ts", ["codex_thread_id.resolve_home"]],
["domain/review/gather.ts", ["review.gather.git"]],
["domain/tmux-option-defaults.ts", ["tmux_options.command_v"]],
["domain/queue-repository.ts", ["queue.decision_revision.replies", "queue.linked_successors.build", "queue.handoff"]],
]);
const pluginVendorUrl = "https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz";
@@ -290,6 +291,23 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
await recorder.close();
});
it("records a slow synchronous site only at or above the slow threshold, with its outcome", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
const dir = tempDir();
const logPath = path.join(dir, "slow-operations.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath, slowThresholdMs: 250 });
recorder.recordSlowSync("queue.decision_revision.replies", 249, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 250, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 400, "failed");
await recorder.flush();
const records = (await readRecords(logPath)).filter((record) => record.site === "queue.decision_revision.replies");
expect(records.map((record) => [record.phase, record.durationMs, record.outcome])).toEqual([["end", 250, "ok"], ["end", 400, "failed"]]);
await recorder.close();
});
// The request-timing MIDDLEWARE CONTRACT, hermetic + deterministic. The prior "real server" form
// ran the full daemon (event-loop monitor + a globalThis.fetch monkey-patch), which is structurally
// ambient-fragile under the concurrent-workspace gate: it intermittently returned 200 for an unmatched
@@ -18,13 +18,13 @@ const runFile = promisify(execFile);
const roots: string[] = [];
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
/** The file node spawns for each child started while `fn` runs. */
async function spawnedFiles(fn: () => Promise<void>): Promise<string[]> {
/** The executable and argv node spawns for each child started while `fn` runs. */
async function spawnedCommands(fn: () => Promise<void>): Promise<{ file: string; args: string[] }[]> {
const children: ChildProcess[] = [];
const onChild = (message: unknown) => { children.push((message as { process: ChildProcess }).process); };
diagnostics.subscribe("child_process", onChild);
try { await fn(); } finally { diagnostics.unsubscribe("child_process", onChild); }
return children.map((child) => child.spawnfile);
return children.map((child) => ({ file: child.spawnfile, args: child.spawnargs.slice(1) }));
}
async function scenario(mode: "shell" | "argv") {
@@ -43,12 +43,14 @@ async function scenario(mode: "shell" | "argv") {
await runFile("tmux", ["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 600"], { env: anchorEnv });
vi.stubEnv("TMUX", undefined);
vi.stubEnv("TMUX_TMPDIR", serverDir);
// Exercise the direct server-start path independently of an inherited systemd service environment.
vi.stubEnv("MANAGERPID", undefined);
let calls = 0;
const counted = <A,>(fn: (arg: A) => Promise<string>) => (arg: A) => { calls++; return fn(arg); };
const adapter = new TmuxAdapter(counted(execCommand), undefined, mode === "argv" ? counted(execArgvCommand) : undefined);
try {
let result: Record<string, unknown> = {};
const files = await spawnedFiles(async () => {
const commands = await spawnedCommands(async () => {
const created = await adapter.createSession("seat a", root);
const duplicate = await adapter.createSession("seat a", root);
// Wait for the fixture's /bin/sh login prompt. Input typed during system profile startup can be echoed before
@@ -93,7 +95,7 @@ async function scenario(mode: "shell" | "argv") {
result.noServerCause = probe.state === "transport_unavailable" ? probe.cause : null;
result.noServerSessions = await adapter.listSessions();
});
return { result, files, calls, root: fs.realpathSync(root) };
return { result, files: commands.map((command) => command.file), commands, calls, root: fs.realpathSync(root) };
} finally {
vi.unstubAllEnvs();
await runFile("tmux", ["kill-server"], { env: anchorEnv }).catch(() => {});
@@ -150,11 +152,26 @@ describe.skipIf(!nativeAvailable)("tmux shell and argv transports against a real
expect(comparable(argv.result)).toEqual(comparable(shell.result));
});
it("the shell transport starts /bin/sh for every call, the argv transport starts tmux by its path, or /bin/sh when PATH leaves it to the shell", () => {
it("the argv transport uses a limit-setting shell for creation and a direct tmux path for ordinary calls", () => {
const tmux = resolveOnPath("tmux", process.env.PATH);
if (tmux) expect(tmux).toMatch(/^\/.*\/tmux$/);
expect(shell.calls).toBeGreaterThan(15);
expect(shell.files).toEqual(Array(shell.calls).fill("/bin/sh"));
expect(argv.files).toEqual(Array(argv.calls).fill(tmux ?? "/bin/sh"));
expect(argv.commands).toHaveLength(argv.calls);
// Both createSession attempts issue start-server ; source-file for their private config.
// Only those clients' child shells raise their soft limit;
// the executable and original arguments follow the fixed script as positional parameters.
const creating = argv.commands.filter((command) => command.args.includes("start-server"));
expect(creating).toHaveLength(2);
for (const command of argv.commands) {
if (creating.includes(command)) {
expect(command.file).toBe("/bin/sh");
expect(command.args[0]).toBe("-c");
expect(command.args[1]).toContain('exec "$0" "$@"');
expect(command.args.slice(2, 6)).toEqual(["tmux", "start-server", ";", "source-file"]);
} else {
expect(command.file).toBe(tmux ?? "/bin/sh");
}
}
});
});
@@ -0,0 +1,86 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { TMUX_SERVER_LIMIT_PREFIX, TMUX_SERVER_LIMIT_WARNING, tmuxServerStartCommand } from "../src/tmux-server-start.js";
const shell = (script: string, args: string[] = []) => spawnSync("/bin/sh", ["-c", script, "nofile-test", ...args], { encoding: "utf8", timeout: 10_000 });
let tmux = false;
try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); tmux = true; } catch { /* Native prerequisite absent. */ }
describe.skipIf(process.platform === "win32")("tmux server creation limit", () => {
it("raises only the soft limit, capped by the hard limit and 8192", () => {
const result = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
expect(result.status).toBe(0);
const [soft, hard] = result.stdout.split(" ");
expect(Number(soft)).toBe(hard === "unlimited" ? 8192 : Math.min(Number(hard), 8192));
expect(result.stderr).toBe("");
});
it("does not lower a soft limit above the target", ({ skip }) => {
const result = shell('ulimit -S -n 16384 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'ulimit -S -n');
if (result.status === 77) skip(); // This host's hard limit cannot supply the fixture.
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("16384");
});
it("honours a lower hard limit without changing it", ({ skip }) => {
const result = shell('ulimit -S -n 256; ulimit -H -n 1024 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
if (result.status === 77) skip();
expect(result.status).toBe(0);
expect(result.stdout).toBe("1024 1024");
expect(result.stderr).toBe("");
});
it("continues exactly once with unchanged argv and limit when raising fails", async () => {
const command = tmuxServerStartCommand("/bin/sh", ["-c", 'printf "ran:%s:%s" "$(ulimit -S -n)" "$1"', "payload", "quoted ' $HOME ; new-session", "new-session"]);
// Supply a real low inherited soft limit and a shell builtin override that refuses only a raise.
const prelude = 'ulimit -S -n 256; ulimit() { case "$*" in "-H -n") printf "8192\\n";; "-S -n 8192") return 1;; *) command ulimit "$@";; esac; }; ';
// Execute the production script in that same shell so the failed-set premise reaches it.
const result = spawnSync("/bin/sh", ["-c", prelude + command.args[1], ...command.args.slice(2)], { encoding: "utf8", timeout: 10_000 });
expect(result.status).toBe(0);
expect(result.stdout).toBe("ran:256:quoted ' $HOME ; new-session");
expect(result.stderr.trim()).toBe(TMUX_SERVER_LIMIT_WARNING);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
vi.resetModules();
const { reportTmuxServerLimit } = await import("../src/tmux-server-start.js");
reportTmuxServerLimit(result.stderr);
reportTmuxServerLimit(result.stderr);
expect(warn).toHaveBeenCalledExactlyOnceWith(TMUX_SERVER_LIMIT_WARNING);
} finally { warn.mockRestore(); }
});
it("keeps ordinary capture/send commands direct", () => {
for (const args of [["capture-pane", "-p"], ["send-keys", "-l", "text"], ["-V"]]) {
expect(tmuxServerStartCommand("tmux", args)).toEqual({ file: "tmux", args });
}
});
it.skipIf(!tmux)("a fresh private server inherits the raised limit; an existing one is preserved", async () => {
const dir = mkdtempSync(join(tmpdir(), "or-nofile-"));
const socket = join(dir, "socket");
const limit = join(dir, "limit");
const query = (...args: string[]) => execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", timeout: 5000 });
try {
const start = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" -f /dev/null new-session -d -s owned /bin/sh -c \'ulimit -S -n > "$1"; sleep 30\' pane "$2"', [socket, limit]);
expect(start.status, start.stderr).toBe(0);
let actual = "";
for (let i = 0; i < 100; i++) {
try { actual = readFileSync(limit, "utf8").trim(); if (actual) break; } catch { /* Pane is starting. */ }
await new Promise(resolve => setTimeout(resolve, 10));
}
const hard = shell('ulimit -H -n').stdout.trim();
expect(Number(actual)).toBe(Math.min(hard === "unlimited" ? Infinity : Number(hard), 8192));
const pid = query("display-message", "-p", "#{pid}").trim();
const again = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" new-session -d -s another sleep 30', [socket]);
expect(again.status, again.stderr).toBe(0);
expect(query("display-message", "-p", "#{pid}").trim()).toBe(pid);
expect(query("list-sessions", "-F", "#{session_name}")).toContain("owned");
} finally {
try { query("kill-server"); } catch { /* Only this test's exact private socket. */ }
rmSync(dir, { recursive: true, force: true });
}
});
});