mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdaa8717bd | ||
|
|
b68742c3c2 | ||
|
|
80cfb7d01a | ||
|
|
f1a06c4f1b | ||
|
|
a7b8a27eb8 | ||
|
|
be2690179d | ||
|
|
a71ce16dd7 | ||
|
|
0834a16949 |
No files matched your search
@@ -75,20 +75,20 @@ in the rig's `attentionCount` and the TUI's attention totals.
|
||||
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
|
||||
the seat's activity next turns to running or idle, or when the whole restore completes
|
||||
after the seat already reads running or idle, the daemon checks the live seat: its tmux
|
||||
session is alive, and the pane runs the agent resuming the conversation the seat
|
||||
records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When it fails, the restore outcome
|
||||
stays and the seat still needs attention, which is true when the pane runs another
|
||||
conversation. A check that started before a newer restore of the seat doesn't settle
|
||||
the newer outcome.
|
||||
session is alive, and the agent in its pane was started to resume the conversation the
|
||||
seat records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When the check fails, the restore
|
||||
outcome stays and the seat still needs attention. The check uses launch evidence; it
|
||||
does not prove which conversation is open now.
|
||||
|
||||
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
|
||||
command says which check failed, and `--reason` doesn't bypass it. If
|
||||
the seat records the wrong conversation and you know the right one, set it with
|
||||
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again. After a restore of only some seats, or a community
|
||||
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
|
||||
outcome instead.
|
||||
command says which check failed, and `--reason` doesn't bypass it. If the seat records
|
||||
the wrong conversation and the agent in its pane was started with the right one, set it
|
||||
with `rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again; a token the running agent wasn't started with fails the same
|
||||
check. After a restore of only some seats, or a community harness's assisted startup,
|
||||
`--reason "<what you saw>"` can acknowledge the outcome instead, once any startup text
|
||||
left in the input box is submitted (not sent again) and the seat has responded.
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
|
||||
@@ -84,11 +84,23 @@ so its rate-limit hook is attributed to that seat too. This is appropriate when
|
||||
the helper uses the same login, since the quota belongs to the account. A
|
||||
helper on a different login is attributed the same way, so its rate limit can
|
||||
hold pending handoffs to every seat in that seat's Claude pool for one retry
|
||||
interval, even when the seat's own login still has room. This attribution does
|
||||
not bind the hook to Claude's own native session.
|
||||
interval, even when the seat's own login still has room. A reset time the
|
||||
helper's message states is not used: OpenRig keeps a stated reset only when the
|
||||
hook's Claude `session_id` is the seat's own resume token, so a helper never
|
||||
holds the pool past that one interval.
|
||||
|
||||
The event carries no usage percentage, account identity or reset timestamp.
|
||||
It marks the Claude provider pool as limited and preserves a current
|
||||
The event carries no usage percentage or account identity. When the seat's own
|
||||
Claude states when the limit resets (for example `resets Nov 10, 4pm
|
||||
(America/Sao_Paulo)`, `resets in 2h 30m`, or the older `usage limit
|
||||
reached|<epoch>`), the hook relay reads that time on the seat's machine and
|
||||
forwards only the resulting timestamp (`resetsAt`), never the message text. It
|
||||
reads the reset only after the typed `rate_limit` failure; the text never
|
||||
decides that a seat is limited. A reset it can't read completely (an unknown or
|
||||
truncated time zone, an unsupported unit, a date that doesn't exist such as Feb
|
||||
30) or that is in the past or more than eight days away gives no reset. The
|
||||
daemon applies the same eight-day bound to any sender.
|
||||
|
||||
The event marks the Claude provider pool as limited and preserves a current
|
||||
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
|
||||
count as a request for human input; a newer permission prompt still takes
|
||||
precedence in the seat's activity display. Conflicting seat windows retain the
|
||||
@@ -99,13 +111,15 @@ Pending work handled by the wake ladder uses the existing local Claude pool
|
||||
declare different Claude config directories. If reset windows conflict within
|
||||
a proposed shared pool, only the seats reporting exhaustion are held; another
|
||||
seat's pending handoffs can still retry. An exhausted status line window with
|
||||
a known reset supplies the timer. Otherwise a typed failure uses
|
||||
a known reset supplies the timer, and so does a reset the seat's own typed
|
||||
failure stated (`provider-reset`). Otherwise a typed failure uses
|
||||
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
|
||||
existing wake jitter, and records `config-fallback`. That is a retry time, not a
|
||||
claim that the provider's quota will reset then. The timer releases the shared
|
||||
blocker once and retains the existing durable wake delivery behavior.
|
||||
|
||||
Authentication, billing and overloaded errors are not classified as usage
|
||||
limits. Claude limit banners are not scraped, and older runtimes or failures
|
||||
that do not emit this hook still depend on status line data. Without either
|
||||
limits. Claude limit banners are not scraped from the terminal, and older
|
||||
runtimes or failures that do not emit this hook still depend on status line
|
||||
data. Without either
|
||||
source, provider usage remains unknown.
|
||||
@@ -898,7 +898,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| --- | --- |
|
||||
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
|
||||
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | After a full restore of a Claude or Codex seat, the daemon clears this by itself once the seat reads running or idle and passes its check. If it still counts, check its `restoreOutcome` in `rig ps --nodes --json` (the RESTORE column): when it is `attention_required`, run `rig seat clear-attention <seat>`, which says which check failed; answering the prompt again or running `rig seat continue` doesn't change it. For a partial restore, submit any startup text left in the input box without sending it again, and wait for the agent to respond before clearing attention. See [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
|
||||
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
|
||||
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
|
||||
|
||||
@@ -1493,9 +1493,10 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -144,10 +144,134 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
...promptBoundaryFields(hookEvent, providerPayload),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
...limitResetField(runtime, hookEvent, subtype, providerPayload, now),
|
||||
...conversationFields(providerPayload, env),
|
||||
};
|
||||
}
|
||||
|
||||
// #99: a Claude usage limit says when it resets only in its rendered text ("You've hit your weekly limit ·
|
||||
// resets Nov 10, 4pm (America/Sao_Paulo)"). The typed error still decides THAT the seat is limited; the text
|
||||
// is read here, on the seat's machine and in its time zone, only for WHEN, and only the instant leaves the
|
||||
// relay: never the text itself.
|
||||
function limitResetField(runtime, hookEvent, subtype, providerPayload, now) {
|
||||
if (runtime !== "claude-code" || hookEvent !== "StopFailure" || subtype !== "rate_limit") return {};
|
||||
for (const value of [providerPayload.last_assistant_message, providerPayload.error_details]) {
|
||||
if (typeof value !== "string") continue;
|
||||
const resetsAt = claudeLimitResetAt(value.slice(0, 2000), now());
|
||||
if (resetsAt) return { resetsAt };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
||||
const RESET_EPOCH_RE = /usage limit reached\|(\d{10})\b/i;
|
||||
// Each unit must be a whole word ("2 months" is not "2 m"), and a "resets in" fragment is read only this way.
|
||||
const RESET_IN_LEAD_RE = /\bresets?\s+in\b/i;
|
||||
const RESET_IN_RE = /\bresets?\s+in\s+(?:(\d+)\s*(?:d|days?)\b\s*)?(?:(\d+)\s*(?:h|hrs?|hours?)\b\s*)?(?:(\d+)\s*(?:m|mins?|minutes?)\b)?/i;
|
||||
// A zone, when the text opens one, must be closed: "4pm (Not/AZone" is truncated, not local time.
|
||||
const RESET_AT_RE = /\bresets?\s+(?:at\s+|on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*(\()([^)\n]{0,64})(\))?)?/i;
|
||||
|
||||
/** The reset instant a Claude usage-limit message states, as an ISO string, or null when it states none
|
||||
* this can read. A wall-clock time without a zone is the seat's local time, which is what Claude renders. */
|
||||
function claudeLimitResetAt(text, nowDate) {
|
||||
const nowMs = nowDate.getTime();
|
||||
if (!Number.isFinite(nowMs) || typeof text !== "string") return null;
|
||||
let resetMs = null;
|
||||
const epoch = RESET_EPOCH_RE.exec(text);
|
||||
if (epoch) {
|
||||
resetMs = Number(epoch[1]) * 1000;
|
||||
} else if (RESET_IN_LEAD_RE.test(text)) {
|
||||
const relative = RESET_IN_RE.exec(text);
|
||||
// The whole fragment must be read: nothing numeric may follow what was, across a separator either
|
||||
// ("in 2h 30x", "in 2h, 30x" and "in 2h and 30x" are not two hours).
|
||||
if (!relative || !(relative[1] || relative[2] || relative[3])
|
||||
|| /^(?:\s|[,;&+]|and\b)*\d/i.test(text.slice(relative.index + relative[0].length))) return null;
|
||||
resetMs = nowMs + ((Number(relative[1] || 0) * 24 + Number(relative[2] || 0)) * 60 + Number(relative[3] || 0)) * 60_000;
|
||||
} else {
|
||||
const wall = RESET_AT_RE.exec(text);
|
||||
if (wall) resetMs = wallClockReset(wall, nowMs);
|
||||
}
|
||||
if (resetMs === null || !Number.isFinite(resetMs) || resetMs <= nowMs || resetMs > nowMs + RESET_MAX_MS) return null;
|
||||
return new Date(resetMs).toISOString();
|
||||
}
|
||||
|
||||
function wallClockReset(match, nowMs) {
|
||||
const [, monthName, day, hourText, minuteText, meridiem, zoneOpen, zoneText, zoneClose] = match;
|
||||
if (zoneOpen && (!zoneClose || !zoneText.trim())) return null; // a truncated or empty zone: no reset
|
||||
const hour12 = Number(hourText);
|
||||
const minute = Number(minuteText || 0);
|
||||
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
|
||||
const hour = (hour12 % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
|
||||
const zone = zoneText ? zoneText.trim() : null;
|
||||
let toUtc;
|
||||
try {
|
||||
toUtc = zone ? zonedClock(zone) : localClock();
|
||||
} catch {
|
||||
return null; // a zone this runtime doesn't know: no reset rather than a guessed one
|
||||
}
|
||||
const today = toUtc.partsAt(nowMs);
|
||||
if (monthName) {
|
||||
const month = MONTHS.indexOf(monthName.toLowerCase());
|
||||
if (month < 0) return null;
|
||||
for (const year of [today.year, today.year + 1]) {
|
||||
if (!isCalendarDay(year, month, Number(day))) continue; // "Feb 30" names no day; never roll it into March
|
||||
const at = toUtc.instant(year, month, Number(day), hour, minute);
|
||||
if (Number.isNaN(at)) return null;
|
||||
if (at > nowMs) return at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const at = toUtc.instant(today.year, today.month, today.day, hour, minute);
|
||||
if (Number.isNaN(at)) return null; // ambiguous today: never advance a day on a guess
|
||||
return at > nowMs ? at : toUtc.instant(today.year, today.month, today.day + 1, hour, minute);
|
||||
}
|
||||
|
||||
function isCalendarDay(year, month, day) {
|
||||
return Number.isInteger(day) && day >= 1 && day <= new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function localClock() {
|
||||
return wallClock(
|
||||
(ms) => { const d = new Date(ms); return { year: d.getFullYear(), month: d.getMonth(), day: d.getDate(), hour: d.getHours(), minute: d.getMinutes() }; },
|
||||
(ms) => -new Date(ms).getTimezoneOffset() * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
function zonedClock(timeZone) {
|
||||
const format = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric",
|
||||
day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
|
||||
const partsAt = (ms) => {
|
||||
const parts = Object.fromEntries(format.formatToParts(new Date(ms)).map((part) => [part.type, Number(part.value)]));
|
||||
return { year: parts.year, month: parts.month - 1, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second };
|
||||
};
|
||||
const offsetAt = (ms) => {
|
||||
const p = partsAt(ms);
|
||||
return Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
|
||||
};
|
||||
return wallClock(partsAt, offsetAt);
|
||||
}
|
||||
|
||||
/** A clock whose instant() is the one moment a wall time names, or NaN when it names none (skipped by a DST jump) or
|
||||
* two (repeated by a DST fall-back): the message doesn't say which, so no reset rather than a guess a day off. */
|
||||
function wallClock(partsAt, offsetAt) {
|
||||
return {
|
||||
partsAt,
|
||||
instant: (year, month, day, hour, minute) => {
|
||||
const asUtc = Date.UTC(year, month, day, hour, minute);
|
||||
const want = new Date(asUtc); // normalizes a day past the month's end (tomorrow)
|
||||
const matches = new Set();
|
||||
for (const probe of [asUtc - 14 * 3600_000, asUtc, asUtc + 14 * 3600_000]) {
|
||||
const candidate = asUtc - offsetAt(probe);
|
||||
const p = partsAt(candidate);
|
||||
if (p.year === want.getUTCFullYear() && p.month === want.getUTCMonth() && p.day === want.getUTCDate()
|
||||
&& p.hour === want.getUTCHours() && p.minute === want.getUTCMinutes()) matches.add(candidate);
|
||||
}
|
||||
return matches.size === 1 ? [...matches][0] : NaN;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OPR.0.4.3.28 B1+B3 — resolve the ingest base URL + token without depending on
|
||||
// the operator seeding OPENRIG_URL/OPENRIG_ACTIVITY_HOOK_TOKEN into the shell:
|
||||
// 1. env OPENRIG_URL / token (unchanged fast path).
|
||||
@@ -390,6 +514,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildOpenRigPayload,
|
||||
claudeLimitResetAt,
|
||||
claimFirstLaunchHook,
|
||||
buildSessionIdentityPayload,
|
||||
parseJson,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import stringWidth from "string-width";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -71,14 +72,306 @@ export function preparePiAccount(agentDir: string, accountHome: string | undefin
|
||||
// Use Node's line editor in raw mode, with only paste framing handled here.
|
||||
export const MAX_PI_INPUT_BYTES = 1024 * 1024;
|
||||
|
||||
// ── The compose region's physical layout ──────────────────────────────────────────
|
||||
// One layout model for painting, erasing and the caret: cells fill terminal rows
|
||||
// greedily, a wide character that no longer fits wraps whole to the next row, and
|
||||
// an explicit newline starts one. Every row the compositor paints or erases, and
|
||||
// the caret it places, derives from here — measured with the daemon's string-width.
|
||||
|
||||
/** Where a compose region sits on screen, and where its caret lands. */
|
||||
export interface ComposeLayout { rows: number; caretRow: number; caretColumn: number; }
|
||||
|
||||
const SEGMENTER = (() => {
|
||||
try {
|
||||
return new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
/** Grapheme clusters: what a terminal lays out. A ZWJ family emoji is one cluster
|
||||
* taking two cells where its code points would sum to six; a combining mark adds none. */
|
||||
export function* graphemesOf(text: string): Generator<string> {
|
||||
if (SEGMENTER) {
|
||||
for (const part of SEGMENTER.segment(text)) yield part.segment;
|
||||
} else {
|
||||
for (const character of text) yield character;
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 8;
|
||||
|
||||
export function composeLayout(text: string, width: number, caret = text.length): ComposeLayout {
|
||||
if (!text) return { rows: 0, caretRow: 0, caretColumn: 0 };
|
||||
if (width <= 0) return { rows: text.split("\n").length, caretRow: 0, caretColumn: 0 };
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
let caretRow = 0;
|
||||
let caretColumn = 0;
|
||||
let index = 0;
|
||||
const place = () => { caretRow = row; caretColumn = column; };
|
||||
for (const segment of graphemesOf(text)) {
|
||||
if (index === caret) place();
|
||||
if (segment === "\n") {
|
||||
row += 1;
|
||||
column = 0;
|
||||
} else if (segment === "\t") {
|
||||
// A tab advances to the next ordinary tab stop, clamped to the last column.
|
||||
column = Math.min((Math.floor(column / TAB_WIDTH) + 1) * TAB_WIDTH, width - 1);
|
||||
} else {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (column + cells > width) {
|
||||
row += 1;
|
||||
column = 0;
|
||||
}
|
||||
column += cells;
|
||||
}
|
||||
index += segment.length;
|
||||
}
|
||||
if (index === caret) place();
|
||||
return { rows: row + 1, caretRow, caretColumn };
|
||||
}
|
||||
|
||||
// ── Pane live region (compose + status) ────────────────────────────────────────
|
||||
// A seat pane is a plain terminal: one bottom live region holds the operator's draft
|
||||
// and the runner's status line, and is erased and repainted around every transcript
|
||||
// write. Nothing else may write at the cursor, or a draft freezes into scrollback and
|
||||
// streamed text fuses with it mid-word. When the terminal's width is unknown the
|
||||
// compositor places nothing — the pane falls back to plain output, and the draft,
|
||||
// though still editable, is not drawn.
|
||||
|
||||
/** The live region a pane host repaints around transcript writes. */
|
||||
export interface PaneCompositor {
|
||||
/** Bind the line editor the compose line renders from, and where its cursor sits. */
|
||||
attach(read: () => string, redraw: () => void, caretUp?: () => number, readCursor?: () => number): void;
|
||||
/** Unbind the editor; any held streamed tail is flushed and the live region is dropped. */
|
||||
detach(): void;
|
||||
/** A keystroke changed the draft: repaint the live region from readline's state. A
|
||||
* draft that just went away flushes a held streamed tail first, as plain output would. */
|
||||
refresh(): void;
|
||||
/** Streamed model text: verbatim while no live region exists; once a draft or a
|
||||
* status line is on the bottom, complete lines go above the region and an
|
||||
* unfinished line is held, so no live line is split or frozen into scrollback. */
|
||||
transcript(text: string): void;
|
||||
/** One complete line of transcript. */
|
||||
line(text: string): void;
|
||||
/** A keypress is about to echo: close an open transcript line first, or the echo
|
||||
* and the next streamed deltas would land on top of it. */
|
||||
beforeInput(): void;
|
||||
/** Pin the latest diagnostic on the status line. The transcript copy is the caller's;
|
||||
* this pin is never the only copy. */
|
||||
diagnostic(text: string): void;
|
||||
/** Drop a pinned status line. */
|
||||
clearStatus(): void;
|
||||
/** Before the submit commits: drop the draft line so no partial copy scrolls, and
|
||||
* finish any held streamed line; the durable copy is the commit line below. */
|
||||
beforeSubmit(): void;
|
||||
/** Repaint the live region in place. */
|
||||
repaint(): void;
|
||||
}
|
||||
|
||||
export function createPaneCompositor(out: NodeJS.WriteStream): PaneCompositor {
|
||||
let status: string | null = null;
|
||||
/** The status text actually on screen: clipped when it was painted. A resize must
|
||||
* size the region from this, not from the longer text it was pinned with. */
|
||||
let paintedStatus: string | null = null;
|
||||
let readInput: (() => string) | null = null;
|
||||
let redrawLine: (() => void) | null = null;
|
||||
let held = "";
|
||||
let openLine = false;
|
||||
let screenRows = 0;
|
||||
let readCaretUp: (() => number) | null = null;
|
||||
let readCaretIndex: (() => number) | null = null;
|
||||
const widthKnown = () => typeof out.columns === "number" && out.columns > 0;
|
||||
const composeText = () => (readInput && redrawLine ? readInput() : "");
|
||||
const composeRows = () => {
|
||||
const text = composeText();
|
||||
return text && widthKnown() ? composeLayout(text, out.columns).rows : 0;
|
||||
};
|
||||
const clip = (text: string) => {
|
||||
const flat = text.replace(/[\r\n\t]+/g, " ").trimEnd();
|
||||
if (!widthKnown() || stringWidth(flat) < out.columns) return flat;
|
||||
let used = 0;
|
||||
let clipped = "";
|
||||
for (const segment of graphemesOf(flat)) {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (used + cells > out.columns - 1) break;
|
||||
clipped += segment;
|
||||
used += cells;
|
||||
}
|
||||
return `${clipped}\u2026`;
|
||||
};
|
||||
const eraseLines = (lines: number) => {
|
||||
let sequence = "";
|
||||
for (let index = 0; index < lines; index += 1) sequence += index === 0 ? "\r\u001b[2K" : "\u001b[1A\r\u001b[2K";
|
||||
if (sequence) out.write(sequence);
|
||||
};
|
||||
/** Rows this compositor has on screen, recorded when it painted them. Readline never writes
|
||||
* to the terminal, so nothing else can change these rows behind the compositor's back —
|
||||
* except a reflow, which the resize handler owns. */
|
||||
const paint = () => {
|
||||
paintedStatus = status !== null ? clip(status) : null;
|
||||
if (paintedStatus !== null) {
|
||||
out.write(paintedStatus);
|
||||
out.write("\n");
|
||||
}
|
||||
const rows = composeRows();
|
||||
if (rows) redrawLine?.();
|
||||
screenRows = (status !== null ? 1 : 0) + (rows || (status !== null ? 1 : 0));
|
||||
// Only a painted region closes an open transcript line. With nothing painted the
|
||||
// cursor still sits on the line a flush left open, and the next keystroke belongs
|
||||
// to that line's context, not to a fresh one.
|
||||
if (screenRows > 0) openLine = false;
|
||||
};
|
||||
const eraseScreen = () => {
|
||||
// Nothing painted: there is no region to erase, and stepping down from a stale
|
||||
// caret count would move the cursor off the transcript line it is still on.
|
||||
if (screenRows === 0) return;
|
||||
const down = readCaretUp ? readCaretUp() : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
eraseLines(screenRows);
|
||||
screenRows = 0;
|
||||
};
|
||||
const flushHeld = () => {
|
||||
if (!held) return;
|
||||
out.write(held.endsWith("\n") ? held : `${held}\n`);
|
||||
held = "";
|
||||
openLine = false;
|
||||
};
|
||||
const writeVerbatim = (text: string) => {
|
||||
out.write(text);
|
||||
openLine = !text.endsWith("\n");
|
||||
};
|
||||
const writeAbove = (text: string) => {
|
||||
eraseScreen();
|
||||
out.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
paint();
|
||||
};
|
||||
// A tile attaching or detaching reflows the pane, and the terminal keeps the cursor on
|
||||
// its character. So the old paint now occupies exactly what the new width says it does:
|
||||
// the pinned status wraps to ceil(cells/columns) rows, the draft to its layout rows.
|
||||
// Move down to the region's last row — past the lines below the caret — erase exactly
|
||||
// that many rows, and repaint at the new width. No max(): under-erasing leaves a stale
|
||||
// copy of a wrapped status, over-erasing drops a transcript line off the screen.
|
||||
const onResize = () => {
|
||||
if (!widthKnown()) {
|
||||
screenRows = 0;
|
||||
return;
|
||||
}
|
||||
const draft = composeText();
|
||||
const layout = draft ? composeLayout(draft, out.columns, readCaretIndex ? readCaretIndex() : draft.length) : null;
|
||||
const draftRows = layout ? layout.rows : 0;
|
||||
const statusLines = paintedStatus !== null ? Math.max(1, Math.ceil(stringWidth(paintedStatus) / out.columns)) : 0;
|
||||
const down = layout ? layout.rows - 1 - layout.caretRow : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
// A pinned status without a draft also owns the blank row its trailing newline
|
||||
// left the cursor on — the same row paint() counts into screenRows.
|
||||
eraseLines(statusLines + (draftRows || (status !== null ? 1 : 0)));
|
||||
screenRows = 0;
|
||||
paint();
|
||||
};
|
||||
out.on("resize", onResize);
|
||||
const pane: PaneCompositor = {
|
||||
attach(read, redraw, caretUp, readCursor) {
|
||||
readInput = read;
|
||||
redrawLine = redraw;
|
||||
readCaretUp = caretUp ?? null;
|
||||
readCaretIndex = readCursor ?? null;
|
||||
},
|
||||
detach() {
|
||||
eraseScreen();
|
||||
flushHeld();
|
||||
readInput = null;
|
||||
redrawLine = null;
|
||||
readCaretUp = null;
|
||||
readCaretIndex = null;
|
||||
openLine = false;
|
||||
screenRows = 0;
|
||||
},
|
||||
refresh() {
|
||||
eraseScreen();
|
||||
// With no region left, an unfinished streamed line goes out verbatim so the
|
||||
// next delta continues it on the same row instead of splitting mid-word.
|
||||
if (held && status === null && !composeText()) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
paint();
|
||||
},
|
||||
transcript(text) {
|
||||
if (!text) return;
|
||||
if (status === null && composeRows() === 0 && screenRows === 0) {
|
||||
if (held) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
writeVerbatim(text);
|
||||
return;
|
||||
}
|
||||
held += text;
|
||||
let newline = held.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = held.slice(0, newline + 1);
|
||||
held = held.slice(newline + 1);
|
||||
writeAbove(line);
|
||||
newline = held.indexOf("\n");
|
||||
}
|
||||
},
|
||||
line(text) {
|
||||
pane.transcript(`${text}\n`);
|
||||
},
|
||||
beforeInput() {
|
||||
if (!openLine) return;
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
},
|
||||
diagnostic(text) {
|
||||
// Without a known width there is no region to pin into. Callers already wrote
|
||||
// the transcript copy; printing here would duplicate it.
|
||||
if (!widthKnown()) return;
|
||||
if (openLine) {
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
}
|
||||
eraseScreen();
|
||||
status = clip(text);
|
||||
paint();
|
||||
},
|
||||
clearStatus() {
|
||||
if (status === null) return;
|
||||
eraseScreen();
|
||||
status = null;
|
||||
paint();
|
||||
},
|
||||
beforeSubmit() {
|
||||
eraseScreen();
|
||||
openLine = false;
|
||||
flushHeld();
|
||||
},
|
||||
repaint() {
|
||||
paint();
|
||||
},
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
|
||||
export function createRunnerInput(
|
||||
input: NodeJS.ReadStream,
|
||||
output: NodeJS.WriteStream,
|
||||
onSubmit: (block: string) => void,
|
||||
pane?: PaneCompositor,
|
||||
): readline.Interface {
|
||||
const reject = () => output.write(
|
||||
"[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.\n",
|
||||
);
|
||||
const say = (text: string) => {
|
||||
if (pane) {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
} else {
|
||||
output.write(`${text}\n`);
|
||||
}
|
||||
};
|
||||
const reject = () => {
|
||||
say("[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.");
|
||||
};
|
||||
const submit = (block: string) => {
|
||||
if (Buffer.byteLength(block) > MAX_PI_INPUT_BYTES) reject();
|
||||
else if (block.trim()) onSubmit(block);
|
||||
@@ -94,14 +387,37 @@ export function createRunnerInput(
|
||||
|| (major === 25 && (minor === 0 || (minor === 1 && patch === 0)));
|
||||
const keys = new PassThrough();
|
||||
const editorInput = batchText ? new PassThrough() : keys;
|
||||
// Readline edits against a discard sink: it must never draw on the terminal, because
|
||||
// _refreshLine does moveCursor(0, -prevRows) + clearScreenDown(), which erases whatever the
|
||||
// compositor put above the draft. The compositor owns every terminal byte instead.
|
||||
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
// Readline wraps its edit against output.columns; carry the terminal's geometry onto the sink.
|
||||
Object.assign(sink, { columns: output.columns, rows: output.rows });
|
||||
let caretUp = 0;
|
||||
const renderCompose = () => {
|
||||
const text = `${editor.line ?? ""}`;
|
||||
if (!text) return;
|
||||
const width = typeof output.columns === "number" && output.columns > 0 ? output.columns : 0;
|
||||
output.write(text);
|
||||
if (!width) return;
|
||||
const cursor = Math.max(0, Math.min(Number(editor.cursor) || 0, text.length));
|
||||
const layout = composeLayout(text, width, cursor);
|
||||
caretUp = layout.rows - 1 - layout.caretRow;
|
||||
if (caretUp > 0) output.write(`\u001b[${caretUp}A`);
|
||||
output.write("\r");
|
||||
if (layout.caretColumn > 0) output.write(`\u001b[${layout.caretColumn}C`);
|
||||
};
|
||||
pane?.attach(() => `${editor.line ?? ""}`, renderCompose, () => caretUp, () => Math.max(0, Math.min(Number(editor.cursor) || 0, `${editor.line ?? ""}`.length)));
|
||||
|
||||
const editor = readline.createInterface({
|
||||
input: editorInput, output, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
input: editorInput, output: sink, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
});
|
||||
// Node forces dumb-terminal mode without editing if TERM=dumb. When terminal: true
|
||||
// was requested, remove the dumb-mode override so VT100 line editing is preserved.
|
||||
if (Object.prototype.hasOwnProperty.call(editor, "_ttyWrite")) {
|
||||
delete (editor as any)._ttyWrite;
|
||||
}
|
||||
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let pending = "";
|
||||
let paste: string | null = null;
|
||||
@@ -111,7 +427,7 @@ export function createRunnerInput(
|
||||
// Node documents changing rl.line together with rl.cursor. The installed
|
||||
// typings mark them readonly, so assign this pair through one explicit seam.
|
||||
Object.assign(editor, { line, cursor });
|
||||
editor.prompt(true);
|
||||
pane?.refresh();
|
||||
};
|
||||
const clear = () => setLine("", 0);
|
||||
// Node 22.21–22.22.0 (also some 24/25 releases) appends batched text after
|
||||
@@ -137,6 +453,7 @@ export function createRunnerInput(
|
||||
}
|
||||
const consume = (text: string) => {
|
||||
if (paste === null) {
|
||||
pane?.beforeInput();
|
||||
keys.write(text);
|
||||
if (batchText) flushTyped();
|
||||
} else if (!discarded) {
|
||||
@@ -170,7 +487,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
discarded = false;
|
||||
clear();
|
||||
output.write("\n[pi-runner] input cleared\n");
|
||||
say("[pi-runner] input cleared");
|
||||
onSubmit("/abort");
|
||||
} else if (paste === null) {
|
||||
paste = "";
|
||||
@@ -189,6 +506,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
}
|
||||
}
|
||||
pane?.refresh();
|
||||
};
|
||||
const wasRaw = input.isRaw;
|
||||
const end = () => editor.close();
|
||||
@@ -196,7 +514,10 @@ export function createRunnerInput(
|
||||
output.write("\u001b[?2004h");
|
||||
input.on("data", receive);
|
||||
input.once("end", end);
|
||||
editor.on("line", submit);
|
||||
editor.on("line", (line) => {
|
||||
pane?.beforeSubmit();
|
||||
submit(line);
|
||||
});
|
||||
editor.once("close", () => {
|
||||
input.removeListener("data", receive);
|
||||
input.removeListener("end", end);
|
||||
@@ -204,6 +525,7 @@ export function createRunnerInput(
|
||||
input.pause();
|
||||
keys.removeListener("keypress", keypress);
|
||||
keys.destroy();
|
||||
pane?.detach();
|
||||
if (editorInput !== keys) editorInput.destroy();
|
||||
output.write("\u001b[?2004l");
|
||||
});
|
||||
@@ -359,6 +681,11 @@ export interface RunnerIo {
|
||||
mirrorLine(line: string): void;
|
||||
/** Append raw text to the current pane line (streamed deltas). */
|
||||
mirrorAppend(text: string): void;
|
||||
/** Pin a copy of a diagnostic on the pane's status line, when the pane provides one.
|
||||
* The transcript copy is written by the caller; this pin is never the only copy. */
|
||||
diagnostic?(text: string): void;
|
||||
/** Drop a pinned status line at the start of a new agent turn. */
|
||||
clearStatus?(): void;
|
||||
/** POST to the daemon activity endpoint. May resolve to the parsed JSON
|
||||
* response (null on transport failure) so identity delivery can retry. */
|
||||
postActivity(payload: Record<string, unknown>): void | Promise<Record<string, unknown> | null>;
|
||||
@@ -592,7 +919,7 @@ export class RunnerCore {
|
||||
|
||||
/** A frame that can't be reassembled is lost, and said so. If it was a get_entries reply, the cursor stays where it was. */
|
||||
private reportDroppedFrame(reason: string | null | undefined): void {
|
||||
if (reason) this.io.mirrorLine(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
if (reason) this.diag(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
}
|
||||
|
||||
private get runtime(): string { return this.opts.runtime ?? "pi"; }
|
||||
@@ -601,7 +928,7 @@ export class RunnerCore {
|
||||
handleUserBlock(block: string): void {
|
||||
if (block === "/abort") {
|
||||
this.io.sendRpc({ type: "abort" });
|
||||
this.io.mirrorLine("[pi-runner] abort sent");
|
||||
this.diag("[pi-runner] abort sent");
|
||||
return;
|
||||
}
|
||||
if (block.startsWith("/followup ")) {
|
||||
@@ -646,7 +973,7 @@ export class RunnerCore {
|
||||
} else {
|
||||
// Advertised but refused: v1 as before, so a reply over 1 MiB still fails, visibly.
|
||||
const detail = typeof record.error === "string" ? record.error : "refused";
|
||||
this.io.mirrorLine(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
this.diag(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -656,7 +983,7 @@ export class RunnerCore {
|
||||
if (record.id === GET_STATE_ID) {
|
||||
this.ready = false;
|
||||
this.writeSidecar({});
|
||||
this.io.mirrorLine(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
this.diag(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
// OMP cannot provide a resume token without get_state; stop it so the
|
||||
// launch fails now instead of waiting out readiness.
|
||||
if (this.runtime === "omp") this.io.stopChild?.();
|
||||
@@ -665,11 +992,11 @@ export class RunnerCore {
|
||||
if (record.id === CATCH_UP_ID && this.runtime === "pi") {
|
||||
// A refused catch-up (a saved cursor this session's history doesn't hold, such as an older runner's UI-event
|
||||
// id) is history sync, not the seat's resume: say so, and take a confirmed cursor from the history's tail.
|
||||
this.io.mirrorLine(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.diag(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.io.sendRpc({ type: "get_entries", id: CURSOR_REFRESH_ID });
|
||||
return;
|
||||
}
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
// Only a rejected input means the seat cannot do the requested work.
|
||||
// A mistimed /abort (or any control command) leaves the seat idle.
|
||||
if (this.runtime === "omp" && typeof record.command === "string" && Object.hasOwn(INPUT_COMMANDS, record.command)) {
|
||||
@@ -687,13 +1014,13 @@ export class RunnerCore {
|
||||
this.ready = this.runtime === "pi" || !!this.sessionFile;
|
||||
if (!this.ready) {
|
||||
if (this.runtime === "omp") {
|
||||
this.io.mirrorLine("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.diag("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.io.stopChild?.();
|
||||
return;
|
||||
}
|
||||
// For custom protocol: pi-rpc community harnesses, reporting no session file
|
||||
// reads as needing attention rather than stopping the child process.
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.raiseAttention("runtime_error");
|
||||
return;
|
||||
}
|
||||
@@ -856,6 +1183,13 @@ export class RunnerCore {
|
||||
this.attentionIsLatest = false;
|
||||
}
|
||||
|
||||
/** A diagnostic stays in the transcript — the readiness probe and the operator both read
|
||||
* it there; a pane with a status line pins the latest one as a convenience copy. */
|
||||
private diag(text: string): void {
|
||||
this.io.mirrorLine(text);
|
||||
this.io.diagnostic?.(text);
|
||||
}
|
||||
|
||||
private handleEvent(event: Record<string, unknown>): void {
|
||||
if (event.type === "extension_ui_request") {
|
||||
const method = event.method;
|
||||
@@ -863,7 +1197,7 @@ export class RunnerCore {
|
||||
const detail = [event.title, event.message].filter((text): text is string => typeof text === "string")
|
||||
.map(stripVTControlCharacters).join(" — ").replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 400);
|
||||
if (this.runtime !== "omp" && !dialog) {
|
||||
if (method === "notify") this.io.mirrorLine(`[${this.runtime}-runner] ${detail}`);
|
||||
if (method === "notify") this.diag(`[${this.runtime}-runner] ${detail}`);
|
||||
return; // Pi notifications and other fire-and-forget UI requests expect no answer.
|
||||
}
|
||||
// RPC does not mount an interactive UI. Reply at once; otherwise the
|
||||
@@ -872,7 +1206,7 @@ export class RunnerCore {
|
||||
this.io.sendRpc({ type: "extension_ui_response", id: event.id, cancelled: true });
|
||||
}
|
||||
if (dialog) {
|
||||
this.io.mirrorLine(this.runtime !== "omp"
|
||||
this.diag(this.runtime !== "omp"
|
||||
? `[${this.runtime}-runner] Extension ${method}: ${detail}; cancelled because the managed RPC pane cannot answer it. Operator action required: review the cancelled request and re-send an instruction the extension can complete without a dialog.`
|
||||
: "[omp-runner] Approval/input requested; cancelled because the managed RPC pane cannot answer it. Operator action required: review the denied action and re-send a safe instruction, or change the seat permission policy explicitly.");
|
||||
this.raiseAttention("permission_prompt");
|
||||
@@ -892,7 +1226,10 @@ export class RunnerCore {
|
||||
this.writeSidecar({});
|
||||
}
|
||||
|
||||
if (event.type === "agent_start") this.clearAttention();
|
||||
if (event.type === "agent_start") {
|
||||
this.clearAttention();
|
||||
this.io.clearStatus?.();
|
||||
}
|
||||
if (this.runtime === "omp") {
|
||||
if (event.type === "agent_start" || event.type === "turn_start"
|
||||
|| (event.type === "message_start" && message?.role === "assistant")
|
||||
@@ -936,7 +1273,7 @@ export class RunnerCore {
|
||||
// the repeat lacks it, then reset at the next assistant message/agent
|
||||
// turn, not at agent_end.
|
||||
if (event.type === "message_end" || !this.assistantErrorShown) {
|
||||
this.io.mirrorLine(mapped.errorNotice);
|
||||
this.diag(mapped.errorNotice);
|
||||
}
|
||||
this.assistantErrorShown = true;
|
||||
// Only typed, explicitly transient HTTP failures may release the send
|
||||
@@ -1445,6 +1782,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
// The child may close its pipe before the first RPC write completes.
|
||||
child.stdin.on("error", () => { /* spawn/exit handlers own the diagnosis */ });
|
||||
|
||||
const pane = createPaneCompositor(process.stdout);
|
||||
// Diagnostics stay in the transcript — the pane's readiness probe greps it, and people
|
||||
// and agents read the history there; the status line only pins the latest copy.
|
||||
const paneDiag = (text: string) => {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
};
|
||||
|
||||
const chargeLookup = new OpenRouterChargeLookup({
|
||||
fetchGeneration: openRouterGenerationFetcher(() => childEnv["OPENROUTER_API_KEY"]),
|
||||
postCharges: async (piSessionId, charges) => {
|
||||
@@ -1463,7 +1808,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
}, () => false as const).finally(() => clearTimeout(timeout));
|
||||
},
|
||||
schedule: (fn, ms) => { setTimeout(fn, ms).unref(); },
|
||||
notice: (line) => { process.stdout.write(`${line}\n`); },
|
||||
notice: (line) => { paneDiag(line); },
|
||||
});
|
||||
|
||||
// One activity post at a time: the daemon reads its newest record by arrival, so a beat still in flight when a turn
|
||||
@@ -1486,8 +1831,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
sendRpc: (cmd) => {
|
||||
try { child.stdin.write(`${JSON.stringify(cmd)}\n`); } catch { /* exit handler reports */ }
|
||||
},
|
||||
mirrorLine: (line) => process.stdout.write(`${line}\n`),
|
||||
mirrorAppend: (text) => process.stdout.write(text),
|
||||
mirrorLine: (line) => pane.line(line),
|
||||
mirrorAppend: (text) => pane.transcript(text),
|
||||
diagnostic: (text) => pane.diagnostic(text),
|
||||
clearStatus: () => pane.clearStatus(),
|
||||
postActivity: (payload) => {
|
||||
if (!endpoint || typeof fetch !== "function") return;
|
||||
const result = postInOrder(payload).catch(() => null);
|
||||
@@ -1568,7 +1915,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
let reportedPiCapability = false;
|
||||
readline.createInterface({ input: child.stderr }).on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
process.stdout.write(`[${runtime}:err] ${line}\n`);
|
||||
paneDiag(`[${runtime}:err] ${line}`);
|
||||
stderrTail.push(line.length > OMP_STDERR_TAIL_CHARS ? `${line.slice(0, OMP_STDERR_TAIL_CHARS)}...` : line);
|
||||
if (stderrTail.length > OMP_STDERR_TAIL_LINES) stderrTail.shift();
|
||||
if (runtime === "pi" && !core.isReady() && !reportedPiCapability) {
|
||||
@@ -1582,15 +1929,15 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
});
|
||||
if (detail) {
|
||||
reportedPiCapability = true;
|
||||
process.stdout.write(`[pi-runner] ${detail}\n`);
|
||||
paneDiag(`[pi-runner] ${detail}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block));
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block), pane);
|
||||
|
||||
if (runtime !== "omp") {
|
||||
child.on("error", (err) => {
|
||||
console.error(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
paneDiag(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
const code = (err as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1;
|
||||
core.handlePiExit(code);
|
||||
input.close();
|
||||
@@ -1617,7 +1964,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
if (!core.isReady()) {
|
||||
const how = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
const output = stderrTail.length > 0 ? ` Its last output: ${stderrTail.join(" | ")}` : " It printed nothing on stderr.";
|
||||
console.error(transportUp
|
||||
paneDiag(transportUp
|
||||
? "[omp-runner] ERROR OMP did not establish a resumable RPC session. Authenticate this isolated seat using HOME=<seat-root> PI_CODING_AGENT_DIR=<seat-root>/agent omp and /login, or provide its declared model provider key in the OpenRig daemon environment. Default OMP credentials are not shared."
|
||||
: `[omp-runner] ERROR OMP exited during startup, before its RPC transport started (${command}, ${how}).${output}`);
|
||||
}
|
||||
@@ -1626,7 +1973,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
process.exitCode = code ?? 1;
|
||||
};
|
||||
child.on("error", (err) => {
|
||||
console.error(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
paneDiag(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
recordExit(null);
|
||||
});
|
||||
// "close" fires once OMP's stdio is drained, so its stderr is in the tail
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface HookActivityInput {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while it stays idle (OpenRig's pi runner). */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: the reset instant a Claude usage limit stated; kept only on that typed failure. */
|
||||
resetsAt?: string | null;
|
||||
/** The provider's own session id the hook reports (Claude's session_id). A stated reset is kept only when this is
|
||||
* the seat's own Claude session (its resume token), so a helper on another login can't park the seat's pool. */
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export type RecordHookActivityResult =
|
||||
@@ -113,6 +118,16 @@ export class AgentActivityStore {
|
||||
} catch { return unavailablePermissionStop(); }
|
||||
}
|
||||
|
||||
/** #99: whether a hook's Claude session id is the seat's own Claude session (its current resume token). A helper
|
||||
* Claude started from the seat's shell posts as the seat but runs its own session, possibly on another login. */
|
||||
private isSeatOwnClaudeSession(nodeId: string, hookSessionId: string | null | undefined): boolean {
|
||||
const token = typeof hookSessionId === "string" ? hookSessionId.trim() : "";
|
||||
if (!token) return false;
|
||||
const row = this.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(nodeId) as { resume_type: string | null; resume_token: string | null } | undefined;
|
||||
return row?.resume_type === "claude_id" && row.resume_token?.trim() === token;
|
||||
}
|
||||
|
||||
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
|
||||
if (!input.sessionName && !input.nodeId) {
|
||||
return {
|
||||
@@ -159,6 +174,10 @@ export class AgentActivityStore {
|
||||
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
|
||||
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
|
||||
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
|
||||
if (input.resetsAt && isClaudeRateLimitHook(activity.runtime, input.hookEvent, input.subtype)
|
||||
&& this.isSeatOwnClaudeSession(session.node_id, input.sessionId)) {
|
||||
activity.resetsAt = input.resetsAt;
|
||||
}
|
||||
|
||||
const event = this.eventBus.emit({
|
||||
type: "agent.activity",
|
||||
|
||||
@@ -1508,7 +1508,7 @@ export function hookOrPaneActivity(
|
||||
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
|
||||
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
|
||||
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
|
||||
if (structural && structural.state !== "unknown") {
|
||||
if (structural && (structural.state !== "unknown" || structural.reason === "unrecognized_client_version")) {
|
||||
return {
|
||||
source: "pane",
|
||||
activity: {
|
||||
@@ -1617,8 +1617,9 @@ export async function attachAgentActivity(
|
||||
*
|
||||
* needs_input > running > MOTION > idle / unknown
|
||||
*
|
||||
* A current, target-bound complete idle frame is the narrow exception to motion below.
|
||||
* A generic footer or old idle capture is not that evidence; D2 remains unchanged.
|
||||
* A current, target-bound complete idle frame is the narrow idle exception to motion below.
|
||||
* An explicitly unsupported client version also retains unknown rather than inferring work from redraws.
|
||||
* A generic footer or old idle capture is not completed-idle evidence; D2 remains unchanged.
|
||||
* Live motion otherwise UPGRADES a verdict of `idle` or `unknown`; it never touches `needs_input` or an
|
||||
* already-`running` verdict, and it never MANUFACTURES `idle`. That asymmetry is the whole
|
||||
* safety argument, and it is what each half of the defect needs:
|
||||
@@ -1644,7 +1645,7 @@ export async function attachAgentActivity(
|
||||
* signal improving. Silence therefore leaves the prior verdict exactly as it was.
|
||||
*/
|
||||
const withMotion = (activity: AgentActivity, currentIdleFrame = false): AgentActivity => {
|
||||
if (!motionRunning || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (!motionRunning || activity.reason === "unrecognized_client_version" || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (activity.state !== "idle" && activity.state !== "unknown") return activity;
|
||||
return {
|
||||
state: "running",
|
||||
@@ -1682,7 +1683,8 @@ export async function attachAgentActivity(
|
||||
|
||||
// An idle hook alone still yields to motion (D2); when a current complete idle frame exists, report
|
||||
// that frame's provenance instead.
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural, motionRunning && currentIdleFrame, entry.runtime);
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural,
|
||||
motionRunning && (currentIdleFrame || structural?.reason === "unrecognized_client_version"), entry.runtime);
|
||||
if (resolved?.source === "hook") return { ...entry, agentActivity: withMotion(resolved.activity) };
|
||||
if (resolved?.source === "limit") return { ...entry, agentActivity: resolved.activity };
|
||||
if (resolved?.source === "pane") return { ...entry, agentActivity: withMotion(resolved.activity, currentIdleFrame) };
|
||||
|
||||
@@ -219,6 +219,8 @@ export type ReactiveEventInput = {
|
||||
// Required: a reactive event without a freshness bound could never pass BR-2, and a momentary
|
||||
// event must always carry when it expires. Staleness itself stays the shared predicate's job.
|
||||
staleAfter: string;
|
||||
/** #99: when the provider's own failure says the limit resets. Absent, the pool falls back to the bounded retry. */
|
||||
resetsAt?: string;
|
||||
} & ({ provider: ProviderKind; accountRef: string; seatSession?: never }
|
||||
| { provider: "claude"; seatSession: string; accountRef?: never });
|
||||
|
||||
@@ -236,6 +238,7 @@ export function reactiveEventSignal(input: ReactiveEventInput): ProviderSignal {
|
||||
// At-limit exhaustion is an actionable switch trigger; stream/stop errors are advisory.
|
||||
automationUse: input.kind === "at_limit" ? "allow_switch_decision" : "advisory_only",
|
||||
// No usedPercent — a reactive event is exhaustion evidence, not a remaining meter.
|
||||
...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,8 +322,8 @@ export function deriveUsageLimitPools(input: {
|
||||
const statedResets = pool.signals
|
||||
.map((signal) => Date.parse(signal.resetsAt ?? ""))
|
||||
.filter((value) => Number.isFinite(value) && value > nowMs);
|
||||
// A typed Claude failure has no reset timestamp. Use the existing bounded retry interval,
|
||||
// as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
// A typed Claude failure carries a reset only when Claude's own message stated one (#99). Without it, use
|
||||
// the existing bounded retry interval, as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
const fallbackResets = pool.signals
|
||||
.filter((signal) => pool.provider === "codex" || (signal.sourceClass === "provider_event" && signal.authority === "reactive_error"))
|
||||
.map((signal) => Date.parse(signal.asOf) + input.fallbackSeconds * 1000)
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { CodexAuthMetadata } from "./codex-auth-reader.js";
|
||||
import { reactiveEventSignal, type ReactiveEventKind } from "./provider-signals.js";
|
||||
import type { ProviderSignal } from "./provider-types.js";
|
||||
|
||||
/** #99: the farthest a stated Claude reset may sit after its failure: the relay's own cap (activity-relay.cjs). */
|
||||
const CLAUDE_STATED_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
interface ReactiveSeat {
|
||||
seatSession: string;
|
||||
runtime: string;
|
||||
@@ -98,9 +101,13 @@ export function collectReactiveEventSignals(deps: ReactiveTapDeps): ReactiveTapR
|
||||
|| isVerificationReason(event.reason) || event.stale) continue;
|
||||
const eventAtMs = Date.parse(event.eventAt ?? "");
|
||||
if (!Number.isFinite(eventAtMs) || eventAtMs > nowMs || nowMs >= eventAtMs + deps.freshnessMs) continue;
|
||||
// #99: the reset Claude's message stated, when the relay could read one, after the failure and within the relay's
|
||||
// eight-day cap. The cap is enforced here too, so no other sender can park a pool longer than the relay could.
|
||||
const resetMs = Date.parse(event.resetsAt ?? "");
|
||||
signals.push(reactiveEventSignal({
|
||||
provider: "claude", seatSession: seat.seatSession, kind: "at_limit",
|
||||
asOf: event.eventAt!, staleAfter: new Date(eventAtMs + deps.freshnessMs).toISOString(),
|
||||
...(Number.isFinite(resetMs) && resetMs > eventAtMs && resetMs <= eventAtMs + CLAUDE_STATED_RESET_MAX_MS ? { resetsAt: new Date(resetMs).toISOString() } : {}),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -854,12 +854,12 @@ export class SeatHandoverService {
|
||||
}
|
||||
// #981: a composer-launched successor respawns into the seat's own pane, whose discovery row the
|
||||
// previous handover claimed for this same node, and a rescan keeps that claim (#237). That claim is
|
||||
// this seat's own, so commit re-claims it. A row claimed by any other node, or a discovered-source
|
||||
// successor, still needs an active, unclaimed record.
|
||||
// this seat's own, so commit re-claims it. #2861: a deleted owner or a terminal owner in an archived
|
||||
// earlier rig can also leave that pane claimed. Other owners and discovered-source successors still refuse.
|
||||
const ownPaneClaim = input.reportedSource.mode !== "discovered"
|
||||
&& discovered.status === "claimed"
|
||||
&& discovered.claimedNodeId === input.node.id
|
||||
&& discovered.tmuxSession === input.latestSession.session_name;
|
||||
&& discovered.tmuxSession === input.latestSession.session_name
|
||||
&& (discovered.claimedNodeId === input.node.id || this.isStalePaneClaimOwner(discovered.claimedNodeId));
|
||||
if (discovered.status !== "active" && !ownPaneClaim) {
|
||||
return fail({
|
||||
ok: false,
|
||||
@@ -1277,6 +1277,15 @@ export class SeatHandoverService {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
|
||||
}
|
||||
|
||||
private isStalePaneClaimOwner(nodeId: string | null): boolean {
|
||||
if (nodeId === null) return true; // ON DELETE SET NULL retains the claim after its owner is removed.
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE n.id = ? AND r.archived_at IS NOT NULL
|
||||
AND (SELECT status FROM sessions WHERE node_id = n.id ORDER BY id DESC LIMIT 1) IN ('superseded', 'detached', 'exited')
|
||||
`).pluck().get(nodeId) === 1;
|
||||
}
|
||||
|
||||
private commit(input: {
|
||||
seatRef: string;
|
||||
status: SeatStatus;
|
||||
@@ -1298,6 +1307,15 @@ export class SeatHandoverService {
|
||||
}): SeatHandoverResult {
|
||||
const handoverAt = this.now().toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
// The presence probe awaited after validation. Reclaim only the same still-stale foreign claim,
|
||||
// inside the transaction that assigns it, so a resumed owner or a new claimant cannot be overwritten.
|
||||
if (input.discovered.status === "claimed" && input.discovered.claimedNodeId !== input.node.id) {
|
||||
const current = this.discoveryRepo.getDiscoveredSession(input.discovered.id);
|
||||
if (current?.status !== "claimed" || current.claimedNodeId !== input.discovered.claimedNodeId
|
||||
|| !this.isStalePaneClaimOwner(current.claimedNodeId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const rows = this.db.prepare(
|
||||
"SELECT id FROM sessions WHERE node_id = ? AND status NOT IN ('superseded', 'detached', 'exited') ORDER BY id"
|
||||
).all(input.node.id) as Array<{ id: string }>;
|
||||
@@ -1413,7 +1431,16 @@ export class SeatHandoverService {
|
||||
|
||||
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean };
|
||||
try {
|
||||
committed = tx();
|
||||
const outcome = tx();
|
||||
if (outcome === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "discovered_not_active",
|
||||
message: `Discovery record "${input.discovered.id}" no longer has a stale owner claim.`,
|
||||
guidance: "Inspect the current discovery owner and successor state before retrying.",
|
||||
};
|
||||
}
|
||||
committed = outcome;
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
|
||||
@@ -73,7 +73,8 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
@@ -85,9 +86,13 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
@@ -1381,6 +1386,8 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1424,8 +1431,10 @@ export class SeatLifecycleService {
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, namesAnotherClientVersion, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
|
||||
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
|
||||
@@ -129,6 +129,9 @@ export class SeatStructuralActivityService {
|
||||
return null;
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
// Redraws from an unrecorded Codex layout cannot establish work or completed idle.
|
||||
// Keep positive work and attention visible; native hooks are resolved separately by the activity fold.
|
||||
const unrecognizedVersion = runtime === "codex" && c.state !== "attention" && c.state !== "agent_active" && namesAnotherClientVersion(content, "codex");
|
||||
const at = observedAt ?? this.now();
|
||||
const latest = this.latestBySession.get(sessionName);
|
||||
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
|
||||
@@ -146,8 +149,8 @@ export class SeatStructuralActivityService {
|
||||
...(advanced ? { previousCaptureStartedAt: previous!.codexWorkingClock!.captureStartedAt } : {}) }
|
||||
: priorProof;
|
||||
const obs: StructuralObservation = {
|
||||
state: c.state,
|
||||
reason: c.reason,
|
||||
state: unrecognizedVersion ? "unknown" : c.state,
|
||||
reason: unrecognizedVersion ? "unrecognized_client_version" : c.reason,
|
||||
evidence: c.evidence,
|
||||
observedAt: at.toISOString(),
|
||||
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
|
||||
@@ -167,7 +170,7 @@ export class SeatStructuralActivityService {
|
||||
// Null/failed, blank and unrecognized captures cannot establish recovery.
|
||||
if (runtime === "codex" && this.bannerEmitter) {
|
||||
const banner = detectCodexLimitBanner(content);
|
||||
if (banner || (content.trim().length > 0 && c.state !== "unknown")) {
|
||||
if (banner || (content.trim().length > 0 && obs.state !== "unknown")) {
|
||||
this.bannerEmitter(sessionName, banner, at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,8 +1835,9 @@ const SEAT_IDLE_SHAPES: {
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
// The hint row under the status line of an empty box (product-path-idle.J, idle).
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// Empty-box hints: 0.160 product-path-idle.J/idle, and the captured 0.167 completed frame.
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/,
|
||||
/^← for agents · \? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// The last non-blank row above the empty box: the completion row Codex adds when a turn completes
|
||||
// (after-enter-15s.J; its grammar is CODEX_COMPLETION_ROW above), or the greeting under the session header
|
||||
// before any turn (idle, product-path-idle.J, cleared-without-enter.J). A running or streaming turn draws rows
|
||||
@@ -1909,7 +1910,7 @@ const VISIBLE_CLIENT_VERSION: Record<"claude-code" | "codex", RegExp[]> = {
|
||||
};
|
||||
|
||||
/** Whether a frame visibly names a client version other than the recorded one (see RECORDED_CLIENT_VERSION). */
|
||||
function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
export function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
return VISIBLE_CLIENT_VERSION[runtime].some((pattern) =>
|
||||
[...pane.matchAll(pattern)].some((match) => match[1] !== RECORDED_CLIENT_VERSION[runtime]));
|
||||
}
|
||||
|
||||
@@ -651,6 +651,9 @@ export interface AgentActivity {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while idle; an idle report older than two of these no longer holds. */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: when a Claude usage limit (StopFailure/rate_limit) says it resets, read by the relay from Claude's own
|
||||
* message. Only the instant is kept, never the message. */
|
||||
resetsAt?: string | null;
|
||||
rawEvent?: string | null;
|
||||
rawSubtype?: string | null;
|
||||
runtime?: string | null;
|
||||
|
||||
@@ -116,6 +116,16 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
|
||||
const hookEvidenceSeq = new Map<string, number>();
|
||||
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
|
||||
const workingLiveness = new WorkingLivenessGate();
|
||||
/** #99: the reset instant the relay read from a Claude usage limit, as a canonical ISO timestamp, or null. The store
|
||||
* keeps it only on the typed StopFailure/rate_limit row; the provider lane decides whether it is still ahead. */
|
||||
function limitResetsAt(body: Record<string, unknown>): string | null {
|
||||
if (typeof body.resetsAt !== "string" || body.resetsAt.length > 40) return null;
|
||||
const ms = Date.parse(body.resetsAt);
|
||||
// The relay's eight-day horizon, enforced at ingress too: no sender can park a pool for longer than the relay could.
|
||||
return Number.isFinite(ms) && ms <= Date.now() + LIMIT_RESET_MAX_MS ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
const LIMIT_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
function sourceSequence(body: Record<string, unknown>): number | null {
|
||||
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
|
||||
}
|
||||
@@ -727,6 +737,8 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
|
||||
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
resetsAt: limitResetsAt(body),
|
||||
sessionId: stringOrNull(body.sessionId),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -16,6 +16,8 @@ const oldIdle = { state: "idle", reason: "stop_hook", evidenceSource: "runtime_h
|
||||
const CNAME = "review-r1@sample-rig";
|
||||
const cframe = readFileSync(new URL("./fixtures/input-box/claude-code-2.1.296/openrig-seat-accept-edits-idle.txt", import.meta.url), "utf8");
|
||||
const codexFrame = readFileSync(new URL("./fixtures/input-box/codex-0.160.0/after-enter-15s.J.txt", import.meta.url), "utf8");
|
||||
const codexCompleted = readFileSync(new URL("./fixtures/input-box/codex-0.167.0/completed.txt", import.meta.url), "utf8");
|
||||
const codexWorking = readFileSync(new URL("./fixtures/input-box/codex-0.167.0/working.txt", import.meta.url), "utf8");
|
||||
const busyFrame = cframe.replace(/^✻ Churned for 4s · done 5:24 PM$/m, "✻ Working… (3s · thinking)");
|
||||
async function adapterFold(frame: string, opts: { age?: number; sourceName?: string; sourceRuntime?: "claude-code" | "codex"; runtime?: string; hook?: object | null; lastWriteAge?: number; motion?: boolean } = {}) {
|
||||
const capturedAt = new Date(NOW.getTime() - (opts.age ?? 0));
|
||||
@@ -57,6 +59,63 @@ describe("issue824 existing strict adapter idle frame (behavioral red-first)", (
|
||||
const streaming = codexFrame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer");
|
||||
expect((await adapterFold(streaming, { sourceRuntime: "codex" })).activity.state).toBe("running");
|
||||
});
|
||||
it.each(["0.167.0", "99.0.0"])("an unrecorded Codex %s frame stays unknown under redraw motion", async (version) => {
|
||||
// Synthetic version edits of the recorded 0.160 fixture, not native newer-version captures.
|
||||
const frame = codexFrame.replaceAll("0.160.0", version);
|
||||
for (const pane of [frame, frame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer")]) {
|
||||
expect(readSeatBeforePaste(pane, "codex")).toBe("unknown");
|
||||
const result = await adapterFold(pane, { sourceRuntime: "codex", hook: null, lastWriteAge: 2000 });
|
||||
expect(result.activity.state).toBe("unknown");
|
||||
expect(result.activity.reason).not.toBe("window_activity_motion");
|
||||
}
|
||||
});
|
||||
it("the captured Codex agents hint preserves completed idle despite redraws", async () => {
|
||||
expect.soft(readSeatBeforePaste(codexCompleted, "codex")).toBe("idle");
|
||||
for (const hook of [null, oldIdle]) {
|
||||
const result = await adapterFold(codexCompleted, { sourceRuntime: "codex", hook });
|
||||
expect.soft(result.activity.state).toBe("idle");
|
||||
expect(result.activity.evidenceSource).toBe("pane_heuristic");
|
||||
}
|
||||
});
|
||||
it("the captured working frame remains running without motion", async () => {
|
||||
expect(readSeatBeforePaste(codexWorking, "codex")).toBe("not-idle");
|
||||
expect((await adapterFold(codexWorking, { sourceRuntime: "codex", hook: null, motion: false })).activity.state).toBe("running");
|
||||
});
|
||||
it("the agents footer alone cannot prove idle while output is unfinished", async () => {
|
||||
// Synthetic spinner-hidden stream, not a native streaming capture.
|
||||
const stream = codexCompleted.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer");
|
||||
expect(readSeatBeforePaste(stream, "codex")).toBe("unknown");
|
||||
expect((await adapterFold(stream, { sourceRuntime: "codex", hook: null })).activity.state).toBe("running");
|
||||
const badHint = codexCompleted.replace("← for agents", "unknown hint");
|
||||
expect(readSeatBeforePaste(badHint, "codex")).toBe("unknown");
|
||||
});
|
||||
it("an unsupported Codex frame cannot turn an idle or stale hook into running, while positive work and attention remain", async () => {
|
||||
const frame = codexFrame.replaceAll("0.160.0", "99.0.0");
|
||||
for (const hook of [oldIdle, { ...oldIdle, state: "unknown", stale: true }]) {
|
||||
expect((await adapterFold(frame, { sourceRuntime: "codex", hook })).activity.state).toBe("unknown");
|
||||
}
|
||||
for (const state of ["running", "needs_input"]) {
|
||||
expect((await adapterFold(frame, { sourceRuntime: "codex", hook: { ...oldIdle, state } })).activity.state).toBe(state);
|
||||
}
|
||||
});
|
||||
it.each([" >_ OpenAI Codex (v99.0.0)", "Update available! 99.0.0 -> 99.1.0"])("a fresh idle hook wins without motion on a version-named screen: %s", async (header) => {
|
||||
const frame = `${header}\n${codexCompleted}`;
|
||||
const hook = { ...oldIdle, sampledAt: NOW.toISOString() };
|
||||
expect(readSeatBeforePaste(frame, "codex")).toBe("unknown");
|
||||
const still = await adapterFold(frame, { sourceRuntime: "codex", hook, motion: false });
|
||||
expect(still.observation).toMatchObject({ state: "unknown", reason: "unrecognized_client_version" });
|
||||
expect(still.activity).toEqual(hook);
|
||||
const moving = await adapterFold(frame, { sourceRuntime: "codex", hook, motion: true });
|
||||
expect(moving.activity).toMatchObject({ state: "unknown", reason: "unrecognized_client_version" });
|
||||
});
|
||||
it.each([false, true])("positive pane work survives a version header with motion=%s", async (motion) => {
|
||||
// Synthetic version header over the recorded working frame.
|
||||
const frame = ` >_ OpenAI Codex (v99.0.0)\n${codexWorking}`;
|
||||
expect(readSeatBeforePaste(frame, "codex")).toBe("not-idle");
|
||||
const result = await adapterFold(frame, { sourceRuntime: "codex", hook: null, motion });
|
||||
expect(result.observation).toMatchObject({ state: "agent_active", reason: "mid_work_pattern" });
|
||||
expect(result.activity).toMatchObject({ state: "running", evidenceSource: "pane_heuristic", reason: "mid_work_pattern" });
|
||||
});
|
||||
it("a newer redraw after an old idle capture keeps D2 running", async () => {
|
||||
expect((await adapterFold(cframe, { age: 2000 })).activity.state).toBe("running");
|
||||
// The write and capture are in the same second: ordering is unknown,
|
||||
|
||||
@@ -23,11 +23,14 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
|
||||
const require = createRequire(import.meta.url);
|
||||
const relay = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
|
||||
buildOpenRigPayload: (payload: Record<string, unknown>, env: Record<string, string>, now: () => Date) => Record<string, unknown>;
|
||||
claudeLimitResetAt: (text: string, now: Date) => string | null;
|
||||
};
|
||||
const SESSION = "dev-worker@limit-fixture";
|
||||
const NODE = "node-limit-fixture";
|
||||
const GENERATION = "generation-current";
|
||||
const T0 = "2025-01-01T12:00:00.000Z";
|
||||
// The seat's own Claude session: its resume token, which a hook from the seat's Claude reports as session_id.
|
||||
const OWN_CLAUDE_SESSION = "11111111-1111-4111-8111-111111111111";
|
||||
const identity = { OPENRIG_SESSION_NAME: SESSION, OPENRIG_NODE_ID: NODE, OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: GENERATION };
|
||||
|
||||
describe("Claude provider interruptions (#99)", () => {
|
||||
@@ -48,7 +51,8 @@ describe("Claude provider interruptions (#99)", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-limit', 'limit-fixture')").run();
|
||||
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES ('pod-limit', 'rig-limit', 'dev', 'Dev')").run();
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, pod_id) VALUES (?, 'rig-limit', 'dev.worker', 'claude-code', 'pod-limit')").run(NODE);
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status) VALUES ('session-limit', ?, ?, 'running', 'ready')").run(NODE, SESSION);
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, resume_type, resume_token) VALUES ('session-limit', ?, ?, 'running', 'ready', 'claude_id', ?)")
|
||||
.run(NODE, SESSION, OWN_CLAUDE_SESSION);
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session) VALUES ('binding-limit', ?, ?)").run(NODE, SESSION);
|
||||
const eventBus = new EventBus(db);
|
||||
liveGeneration = GENERATION;
|
||||
@@ -125,6 +129,124 @@ describe("Claude provider interruptions (#99)", () => {
|
||||
expect(store.getLatestForNode({ sessionName: SESSION })).toMatchObject({ state: "unknown", reason: "usage_limit" });
|
||||
});
|
||||
|
||||
describe("the reset a Claude limit states (#99)", () => {
|
||||
const WEEKLY = "You've hit your weekly limit · resets Jan 2, 4pm (America/Sao_Paulo)";
|
||||
const reportStated = (text: string, field = "last_assistant_message", sessionId: string | null = OWN_CLAUDE_SESSION) =>
|
||||
report({ hook_event_name: "StopFailure", error: "rate_limit", [field]: text, ...(sessionId ? { session_id: sessionId } : {}) });
|
||||
const latestResetsAt = () => (store.getLatestForNode({ sessionName: SESSION }) as { resetsAt?: string | null } | null)?.resetsAt;
|
||||
|
||||
it.each([
|
||||
// Real messages from a Claude Code 2.1.296 limit, and the older formats.
|
||||
[WEEKLY, "2025-01-02T19:00:00.000Z"],
|
||||
["5-hour limit reached ∙ resets 8am (UTC)", "2025-01-02T08:00:00.000Z"],
|
||||
["5-hour limit reached ∙ resets 1:30pm (UTC)", "2025-01-01T13:30:00.000Z"],
|
||||
["rate limit · resets in 2h 30m", "2025-01-01T14:30:00.000Z"],
|
||||
["Claude AI usage limit reached|1735740000", "2025-01-01T14:00:00.000Z"],
|
||||
])("reads %j", (text, expected) => {
|
||||
expect(relay.claudeLimitResetAt(text, new Date(T0))).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"API Error: Rate limit reached",
|
||||
"resets 3",
|
||||
"resets 4pm (Not/AZone)",
|
||||
"resets 4pm (Not/AZone", // a truncated zone is not the seat's local time
|
||||
"resets 4pm ()",
|
||||
"resets in 2 months", // not two minutes
|
||||
"resets in 2h 30x",
|
||||
"resets in 2h, 30x", // a separator doesn't end the fragment
|
||||
"resets in 2h and 30x",
|
||||
"resets in soon",
|
||||
"resets Feb 30, 4pm (UTC)",
|
||||
"resets Mar 1, 4pm (UTC)", // more than a week away: not a usage window
|
||||
"Claude AI usage limit reached|1735730000", // already past
|
||||
])("states no reset for %j", text => {
|
||||
expect(relay.claudeLimitResetAt(text, new Date(T0))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Close to the impossible date, where only a calendar check (not the distance cap) can reject it.
|
||||
["2026-02-28T12:00:00.000Z", "resets Feb 30, 4pm (UTC)", null],
|
||||
["2026-02-28T12:00:00.000Z", "resets Feb 29, 4pm (UTC)", null], // 2026 is not a leap year
|
||||
["2026-02-28T12:00:00.000Z", "resets Mar 2, 4pm (UTC)", "2026-03-02T16:00:00.000Z"],
|
||||
["2028-02-28T12:00:00.000Z", "resets Feb 29, 4pm (UTC)", "2028-02-29T16:00:00.000Z"],
|
||||
["2026-04-29T12:00:00.000Z", "resets Apr 31, 4pm (UTC)", null],
|
||||
// A wall time a DST change repeats or skips names no single instant: no reset rather than one a day late.
|
||||
["2025-11-02T05:00:00.000Z", "resets 1:30am (America/New_York)", null], // 1:30 happens twice that night
|
||||
["2025-11-02T05:00:00.000Z", "resets Nov 2, 1:30am (America/New_York)", null],
|
||||
["2025-03-09T06:00:00.000Z", "resets 2:30am (America/New_York)", null], // 2:30 never happens that night
|
||||
["2025-03-09T06:00:00.000Z", "resets 3:30am (America/New_York)", "2025-03-09T07:30:00.000Z"],
|
||||
["2025-11-02T05:00:00.000Z", "resets 3am (America/New_York)", "2025-11-02T08:00:00.000Z"],
|
||||
])("at %s reads %j as %j", (now, text, expected) => {
|
||||
expect(relay.claudeLimitResetAt(text, new Date(now))).toBe(expected);
|
||||
});
|
||||
|
||||
it("relays only the instant, never the message", () => {
|
||||
const payload = reportStated(WEEKLY);
|
||||
expect(payload).toMatchObject({ subtype: "rate_limit", resetsAt: "2025-01-02T19:00:00.000Z" });
|
||||
expect(JSON.stringify(payload)).not.toContain("weekly");
|
||||
expect(reportStated(WEEKLY, "error_details")).toMatchObject({ resetsAt: "2025-01-02T19:00:00.000Z" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ hook_event_name: "StopFailure", error: "overloaded", last_assistant_message: WEEKLY },
|
||||
{ hook_event_name: "Stop", last_assistant_message: WEEKLY },
|
||||
{ hook_event_name: "Notification", notification_type: "rate_limit", message: WEEKLY },
|
||||
])("reads no reset off another event: %j", payload => {
|
||||
expect(report(payload)).not.toHaveProperty("resetsAt");
|
||||
});
|
||||
|
||||
it("keeps a stated reset only from the seat's own Claude session, not a helper's", async () => {
|
||||
// A helper Claude started from the seat's shell posts as the seat, but under its own session (maybe another login).
|
||||
reportStated(WEEKLY, "last_assistant_message", "22222222-2222-4222-8222-222222222222");
|
||||
expect(latestResetsAt()).toBeUndefined();
|
||||
const model = await service.getReadModel();
|
||||
expect(deriveUsageLimitPools({ signals: model.signals, bindings: model.bindings, now: new Date(), fallbackSeconds: 300 }))
|
||||
.toEqual([expect.objectContaining({ poolKey: "claude:local", source: "config-fallback",
|
||||
expiresAt: new Date(Date.parse(T0) + 300_000).toISOString() })]);
|
||||
|
||||
reportStated(WEEKLY, "last_assistant_message", null);
|
||||
expect(latestResetsAt()).toBeUndefined();
|
||||
reportStated(WEEKLY);
|
||||
expect(latestResetsAt()).toBe("2025-01-02T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("drops a stated reset past the eight-day cap from any sender", async () => {
|
||||
const result = store.recordHookEvent({ runtime: "claude-code", sessionName: SESSION, hookEvent: "StopFailure", subtype: "rate_limit",
|
||||
occurredAt: T0, generation: GENERATION, sessionId: OWN_CLAUDE_SESSION, resetsAt: "2025-01-10T12:00:00.000Z" });
|
||||
expect(result.ok && result.activity.resetsAt).toBe("2025-01-10T12:00:00.000Z");
|
||||
expect(await events()).toEqual([expect.not.objectContaining({ resetsAt: expect.anything() })]);
|
||||
});
|
||||
|
||||
it("keeps a stated reset only on the typed limit row", () => {
|
||||
const result = store.recordHookEvent({ runtime: "claude-code", sessionName: SESSION, hookEvent: "Stop",
|
||||
resetsAt: "2025-01-02T19:00:00.000Z", generation: GENERATION, sessionId: OWN_CLAUDE_SESSION });
|
||||
expect(result.ok && result.activity).not.toHaveProperty("resetsAt");
|
||||
});
|
||||
|
||||
it("parks pending work until the stated reset instead of the bounded retry", async () => {
|
||||
reportStated(WEEKLY);
|
||||
const model = await service.getReadModel();
|
||||
expect(model.signals.filter(signal => signal.sourceClass === "provider_event"))
|
||||
.toEqual([expect.objectContaining({ provider: "claude", seatSession: SESSION, resetsAt: "2025-01-02T19:00:00.000Z" })]);
|
||||
expect(deriveUsageLimitPools({ signals: model.signals, bindings: model.bindings, now: new Date(), fallbackSeconds: 300 }))
|
||||
.toEqual([{ poolKey: "claude:local", provider: "claude", seatSessions: [SESSION], expiresAt: "2025-01-02T19:00:00.000Z", source: "provider-reset" }]);
|
||||
|
||||
queue.attachWatchdogJobsRepository(new WatchdogJobsRepository(db, () => new Date()));
|
||||
queue.attachOutbox(new OutboxHandler(db));
|
||||
const source = await queue.create({ sourceSession: "sender@limit-fixture", destinationSession: "relay@limit-fixture", body: "work", nudge: false });
|
||||
const { created: baton } = await queue.handoff({ qitemId: source.qitemId, fromSession: "relay@limit-fixture", toSession: SESSION, nudge: false });
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = 'failed:provider limit' WHERE qitem_id = ?")
|
||||
.run(new Date(Date.parse(T0) - 600_000).toISOString(), baton.qitemId);
|
||||
const result = await runWakeLadderTick({ db, queueRepo: queue, attemptWake: vi.fn(async () => "typed"), resolveOrchestrator: () => null,
|
||||
retryIntervalSeconds: 300, usageLimitJitterSeconds: 0, getProviderReadModel: () => service.getReadModel(), log: () => {} });
|
||||
expect(result.actions).toContainEqual({ qitemId: baton.qitemId, action: "park-usage-limit" });
|
||||
const blocker = queue.getById(baton.qitemId)!.blockedOn!;
|
||||
expect(queue.getParkWakeStatus(blocker)).toMatchObject({ kind: "timer", live: true, expiresAt: "2025-01-02T19:00:00.000Z" });
|
||||
expect(queue.transitionLog.listForQitem(blocker).some(row => row.transitionNote?.includes("cause=provider-reset"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a fresh same-occupant limit without inventing a Claude account, usage meter or reset", async () => {
|
||||
reportLimit();
|
||||
const model = await service.getReadModel();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
detectCodexLimitBanner,
|
||||
@@ -497,6 +498,11 @@ describe("codex-limit-banner — store mapping and seat status", () => {
|
||||
it.each([
|
||||
{ name: "whitespace-only capture", frame: " \n\t\n " },
|
||||
{ name: "unrecognized non-empty capture", frame: "unclassified output" },
|
||||
{
|
||||
name: "unsupported client version on a completed layout",
|
||||
frame: " >_ OpenAI Codex (v99.0.0)\n" + readFileSync(
|
||||
new URL("./fixtures/input-box/codex-0.167.0/completed.txt", import.meta.url), "utf8"),
|
||||
},
|
||||
{ name: "banner above draft composer", frame: `${BANNER}\n› Continue the task` },
|
||||
{ name: "banner above indented empty composer", frame: `${BANNER}\n › Ask Codex to do anything` },
|
||||
])("does not clear a current limit for $name", async ({ frame }) => {
|
||||
@@ -533,6 +539,35 @@ describe("codex-limit-banner — store mapping and seat status", () => {
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("positive work on a version-named frame clears a prior limit durably", async () => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const { sessionName } = seedCodexSeat(db);
|
||||
let clock = NOW;
|
||||
const deps = { db, eventBus: new EventBus(db), now: () => clock, resolveOccupantGeneration: () => "current-generation" };
|
||||
const store = new AgentActivityStore(deps);
|
||||
const capturePaneContent = vi.fn(async () => BANNER);
|
||||
const structuralActivity = new SeatStructuralActivityService({ capturePaneContent }, () => clock);
|
||||
structuralActivity.attachCodexLimitBanner((sessionName, banner, at) => {
|
||||
recordCodexLimitBanner({ store, resolveGeneration: () => "current-generation", sessionName, banner, now: () => at });
|
||||
});
|
||||
await structuralActivity.pollSeat(sessionName, null, "codex");
|
||||
expect(store.getLatestForNode({ sessionName })).toMatchObject({ reason: "usage_limit" });
|
||||
capturePaneContent.mockResolvedValue(" >_ OpenAI Codex (v99.0.0)\n" + readFileSync(
|
||||
new URL("./fixtures/input-box/codex-0.167.0/working.txt", import.meta.url), "utf8"));
|
||||
for (let poll = 1; poll <= 2; poll++) {
|
||||
clock = new Date(NOW.getTime() + poll * 1000);
|
||||
expect((await structuralActivity.pollSeat(sessionName, null, "codex"))?.state).toBe("agent_active");
|
||||
expect(new AgentActivityStore(deps).getLatestForNode({ sessionName })).toMatchObject({ reason: "usage_limit_cleared" });
|
||||
const out = await attachAgentActivity(entries(sessionName), {
|
||||
tmuxAdapter: { capturePaneContent } as never, activityStore: store, structuralActivity, now: clock,
|
||||
});
|
||||
expect(out[0]!.agentActivity).toMatchObject({ state: "running", reason: "mid_work_pattern" });
|
||||
}
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get()).toEqual({ n: 2 });
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "submitted prompt followed by actual work", draft: "› Continue the task\n◦ Working (2s • esc to interrupt)", limit: false },
|
||||
...["Explain this example", ""].map(first => ({ name: `unsent nested draft: ${first || "blank first line"}`,
|
||||
|
||||
@@ -5,6 +5,10 @@ Real frames of Claude Code 2.1.296 (`claude-code-2.1.296/`) and Codex 0.160.0 (`
|
||||
(`claude-code-2.1.296/openrig-seat-*`, see below). `texts/` holds the texts that were sent.
|
||||
`input-box-reading.test.ts` and `session-transport-input-box.test.ts` read them.
|
||||
|
||||
`codex-0.167.0/` adds redacted completed and working tails from a managed terminal.
|
||||
Its README records their provenance and the native streaming evidence still missing.
|
||||
The activity tests use these tails; they do not establish a staged-input/send sequence.
|
||||
|
||||
## Versions and their limits
|
||||
|
||||
Every frame here is of one client version, named by its folder; the idle allow-list and the box readers were built
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Codex 0.167.0 activity frames
|
||||
|
||||
Captured from an OpenRig-managed Codex terminal on Linux, at 120 columns. The
|
||||
runtime reported `codex-cli 0.167.0` separately; neither captured tail displays
|
||||
the client version. These frames establish the drawn layout, not a general
|
||||
allow-list for every screen of that version.
|
||||
|
||||
- `completed.txt`: another agent captured the terminal at the completed prompt
|
||||
with `rig capture`, before waking it, on 2026-11-11. The transcript was replaced
|
||||
by a generic response, and the local path and task title were redacted. The
|
||||
completion, blank rows, composer, model and hint/warning rows are retained.
|
||||
- `working.txt`: captured during a tool turn with `rig capture`. Command text,
|
||||
output, local path and task title were redacted. The working row, tip, blank
|
||||
rows, composer and footer are retained.
|
||||
|
||||
No spinner-hidden streaming frame was captured. Tests that replace the completion
|
||||
row with unfinished response text are synthetic controls, not native captures.
|
||||
No fresh-session header, staged input or submit sequence is represented here.
|
||||
@@ -0,0 +1,10 @@
|
||||
• Finished the task.
|
||||
|
||||
Worked for 50s • 12:32 AM
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-6.1-Sol high · ~/example/work · Finish task
|
||||
← for agents · ? for shortcuts ⚠ 1 warning · f2 to view
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
• Ran a command
|
||||
└ Command output
|
||||
|
||||
• Working (4m 34s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close
|
||||
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-6.1-Sol high · ~/example/work · Finish task
|
||||
← for agents · ? for shortcuts ⚠ 1 warning · f2 to view
|
||||
|
||||
@@ -90,7 +90,8 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
|
||||
// Hook + skills wiring
|
||||
expect(manifest["skills"]).toBe("./skills");
|
||||
expect(manifest["hooks"]).toBe("./hooks/claude.json");
|
||||
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
|
||||
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
|
||||
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
|
||||
expect(manifest["license"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// Hermetic tests for the pane compositor: the bottom live region that keeps the
|
||||
// operator's draft out of the transcript, pins the latest diagnostic as a copy
|
||||
// (never the only one), flushes a held streamed tail when input goes away, and
|
||||
// follows terminal resizes. No live pi.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
composeLayout, createPaneCompositor, createRunnerInput, RunnerCore, type RunnerIo,
|
||||
} from "../src/adapters/pi-runner.js";
|
||||
|
||||
function paneOut(columns: number | null = 40) {
|
||||
let buf = "";
|
||||
const output = Object.assign(new PassThrough(), typeof columns === "number" ? { isTTY: true, columns } : {});
|
||||
output.on("data", (chunk: Buffer) => { buf += chunk.toString(); });
|
||||
return { output: output as unknown as NodeJS.WriteStream, text: () => buf };
|
||||
}
|
||||
|
||||
// ── One physical layout model ────────────────────────────────────────────────
|
||||
|
||||
describe("compose layout", () => {
|
||||
it("counts plain text as one row and places the caret at its end", () => {
|
||||
expect(composeLayout("ab", 40)).toEqual({ rows: 1, caretRow: 0, caretColumn: 2 });
|
||||
});
|
||||
|
||||
it("wraps wide characters whole: a CJK glyph that does not fit starts the next row", () => {
|
||||
// Width 4: "abc" fills columns 0-2, 漢 needs two cells and only one is left, so it
|
||||
// wraps — three terminal rows would be wrong; the terminal uses two.
|
||||
expect(composeLayout("abc漢", 4)).toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
|
||||
});
|
||||
|
||||
it("lays a 90-glyph CJK draft at width 40 into five rows", () => {
|
||||
expect(composeLayout("寛".repeat(90), 40).rows).toBe(5);
|
||||
});
|
||||
|
||||
it("measures a ZWJ family emoji as one two-cell grapheme at the wrap column", () => {
|
||||
// Width 4: "abc" fills columns 0-2; the family emoji is one grapheme taking two
|
||||
// cells, does not fit the last column, and wraps whole — two rows, not three.
|
||||
expect(composeLayout("abc\u{1F468}\u200D\u{1F469}\u200D\u{1F467}", 4))
|
||||
.toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
|
||||
});
|
||||
|
||||
it("advances a tab to the next tab stop, not by zero cells", () => {
|
||||
expect(composeLayout("ab\tc", 40)).toEqual({ rows: 1, caretRow: 0, caretColumn: 9 });
|
||||
});
|
||||
|
||||
it("starts a new row at an explicit newline and puts the caret on that row", () => {
|
||||
expect(composeLayout("line1\nab", 40, 6)).toEqual({ rows: 2, caretRow: 1, caretColumn: 0 });
|
||||
expect(composeLayout("line1\nab", 40, 8)).toEqual({ rows: 2, caretRow: 1, caretColumn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/** A terminal double that models tmux's reflow: soft-wrapped rows join on widening,
|
||||
* paragraphs split on narrowing, and the cursor stays on its character. An ASCII-width
|
||||
* model, which is all these tests need. */
|
||||
export class ReflowScreen {
|
||||
private rows: { text: string; soft: boolean }[] = [{ text: "", soft: false }];
|
||||
private row = 0;
|
||||
private col = 0;
|
||||
columns: number;
|
||||
private handlers = new Set<() => void>();
|
||||
private view: object | null = null;
|
||||
constructor(width: number) { this.columns = width; }
|
||||
terminal(): NodeJS.WriteStream {
|
||||
if (this.view) return this.view as unknown as NodeJS.WriteStream;
|
||||
const self = this;
|
||||
this.view = {
|
||||
isTTY: true,
|
||||
write: (data: string | Buffer) => self.write(typeof data === "string" ? data : data.toString("utf8")),
|
||||
on: (_event: string, handler: () => void) => { self.handlers.add(handler); },
|
||||
get columns() { return self.columns; },
|
||||
};
|
||||
return this.view as unknown as NodeJS.WriteStream;
|
||||
}
|
||||
private ensure(row: number, soft: boolean) {
|
||||
while (this.rows.length <= row) this.rows.push({ text: "", soft: false });
|
||||
if (row > 0) this.rows[row]!.soft = soft;
|
||||
}
|
||||
private place(ch: string) {
|
||||
if (this.col >= this.columns) { this.row += 1; this.col = 0; }
|
||||
// A row reached by wrapping continues the paragraph above; one reached by a
|
||||
// newline was created hard by the \n handler.
|
||||
while (this.rows.length <= this.row) this.rows.push({ text: "", soft: this.row > 0 });
|
||||
const line = this.rows[this.row]!;
|
||||
this.rows[this.row] = { ...line, text: line.text.padEnd(this.col, " ").slice(0, this.col) + ch + line.text.slice(this.col + 1) };
|
||||
this.col += 1;
|
||||
}
|
||||
write(text: string) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i]!;
|
||||
if (ch === "\u001b") {
|
||||
const match = /^\u001b\[\??(\d*)([A-Za-z])/.exec(text.slice(i));
|
||||
if (match) {
|
||||
const count = match[1] ? Number(match[1]) : 1;
|
||||
if (match[2] === "A") this.row = Math.max(0, this.row - count);
|
||||
else if (match[2] === "B") this.row += count;
|
||||
else if (match[2] === "C") this.col += count;
|
||||
else if (match[2] === "K" && count === 2) this.rows[this.row] = { ...this.rows[this.row]!, text: "" };
|
||||
i += match[0].length - 1;
|
||||
} else i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\r") { this.col = 0; continue; }
|
||||
if (ch === "\n") { this.row += 1; this.ensure(this.row, false); this.col = 0; continue; }
|
||||
this.place(ch);
|
||||
}
|
||||
}
|
||||
/** Reflow to a new width the way tmux does, keeping the cursor on its character,
|
||||
* then tell the compositor the terminal resized. */
|
||||
reflowTo(width: number) {
|
||||
let start = this.row;
|
||||
while (start > 0 && this.rows[start]?.soft) start -= 1;
|
||||
let paragraph = 0;
|
||||
for (let i = 0; i < start; i += 1) if (!this.rows[i]?.soft) paragraph += 1;
|
||||
let offset = this.col;
|
||||
for (let i = start; i < this.row; i += 1) offset += this.rows[i]?.text.length ?? 0;
|
||||
const paragraphs: string[] = [];
|
||||
let joined: string | null = null;
|
||||
for (const line of this.rows) {
|
||||
if (line.soft && joined !== null) joined += line.text;
|
||||
else { if (joined !== null) paragraphs.push(joined); joined = line.text; }
|
||||
}
|
||||
if (joined !== null) paragraphs.push(joined);
|
||||
const rendered: { text: string; soft: boolean }[] = [];
|
||||
const startRow: number[] = [];
|
||||
paragraphs.forEach(text => {
|
||||
startRow.push(rendered.length);
|
||||
if (text === "") { rendered.push({ text: "", soft: false }); return; }
|
||||
for (let i = 0; i < text.length; i += width) rendered.push({ text: text.slice(i, i + width), soft: i > 0 });
|
||||
});
|
||||
this.rows = rendered;
|
||||
const text = paragraphs[paragraph] ?? "";
|
||||
const clamped = Math.min(offset, text.length);
|
||||
this.row = (startRow[paragraph] ?? 0) + Math.floor(clamped / width);
|
||||
this.col = clamped % width;
|
||||
this.columns = width;
|
||||
for (const handler of this.handlers) handler();
|
||||
}
|
||||
lines(): string[] { return this.rows.map(line => line.text.replace(/ +$/, "")); }
|
||||
}
|
||||
|
||||
// ── The compositor's transcript contract ─────────────────────────────────────
|
||||
|
||||
describe("pane compositor", () => {
|
||||
it("keeps draftless streaming byte-identical to the bare renderer", () => {
|
||||
const { output, text } = paneOut();
|
||||
const pane = createPaneCompositor(output);
|
||||
pane.transcript("The quick ");
|
||||
pane.transcript("brown fox\n");
|
||||
pane.line("jumps over");
|
||||
expect(text()).toBe("The quick brown fox\njumps over\n");
|
||||
});
|
||||
|
||||
it("writes complete streamed lines above a draft exactly once and holds the unfinished tail", () => {
|
||||
const { output, text } = paneOut();
|
||||
const pane = createPaneCompositor(output);
|
||||
pane.attach(() => "my draft", () => output.write("my draft"), () => 0);
|
||||
pane.transcript("streamed words here\npartial");
|
||||
const raw = text();
|
||||
expect(raw.match(/streamed words here/g)).toHaveLength(1);
|
||||
expect(raw).not.toContain("partial");
|
||||
pane.transcript(" tail\n");
|
||||
expect(text().match(/partial tail\n/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pins diagnostics to a status line, as a copy, and drops the pin on clearStatus", () => {
|
||||
const { output, text } = paneOut(60);
|
||||
const pane = createPaneCompositor(output);
|
||||
pane.attach(() => "draft", () => output.write("draft"), () => 0);
|
||||
pane.line("transcript line");
|
||||
pane.line("[pi-runner] ERROR quota reached, with long trailing advice for the operator");
|
||||
pane.diagnostic("[pi-runner] ERROR quota reached, with long trailing advice for the operator");
|
||||
expect(text()).toContain("[pi-runner] ERROR quota reached");
|
||||
pane.diagnostic("[pi-runner] second notice");
|
||||
expect(text().match(/\[pi-runner\] second notice/g)).toHaveLength(1);
|
||||
pane.detach();
|
||||
pane.clearStatus();
|
||||
const before = text().length;
|
||||
pane.transcript("plain tail\n");
|
||||
expect(text().slice(before)).toBe("plain tail\n");
|
||||
});
|
||||
|
||||
it("prints nothing itself when the width is unknown: the caller's transcript copy stands alone", () => {
|
||||
const { output, text } = paneOut(null);
|
||||
const pane = createPaneCompositor(output);
|
||||
pane.attach(() => "draft", () => output.write("draft"), () => 0);
|
||||
pane.line("[pi-runner] ERROR no width to pin into");
|
||||
pane.diagnostic("[pi-runner] ERROR no width to pin into");
|
||||
expect(text()).toBe("[pi-runner] ERROR no width to pin into\n");
|
||||
});
|
||||
|
||||
it("keeps READY and EXITED markers in the transcript, verbatim", () => {
|
||||
const { output, text } = paneOut();
|
||||
const pane = createPaneCompositor(output);
|
||||
pane.line("[pi-runner] READY session=/state/s.jsonl");
|
||||
pane.line("[pi-runner] EXITED pi exited (code 0)");
|
||||
expect(text()).toBe("[pi-runner] READY session=/state/s.jsonl\n[pi-runner] EXITED pi exited (code 0)\n");
|
||||
});
|
||||
|
||||
it("narrows without leaving a stale wrapped status behind, and keeps the transcript above", () => {
|
||||
const term = new ReflowScreen(120);
|
||||
const pane = createPaneCompositor(term.terminal());
|
||||
pane.line("SENTINEL-ABOVE");
|
||||
pane.diagnostic("S".repeat(110)); // a 110-cell status: one row at 120, two at 80
|
||||
term.reflowTo(80); // tmux reflows; the cursor keeps its character
|
||||
const lines = term.lines();
|
||||
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
|
||||
// The stale first-80-cells copy of the wrapped status must be gone.
|
||||
expect(lines.some(line => line === "S".repeat(80))).toBe(false);
|
||||
// The repainted pin is clipped to the new width instead.
|
||||
expect(lines.some(line => line === "S".repeat(79) + "\u2026")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it("keeps the sentinel when a long pinned status is resized twice", () => {
|
||||
const term = new ReflowScreen(120);
|
||||
const pane = createPaneCompositor(term.terminal());
|
||||
pane.line("SENTINEL-ABOVE");
|
||||
pane.diagnostic("S".repeat(110));
|
||||
term.reflowTo(80); // the pin is repainted clipped to 80 cells
|
||||
term.reflowTo(40); // sized from what is on screen (80 cells = 2 rows), not the 110-cell text
|
||||
const lines = term.lines();
|
||||
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
|
||||
expect(lines.some(line => line === "S".repeat(40))).toBe(false);
|
||||
});
|
||||
|
||||
it("widens a wrapped draft without erasing the transcript line above it", () => {
|
||||
const term = new ReflowScreen(40);
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true, isRaw: false,
|
||||
setRawMode: vi.fn(function (this: { isRaw: boolean }, value: boolean) { this.isRaw = value; }) });
|
||||
const output = term.terminal();
|
||||
const pane = createPaneCompositor(output);
|
||||
const editor = createRunnerInput(input as unknown as NodeJS.ReadStream, output, () => {}, pane);
|
||||
try {
|
||||
pane.line("SENTINEL-ABOVE");
|
||||
input.write("d".repeat(90)); // three draft rows at width 40
|
||||
input.write("\u001b[D".repeat(45)); // caret mid-draft
|
||||
term.reflowTo(120); // the draft joins into one row; the caret keeps its character
|
||||
const lines = term.lines();
|
||||
expect(lines.filter(line => line.includes("SENTINEL-ABOVE"))).toHaveLength(1);
|
||||
// One draft row repainted at the new width — not three, and not the sentinel.
|
||||
expect(lines.filter(line => line.replace(/ /g, "").startsWith("dddd")).length).toBe(1);
|
||||
} finally { editor.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── The compose line through the real line editor ────────────────────────────
|
||||
|
||||
describe("compose line through the real editor", () => {
|
||||
function terminal(columns = 80) {
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true, isRaw: false,
|
||||
setRawMode: vi.fn(function (this: { isRaw: boolean }, value: boolean) { this.isRaw = value; }) });
|
||||
const output = Object.assign(new PassThrough(), { isTTY: true, columns });
|
||||
const blocks: string[] = [];
|
||||
let screen = "";
|
||||
output.on("data", chunk => { screen += chunk.toString(); });
|
||||
const pane = createPaneCompositor(output as unknown as NodeJS.WriteStream);
|
||||
const editor = createRunnerInput(input as unknown as NodeJS.ReadStream,
|
||||
output as unknown as NodeJS.WriteStream, s => { blocks.push(s); }, pane);
|
||||
return { input, output, editor, pane, blocks, screen: () => screen };
|
||||
}
|
||||
|
||||
it("submits the full draft once, on readline's decoded line, with no partial copy left", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("hello compose");
|
||||
t.input.write("\r");
|
||||
expect(t.blocks).toEqual(["hello compose"]);
|
||||
const screen = t.screen();
|
||||
expect(screen).toContain("hello compose");
|
||||
expect(screen).toContain("\u001b[2K");
|
||||
expect(screen).not.toContain("hello compose\n");
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("streams around an active draft without fusing", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("draft line");
|
||||
t.pane.line("streamed model line");
|
||||
const screen = t.screen();
|
||||
expect(screen.match(/streamed model line\n/g)).toHaveLength(1);
|
||||
expect(screen).not.toContain("model linedraft");
|
||||
t.input.write("\r");
|
||||
expect(t.blocks).toEqual(["draft line"]);
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("measures CJK drafts in terminal cells, not UTF-16 characters", () => {
|
||||
const t = terminal(40);
|
||||
try {
|
||||
t.input.write("寛".repeat(90));
|
||||
t.input.write("x");
|
||||
expect(t.screen().match(/\u001b\[2K/g)).toHaveLength(5);
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("leaves meta-return to readline: no submit, together or split", () => {
|
||||
const together = terminal();
|
||||
try {
|
||||
together.input.write("abc");
|
||||
together.input.write("\u001b\r");
|
||||
expect(together.blocks).toEqual([]);
|
||||
} finally { together.editor.close(); }
|
||||
const split = terminal();
|
||||
try {
|
||||
split.input.write("abc");
|
||||
split.input.write("\u001b");
|
||||
split.input.write("\r");
|
||||
expect(split.blocks).toEqual([]);
|
||||
} finally { split.editor.close(); }
|
||||
});
|
||||
|
||||
it("clears the edit history with the submit: undo does not resurrect the draft", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("abc\r");
|
||||
expect(t.blocks).toEqual(["abc"]);
|
||||
t.input.write("\u001f"); // Ctrl-_ on the now-empty line
|
||||
t.input.write("\r");
|
||||
expect(t.blocks).toEqual(["abc"]);
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("flushes a held streamed tail verbatim when the draft is killed (Ctrl-U)", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("abc");
|
||||
t.pane.transcript("an unfinished streamed tail");
|
||||
expect(t.screen()).not.toContain("an unfinished streamed tail");
|
||||
t.input.write("\u0015"); // kill line: the draft goes away, the tail is written as-is
|
||||
expect(t.screen()).toContain("an unfinished streamed tail");
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("joins a flushed tail with the next delta instead of splitting mid-word", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("abc");
|
||||
t.pane.transcript("Hello wor");
|
||||
t.input.write("\u0015");
|
||||
t.pane.transcript("ld\n");
|
||||
expect(t.screen()).toContain("Hello world");
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("keeps coalesced edit keys and text ordered with the compositor attached", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("ac\u001b[Db\u00e9\u{1F98A}\r");
|
||||
expect(t.blocks).toEqual(["ab\u00e9\u{1F98A}c"]);
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
|
||||
it("keeps the flushed tail line open so new typing starts on its own line", () => {
|
||||
const t = terminal();
|
||||
try {
|
||||
t.input.write("abc");
|
||||
t.pane.transcript("Hello wor");
|
||||
t.input.write("\u0015"); // flush "Hello wor" verbatim, no newline
|
||||
t.input.write("x"); // new draft, no newline
|
||||
t.input.write("y"); // the next keystroke must not erase the tail line
|
||||
const screen = t.screen();
|
||||
expect(screen).toContain("Hello wor\n");
|
||||
expect(screen).not.toContain("Hello worx");
|
||||
} finally { t.editor.close(); }
|
||||
});
|
||||
|
||||
it("flushes a held streamed tail at EOF", () => {
|
||||
const t = terminal();
|
||||
t.input.write("x");
|
||||
t.pane.transcript("tail at exit");
|
||||
t.editor.close();
|
||||
expect(t.screen()).toContain("tail at exit\n");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Diagnostics reach the transcript, not only the status pin ────────────────
|
||||
|
||||
describe("runner diagnostics", () => {
|
||||
function coreWith() {
|
||||
const lines: string[] = [];
|
||||
const pins: string[] = [];
|
||||
const io: RunnerIo = {
|
||||
sendRpc: () => {},
|
||||
mirrorLine: (line) => lines.push(line),
|
||||
mirrorAppend: () => {},
|
||||
postActivity: () => {},
|
||||
writeSidecar: () => {},
|
||||
now: () => "2026-11-10T00:00:00Z",
|
||||
diagnostic: (text) => pins.push(text),
|
||||
clearStatus: () => pins.push(""),
|
||||
};
|
||||
const core = new RunnerCore(io, { sessionName: "seat-a@some-rig", nodeId: "node-1", launchId: "launch-1" });
|
||||
core.start();
|
||||
return { core, lines, pins };
|
||||
}
|
||||
|
||||
it("writes every diagnostic to the transcript and only pins the latest", () => {
|
||||
const { core, lines, pins } = coreWith();
|
||||
core.handlePiLine(JSON.stringify({
|
||||
type: "response", id: "pi-runner-get-state", success: false,
|
||||
error: "boom: the full provider setup advice follows at length",
|
||||
}));
|
||||
expect(lines.some(line => line.startsWith("[pi-runner] ERROR"))).toBe(true);
|
||||
expect(pins.at(-1)).toContain("the full provider setup advice follows at length");
|
||||
core.handlePiLine(JSON.stringify({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "second failure with different advice" } }));
|
||||
// The first failure's full text is still in the transcript history.
|
||||
expect(lines.some(line => line.includes("the full provider setup advice follows at length"))).toBe(true);
|
||||
expect(lines.some(line => line.includes("second failure with different advice"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the ERROR marker inside the pane's last 40 lines after later traffic", () => {
|
||||
const { core, lines } = coreWith();
|
||||
core.handlePiLine(JSON.stringify({
|
||||
type: "response", id: "pi-runner-get-state", success: false, error: "quota",
|
||||
}));
|
||||
for (let i = 0; i < 5; i += 1) core.handlePiLine(JSON.stringify({ type: "message_update", delta: "later traffic " }));
|
||||
const tail = lines.slice(-40);
|
||||
expect(tail.some(line => line.includes("[pi-runner] ERROR"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -499,7 +499,8 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
|
||||
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
@@ -686,6 +687,11 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
alive.add(seat.sessionName);
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
|
||||
expect(result).toMatchObject({
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
});
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -524,9 +524,143 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a seat-pane discovery row claimed by another node still refuses the handover (#237 holds)", async () => {
|
||||
function seedForeignPaneClaim(opts: { archived: boolean; status: "running" | "superseded" | "detached" | "exited" }) {
|
||||
const rig = rigRepo.createRig("seat-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "codex", cwd: "/project" });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-impl@seat-rig");
|
||||
sessionRegistry.updateStatus(session.id, opts.status);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9" });
|
||||
const discovered = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
|
||||
discoveryRepo.markClaimed(discovered.id, node.id);
|
||||
if (opts.archived) rigRepo.archiveRig(rig.id);
|
||||
return { rig, node, session, discovered };
|
||||
}
|
||||
|
||||
it.each(["fresh", "fork:dev-impl@seat-rig", "rebuild"])("%s reclaims the pane from an exited owner in an archived rig (#2861)", async (source) => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
const { node, sessionId } = seedSeat({ resumeToken: "native-abc" });
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { mutated: true, currentStatus: { handoverResult: "complete" } } });
|
||||
expect(liveSessionId(node.id)).not.toBe(sessionId);
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toMatchObject({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9" });
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
|
||||
});
|
||||
|
||||
it.each(["superseded", "detached"] as const)("reclaims the pane from a %s owner in an archived rig (#2861)", async (status) => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status });
|
||||
const { node } = seedSeat();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
|
||||
});
|
||||
|
||||
it("reclaims a pane claim whose owner node was deleted (#2861)", async () => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
db.prepare("DELETE FROM nodes WHERE id = ?").run(previous.node.id);
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: null });
|
||||
const { node } = seedSeat();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
|
||||
});
|
||||
|
||||
it("reclaims a pane recorded as claimed without a claimant (#2861)", async () => {
|
||||
const { node } = seedSeat();
|
||||
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
|
||||
db.prepare("UPDATE discovered_sessions SET status = 'claimed' WHERE id = ?").run(row.id);
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
|
||||
expect(discoveryRepo.getDiscoveredSession(row.id)).toMatchObject({ status: "claimed", claimedNodeId: node.id });
|
||||
});
|
||||
|
||||
it("an archived claim owner with a newer running session still refuses (#2861)", async () => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
const running = sessionRegistry.registerSession(previous.node.id, "dev-impl@seat-rig");
|
||||
sessionRegistry.updateStatus(running.id, "running");
|
||||
const { node, sessionId } = seedSeat();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
|
||||
expect(liveSessionId(node.id)).toBe(sessionId);
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
|
||||
});
|
||||
|
||||
it("an exited owner outside an archived rig still refuses (#2861)", async () => {
|
||||
const { rig, node, sessionId } = seedSeat();
|
||||
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex" });
|
||||
const exited = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
|
||||
sessionRegistry.updateStatus(exited.id, "exited");
|
||||
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
|
||||
discoveryRepo.markClaimed(row.id, other.id);
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
|
||||
expect(liveSessionId(node.id)).toBe(sessionId);
|
||||
expect(discoveryRepo.getDiscoveredSession(row.id)?.claimedNodeId).toBe(other.id);
|
||||
});
|
||||
|
||||
it("discovered-source adoption does not reclaim an archived owner's pane (#2861)", async () => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
const { node, sessionId } = seedSeat();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "adopt", source: `discovered:${previous.discovered.id}` });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
|
||||
expect(liveSessionId(node.id)).toBe(sessionId);
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
|
||||
});
|
||||
|
||||
it("does not reclaim when the archived owner resumes during the presence probe (#2861)", async () => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
const { node, sessionId } = seedSeat();
|
||||
hasSession.mockImplementation(async () => {
|
||||
sessionRegistry.updateStatus(previous.session.id, "running");
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
|
||||
expect(liveSessionId(node.id)).toBe(sessionId);
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(previous.node.id);
|
||||
});
|
||||
|
||||
it.each([false, true])("does not overwrite a new claim during the presence probe; original owner deleted=%s (#2861)", async (deleted) => {
|
||||
const previous = seedForeignPaneClaim({ archived: true, status: "exited" });
|
||||
if (deleted) db.prepare("DELETE FROM nodes WHERE id = ?").run(previous.node.id);
|
||||
const { rig, node, sessionId } = seedSeat();
|
||||
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex" });
|
||||
const running = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
|
||||
sessionRegistry.updateStatus(running.id, "running");
|
||||
hasSession.mockImplementation(async () => {
|
||||
discoveryRepo.markClaimed(previous.discovered.id, other.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "rebuild" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "discovered_not_active" });
|
||||
expect(liveSessionId(node.id)).toBe(sessionId);
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(other.id);
|
||||
});
|
||||
|
||||
it.each([false, true])("a foreign pane claim refuses with a running session present=%s (#237 holds)", async (runningSession) => {
|
||||
const { rig, node, sessionId } = seedSeat();
|
||||
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex", cwd: "/project" });
|
||||
if (runningSession) {
|
||||
const running = sessionRegistry.registerSession(other.id, "dev-other@seat-rig");
|
||||
sessionRegistry.updateStatus(running.id, "running");
|
||||
}
|
||||
const row = discoveryRepo.upsertDiscoveredSession({ tmuxSession: "dev-impl@seat-rig", tmuxPane: "%9", runtimeHint: "codex", confidence: "high" });
|
||||
discoveryRepo.markClaimed(row.id, other.id);
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
|
||||
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
|
||||
// even in a one-seat rig.
|
||||
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
const probed = seatFixture("s5-rig", "dev.flaky");
|
||||
tmux.failProbeFor(probed.sessionName);
|
||||
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
|
||||
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
if (shared.ok) throw new Error("expected refusal");
|
||||
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
|
||||
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
|
||||
|
||||
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
});
|
||||
|
||||
// ---- P5 / P6 — clean ----
|
||||
|
||||
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
|
||||
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
|
||||
// OpenRig didn't start running, so it isn't offered as the way to end this one.
|
||||
const guidance = (claimed as { guidance?: string }).guidance ?? "";
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
|
||||
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user