mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 13:09:35 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a52040b6b | ||
|
|
4270bb3c14 | ||
|
|
26a93a3c23 | ||
|
|
9e8f53d5b4 | ||
|
|
cb725a4ed0 | ||
|
|
8f48a8065c |
No files matched your search
+4
-3
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user