mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92b4f3c93 | ||
|
|
85b4ccf27a |
No files matched your search
@@ -428,22 +428,24 @@ running seat only through the action in the last column.
|
||||
|
||||
### New guidance for a running seat
|
||||
|
||||
These reach a running seat without a restart, in the order they reach the agent:
|
||||
There are three ways for a seat to pick up new guidance:
|
||||
|
||||
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
|
||||
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
|
||||
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
|
||||
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
|
||||
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
|
||||
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
|
||||
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
|
||||
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
|
||||
hook's size threshold.
|
||||
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
|
||||
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
|
||||
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
|
||||
conversation grows past the hook's size threshold.
|
||||
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
|
||||
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
|
||||
|
||||
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
|
||||
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
|
||||
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
|
||||
file, a hook picking up, and the agent reading and using it are separate facts.
|
||||
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
|
||||
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
|
||||
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
|
||||
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
|
||||
separate facts.
|
||||
|
||||
Per harness: Claude Code's and Codex's permission choices are in the setup reference
|
||||
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
|
||||
|
||||
@@ -103,7 +103,6 @@ import { communityHarnessStopsRestore } from "./community-harness-presets.js";
|
||||
import { seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
|
||||
import { pluginPathsOf } from "./claude-core-plugin-env.js";
|
||||
import { seatLaunchAdditionsForLaunch, systemPromptPendingNoteForNode, type SeatLaunchAdditions } from "./seat-launch-additions.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
export { resolveActiveSnapshotSession } from "./active-occupant.js";
|
||||
export type { ActiveSnapshotSessionResolution } from "./active-occupant.js";
|
||||
|
||||
@@ -446,7 +445,7 @@ export class RestoreOrchestrator {
|
||||
for (const blocker of validation.blockers) {
|
||||
if (!SEAT_STARTUP_BLOCKERS.has(blocker.code) || !blocker.nodeId) continue;
|
||||
// A seat joined with `rig attach` as an external CLI isn't launched, so its startup files don't hold it.
|
||||
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType)) === "external") continue;
|
||||
if (this.sessionRegistry.getBindingForNode(blocker.nodeId)?.attachmentType === "external_cli") continue;
|
||||
const reason = `${blocker.message} ${blocker.remediation}`;
|
||||
heldForStartup.set(blocker.nodeId, heldForStartup.has(blocker.nodeId) ? `${heldForStartup.get(blocker.nodeId)} ${reason}` : reason);
|
||||
}
|
||||
@@ -714,7 +713,7 @@ export class RestoreOrchestrator {
|
||||
continue;
|
||||
}
|
||||
// An external CLI's claim has no tmux session to probe; restoreNodeWithCompensation leaves it attached.
|
||||
const external = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external";
|
||||
const external = this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli";
|
||||
const sessions = external ? [] : this.sessionRegistry.getSessionsForRig(rigId)
|
||||
.filter((s) => s.nodeId === node.id && s.status === "running");
|
||||
|
||||
@@ -829,7 +828,7 @@ export class RestoreOrchestrator {
|
||||
const holdReasonText = opts?.holdReason ?? "excluded_from_subset";
|
||||
for (const node of nonTargetNodes) {
|
||||
// A seat joined with `rig attach` as an external CLI has no tmux session to check, and restore leaves it attached.
|
||||
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(node.id)?.attachmentType)) === "external") continue;
|
||||
if (this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli") continue;
|
||||
const sessions = this.sessionRegistry.getSessionsForRig(rigId)
|
||||
.filter((s) => s.nodeId === node.id && s.status === "running");
|
||||
|
||||
@@ -905,7 +904,7 @@ export class RestoreOrchestrator {
|
||||
for (const session of this.sessionRegistry.getSessionsForRig(rigId)) {
|
||||
if (session.status !== "running" || (selectedIds && !selectedIds.has(session.nodeId))) continue;
|
||||
// An external CLI's claim has no tmux session; restore leaves it attached (restoreNodeWithCompensation).
|
||||
if (seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") continue;
|
||||
if (this.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") continue;
|
||||
try {
|
||||
// Replacement must preserve unknown transport state before any reconciliation write.
|
||||
if (selectedIds) {
|
||||
@@ -1111,7 +1110,7 @@ export class RestoreOrchestrator {
|
||||
|
||||
// A seat joined with `rig attach` as an external CLI keeps its attachment: launching here would replace it.
|
||||
const external = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
if (external && seatProcessOwner(resolveSeatKind(external.attachmentType)) === "external") {
|
||||
if (external?.attachmentType === "external_cli") {
|
||||
return { nodeId, logicalId: node.logicalId, status: "left_attached", error: leftAttachedReason(external.externalSessionName ?? node.logicalId) };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { communityHarnessStopsRestore } from "./community-harness-presets.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydrate-eligibility.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
|
||||
/** OPR.0.4.3.20 FR-6 — a present token whose last verification is older than this
|
||||
* is surfaced as `stale` (age-based staleness — the "stale while running" signal
|
||||
@@ -322,7 +321,7 @@ export function buildRestorePlanPreview(
|
||||
};
|
||||
const stoppedCurrent = selectedHistory?.nodeId !== node.id && sessionRows.some(row => row.nodeId === savedNodeId && row.stoppedCurrent);
|
||||
// Restore checks the live binding before anything else (restoreNodeWithCompensation), and so does its forecast.
|
||||
const attached = seatProcessOwner(resolveSeatKind(node.binding?.attachmentType)) === "external";
|
||||
const attached = node.binding?.attachmentType === "external_cli";
|
||||
const { intendedAction, reason } = attached
|
||||
? { intendedAction: "leave-attached" as const, reason: leftAttachedReason(node.binding?.externalSessionName ?? node.logicalId) }
|
||||
: runtimeError
|
||||
|
||||
@@ -16,7 +16,6 @@ import { findOtherSessionOwner } from "./session-owner.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID, SEAT_SKILLS_BOOTSTRAP_LINE, cleanSeatSkills, seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { settleAutomaticRestore } from "./existing-rig-restore.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
|
||||
export interface TeardownResult {
|
||||
rigId: string;
|
||||
@@ -189,7 +188,7 @@ export class RigTeardownOrchestrator {
|
||||
for (const session of liveSessions) {
|
||||
// OpenRig neither launches nor stops a seat joined with `rig attach` as an external CLI, and it has no tmux session:
|
||||
// a missing one isn't a stop. Keep its claim, as the historical path below does for non-tmux bindings.
|
||||
if (seatProcessOwner(resolveSeatKind(this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType)) === "external") {
|
||||
if (this.deps.sessionRegistry.getBindingForNode(session.nodeId)?.attachmentType === "external_cli") {
|
||||
external.add(session.nodeId);
|
||||
(result.leftRunning ??= []).push(session.sessionName);
|
||||
continue;
|
||||
|
||||
@@ -23,11 +23,3 @@ export function resolveSeatKind(stored: string | null | undefined): ResolvedSeat
|
||||
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
|
||||
return seat.kind === "external_cli" ? "none" : "tmux";
|
||||
}
|
||||
|
||||
/** Who runs a seat's process, which decides whether OpenRig's lifecycle (restore, fresh launch, teardown) acts on it. An
|
||||
* external CLI seat joined with `rig attach` runs in someone else's program: OpenRig neither launches nor stops it, and
|
||||
* leaves it attached. An unknown stored kind keeps the path it takes today, OpenRig's own; as for the inbound transport,
|
||||
* whether it should is a question for its owner. */
|
||||
export function seatProcessOwner(seat: ResolvedSeatKind): "openrig" | "external" {
|
||||
return seat.kind === "external_cli" ? "external" : "openrig";
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import { CommunityHarnessRuntimeAdapter, seatAdapter } from "../adapters/communi
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
|
||||
import { seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -1428,7 +1427,7 @@ export class SeatLifecycleService {
|
||||
const claimed = rows.find(row => row.origin === "claimed");
|
||||
if (claimed) {
|
||||
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
|
||||
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
|
||||
const externalCli = this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType === "external_cli";
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
@@ -2293,7 +2292,7 @@ export class SeatLifecycleService {
|
||||
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
|
||||
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
|
||||
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
|
||||
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
|
||||
this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType === "external_cli");
|
||||
}
|
||||
|
||||
private describe(resolved: ResolvedSeat): SeatDescriptor {
|
||||
|
||||
@@ -449,14 +449,18 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
|
||||
const codexFooter = (line: string): boolean => isComposerFooter(line)
|
||||
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
|
||||
|
||||
/** Preserve faint styling so autocomplete ghosts can be separated from authored
|
||||
* text. Color channel values must never be interpreted as style codes. */
|
||||
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
|
||||
* Color channel values must never be interpreted as style codes. */
|
||||
function styledTextLines(screen: string) {
|
||||
let faint = false;
|
||||
let faint = false, inverse = false;
|
||||
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
|
||||
let text = "", offset = 0;
|
||||
const faintCells: boolean[] = [];
|
||||
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
|
||||
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
|
||||
const append = (value: string) => {
|
||||
text += value;
|
||||
faintCells.push(...Array<boolean>(value.length).fill(faint));
|
||||
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
|
||||
};
|
||||
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
|
||||
append(raw.slice(offset, match.index));
|
||||
const codes = match[1]!.split(";");
|
||||
@@ -467,15 +471,18 @@ function styledTextLines(screen: string) {
|
||||
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
|
||||
// Color channels are not style codes. Ambiguous compound sequences
|
||||
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
|
||||
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
|
||||
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
|
||||
i += skip;
|
||||
} else if (code === "" || code === "0" || code === "22") faint = false;
|
||||
} else if (code === "" || code === "0") faint = inverse = false;
|
||||
else if (code === "22") faint = false;
|
||||
else if (code === "2") faint = true;
|
||||
else if (code === "7") inverse = true;
|
||||
else if (code === "27") inverse = false;
|
||||
}
|
||||
offset = match.index! + match[0].length;
|
||||
}
|
||||
append(raw.slice(offset));
|
||||
return { text, faintCells };
|
||||
return { text, faintCells, inverseCells };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,7 +577,11 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
|
||||
}
|
||||
const atStart = y === start && x === prefix;
|
||||
const empty = atStart && rows.length === 1 && rows[0] === "";
|
||||
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
|
||||
// that single cell at the start is padding; ordinary/preceding spaces stay input.
|
||||
const inverseCells = styled[start]!.inverseCells;
|
||||
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
|
||||
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
|
||||
const last = visible.length - 1;
|
||||
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
|
||||
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
|
||||
|
||||
@@ -162,6 +162,15 @@ describe("cursor-bound composer input", () => {
|
||||
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
|
||||
});
|
||||
|
||||
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
|
||||
const cursor = { x: 2, y: 2 };
|
||||
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
|
||||
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
|
||||
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
|
||||
}
|
||||
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
|
||||
});
|
||||
|
||||
it("requires the complete owned text and preserves significant spaces", () => {
|
||||
const input = inspectComposerInput(claude("Run two commands\nthen report."));
|
||||
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("submits a multiline paste under a combined update footer through real tmux", async () => {
|
||||
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
|
||||
const socket = join(scratch, "s");
|
||||
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
|
||||
@@ -143,8 +143,9 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
const db = new Database(":memory:");
|
||||
let transport: SessionTransport | undefined;
|
||||
try {
|
||||
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
|
||||
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
|
||||
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
|
||||
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
|
||||
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
|
||||
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
|
||||
@@ -157,7 +158,14 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
|
||||
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
|
||||
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
|
||||
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
|
||||
if (variant === "cursor-block") {
|
||||
await vi.waitFor(async () => {
|
||||
const snapshot = await adapter.captureComposerSnapshot(pane);
|
||||
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
|
||||
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
|
||||
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
|
||||
});
|
||||
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
|
||||
@@ -165,7 +173,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
|
||||
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
|
||||
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
|
||||
.toMatchObject({ outcome: "retained", sent: false });
|
||||
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
|
||||
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
|
||||
} finally {
|
||||
await transport?.guardedDelivery?.stop();
|
||||
|
||||
+7
-3
@@ -5,19 +5,23 @@ import { StringDecoder } from "node:string_decoder";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
|
||||
const cursorBlock = footerVariant === "cursor-block";
|
||||
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
|
||||
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
|
||||
const submissions = [];
|
||||
const decoder = new StringDecoder("utf8");
|
||||
|
||||
function render() {
|
||||
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
|
||||
if (runtime === "claude") rows.push("────────────────────────────────────────");
|
||||
if (cursorBlock) while (rows.length < 25) rows.push("");
|
||||
if (runtime === "claude") rows.push(rule);
|
||||
const start = rows.length;
|
||||
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
|
||||
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
|
||||
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
|
||||
const body = displayed.split("\n");
|
||||
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
|
||||
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
|
||||
rows.push(...(runtime === "claude" ? [rule, cursorBlock
|
||||
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
|
||||
if (runtime === "claude" && ghost) {
|
||||
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
|
||||
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
|
||||
|
||||
@@ -1135,23 +1135,6 @@ describe("RestoreOrchestrator", () => {
|
||||
])).toBe("fully_restored");
|
||||
});
|
||||
|
||||
it("a full restore launches a seat whose stored kind is neither tmux nor external CLI on OpenRig's own path, as it does today", async () => {
|
||||
const rig = rigRepo.createRig("r99");
|
||||
const worker = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
|
||||
const old = sessionRegistry.registerSession(worker.id, "r99-worker");
|
||||
sessionRegistry.updateStatus(old.id, "running");
|
||||
sessionRegistry.updateBinding(worker.id, { tmuxSession: "r99-worker" });
|
||||
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(worker.id);
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
|
||||
const tmux = { ...mockTmux(), hasSession: vi.fn(async () => false) } as unknown as TmuxAdapter;
|
||||
|
||||
const result = await createOrchestrator({ tmux }).restore(snap.id, { freshLogicalIds: ["worker"] });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.result.nodes).toEqual([expect.objectContaining({ logicalId: "worker", status: "fresh-primed" })]);
|
||||
});
|
||||
|
||||
it("a missing startup file doesn't hold a seat joined with rig attach as an external CLI, or the restore, since nothing launches it", async () => {
|
||||
const rig = rigRepo.createRig("r99");
|
||||
const desk = rigRepo.addNode(rig.id, "desk", { role: "worker", runtime: "claude-code" });
|
||||
|
||||
@@ -117,16 +117,6 @@ describe("FR-6 restore-plan token state", () => {
|
||||
expect(node.runtimePrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forecasts a seat whose stored kind is neither tmux nor external CLI on OpenRig's own path, as restore takes it today", () => {
|
||||
const rig = {
|
||||
rig: { id: "rig-1", name: "test-rig" },
|
||||
nodes: [{ id: "n1", logicalId: "desk", runtime: "claude-code", binding: { attachmentType: "herdr", externalSessionName: null } }],
|
||||
edges: [],
|
||||
} as unknown as RigWithRelations;
|
||||
const node = buildRestorePlanPreview(rig, null, [row("n1", { resumeType: null, resumeToken: null })]).nodes[0]!;
|
||||
expect(node.intendedAction).not.toBe("leave-attached");
|
||||
});
|
||||
|
||||
it("presents a never-occupied current seat as new, without a history warning", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "codex" }]);
|
||||
const result = buildRestorePlanPreview(rig, null, []).nodes[0]!;
|
||||
|
||||
@@ -980,20 +980,6 @@ describe("RigTeardownOrchestrator", () => {
|
||||
expect(result).toMatchObject({ sessionsKilled: 1, errors: [], deleted: del, leftRunning: ["desktop@test-rig"] });
|
||||
});
|
||||
|
||||
it("stops a seat whose stored kind is neither tmux nor external CLI, as it does today: only an external CLI seat is left running", async () => {
|
||||
const { rigId, nodeId } = seedRig();
|
||||
sessionRegistry.updateBinding(nodeId, { tmuxSession: "r01-dev" });
|
||||
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(nodeId);
|
||||
const tmux = mockTmux();
|
||||
tmux.probeSession = vi.fn(async (name: string) => ({ state: name === "r01-dev" ? "present" : "absent" }));
|
||||
|
||||
const result = await buildTeardown(tmux).teardown(rigId, { delete: false });
|
||||
|
||||
expect(tmux.killSession).toHaveBeenCalledWith("r01-dev");
|
||||
expect(result).toMatchObject({ sessionsKilled: 1, errors: [] });
|
||||
expect(result.leftRunning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the community-harness seats whose pane it stopped, since the harness's own processes may outlive it", async () => {
|
||||
const { rigId } = seedRigWithNode({ runtime: "claude-code", cwd: "/tmp" });
|
||||
const hermes = rigRepo.addNode(rigId, "hermes", { runtime: "synth-harness" });
|
||||
|
||||
@@ -512,17 +512,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names only the release route for an adopted seat joined with rig attach as an external CLI, since rig down leaves it running", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
sessionRegistry.updateBinding(seat.node.id, { attachmentType: "external_cli", externalSessionName: seat.sessionName });
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "fixture" });
|
||||
expect(result).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
if (result.ok) throw new Error("expected refusal");
|
||||
expect(result.guidance).toContain("rig unclaim 'dev-impl@fresh-rig'");
|
||||
expect(result.guidance).not.toContain("rig down");
|
||||
expect(tmux.killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps stop effects unknown when pane identity cannot be observed", async () => {
|
||||
const seat = seedSeat();
|
||||
vi.mocked(tmux.listPanes).mockRejectedValue(new Error("secret-pane-error"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSeatKind, seatInboundTransport, seatProcessOwner } from "../src/domain/seat-kinds.js";
|
||||
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
|
||||
|
||||
describe("resolveSeatKind", () => {
|
||||
it.each([
|
||||
@@ -21,11 +21,4 @@ describe("resolveSeatKind", () => {
|
||||
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
|
||||
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
|
||||
});
|
||||
|
||||
it("leaves an external CLI seat's process to its own program, and every other kind to OpenRig's lifecycle as today", () => {
|
||||
expect(seatProcessOwner({ kind: "external_cli" })).toBe("external");
|
||||
expect(seatProcessOwner({ kind: "tmux" })).toBe("openrig");
|
||||
// As for the transport: an unknown stored kind keeps today's path, and the resolver still names it unknown.
|
||||
expect(seatProcessOwner({ kind: "unknown", stored: "herdr" })).toBe("openrig");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user