mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92b4f3c93 | ||
|
|
85b4ccf27a | ||
|
|
eb73d75edd | ||
|
|
1f30867775 |
No files matched your search
@@ -367,10 +367,7 @@ registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
|
||||
|
||||
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
|
||||
approval says how long and until when, in UTC. A target that reports who was asked (`person`) and who reads their answer (`owner`), or why
|
||||
nobody does yet (`noReader`), has `rig host pair` name them; an older target's reply keeps the general text. With
|
||||
nobody registered on the target, its refusal (409 `pair_human_required`) says to register a person there:
|
||||
`rig gateway human add <entityId> --display-name <name> --delivery-class B --default`. A target older than this CLI ignores the timeout and keeps the request
|
||||
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
|
||||
10 minutes.
|
||||
|
||||
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -218,5 +218,9 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
|
||||
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
|
||||
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
|
||||
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
|
||||
mission never counts. Counts are recomputed on every list request. `?refresh=1|true` invalidates the separate
|
||||
listing cache, not a stored queue-count snapshot. This resolution does not change the returned slice list or filters.
|
||||
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
|
||||
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
|
||||
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
|
||||
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
|
||||
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
|
||||
resolution does not change the returned slice list or filters.
|
||||
@@ -862,8 +862,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
// retry loop forever. Values that work today keep their wait. The target
|
||||
// keeps the request open as long as this side waits, and its approval says so.
|
||||
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
|
||||
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string;
|
||||
person?: string; owner?: string | null; ownerNote?: string; noReader?: string };
|
||||
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
|
||||
try {
|
||||
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
|
||||
requester: `${userInfo().username}@${osHostname()}`,
|
||||
@@ -885,14 +884,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
if (!opts.json) {
|
||||
console.log(`Pairing code: ${issued.code}`);
|
||||
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
|
||||
// A target that says who was asked and who reads the answer: name them. An older target keeps the general text.
|
||||
if (issued.person) {
|
||||
console.log(`The request went to ${issued.person} on the target.`);
|
||||
console.log(issued.owner
|
||||
? `${issued.owner} reads their answer and decides${issued.ownerNote ? ` (${issued.ownerNote}, so ${issued.owner} reads it instead)` : ""}.`
|
||||
// The target's own words: none reads answers, or it couldn't tell. Never a categorical "nobody" over an unknown.
|
||||
: `On the target: ${issued.noReader ?? "it didn't say whether an agent reads answers there"}. The request names how to decide directly on the target.`);
|
||||
} else console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,26 +100,6 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
|
||||
expect(yaml).not.toContain("issued-bearer");
|
||||
}, 15_000);
|
||||
|
||||
it("a target that says who was asked and who reads the answer is named; one where no agent reads says why", async () => {
|
||||
const approved = [{ status: 200, body: { status: "approved", token: "issued-bearer" } }];
|
||||
const owned = fakeDeps({ post: { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1", person: "alex@external", owner: "lead@rig" } }, poll: approved });
|
||||
const a = await capture(() => run(owned, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
|
||||
expect(a.out.join("\n")).toContain("The request went to alex@external on the target.\nlead@rig reads their answer and decides.");
|
||||
expect(a.out.join("\n")).not.toContain("On the target, its person answers the pairing request");
|
||||
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
|
||||
const free = fakeDeps({ post: { status: 200, body: { pairId: "p-2", code: "515151", approvalQitemId: "qitem-2", person: "alex@external", owner: null,
|
||||
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" } }, poll: approved });
|
||||
const b = await capture(() => run(free, ["pair", "vps-b:7433", "--id", "vps-b", "--timeout", "10"]));
|
||||
expect(b.out.join("\n")).toContain("On the target: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). The request names how to decide directly on the target.");
|
||||
// A target that couldn't tell is printed as unknown, never as "nobody reads".
|
||||
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
|
||||
const unknown = fakeDeps({ post: { status: 200, body: { pairId: "p-3", code: "616161", approvalQitemId: "qitem-3", person: "alex@external", owner: null,
|
||||
noReader: "OpenRig couldn't tell whether an agent reads answers on this host" } }, poll: approved });
|
||||
const c = await capture(() => run(unknown, ["pair", "vps-c:7433", "--id", "vps-c", "--timeout", "10"]));
|
||||
expect(c.out.join("\n")).toContain("On the target: OpenRig couldn't tell whether an agent reads answers on this host. The request names how to decide directly on the target.");
|
||||
expect(c.out.join("\n")).not.toContain("No agent reads answers there");
|
||||
}, 15_000);
|
||||
|
||||
it("--timeout reaches the target, which keeps the request open that long", async () => {
|
||||
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
|
||||
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// The seat boundary. A seat's kind comes from its binding's `attachment_type`, the key the seat-kinds facade in #1429
|
||||
// proposed (by rlch, credited). This first boundary covers today's kinds only: tmux, which is also the column's default
|
||||
// and what a legacy row with no value means, and external_cli. Each seat's three responsibilities stay where they are:
|
||||
// the runtime that keeps the process alive (RuntimeAdapter), the session that carries messages and turns to the agent
|
||||
// (SessionTransport, which asks this resolver), and the view a person watches and types into (TerminalProvider).
|
||||
|
||||
/** Today's seat kinds. */
|
||||
export type SeatKind = "tmux" | "external_cli";
|
||||
|
||||
/** A binding's kind as stored. `unknown` names a stored value that isn't one of today's kinds, and is never read as tmux. */
|
||||
export type ResolvedSeatKind = { kind: SeatKind } | { kind: "unknown"; stored: string };
|
||||
|
||||
export function resolveSeatKind(stored: string | null | undefined): ResolvedSeatKind {
|
||||
if (stored === null || stored === undefined || stored === "tmux") return { kind: "tmux" };
|
||||
if (stored === "external_cli") return { kind: "external_cli" };
|
||||
return { kind: "unknown", stored };
|
||||
}
|
||||
|
||||
/** How OpenRig's inbound transport (send, capture and the activity probe) reaches a seat of this kind. An external CLI
|
||||
* seat runs inside someone else's program, so OpenRig can't type into it or read it: `none`. An unknown stored kind
|
||||
* keeps the path it takes today, the tmux one, so nothing changes for it here; whether it should is an open question
|
||||
* for its owner, not something this boundary settles. */
|
||||
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
|
||||
return seat.kind === "external_cli" ? "none" : "tmux";
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
|
||||
import { isAttentionRequiredReadinessCode, type NodeBinding, type ReadinessOptions, type ReadinessResult } from "./runtime-adapter.js";
|
||||
import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverableStartupFailure } from "./startup-orchestrator.js";
|
||||
import { resolveSeatKind, seatInboundTransport } from "./seat-kinds.js";
|
||||
|
||||
// OPR.0.4.1.10 — send-readiness freshness. The runtime-hook store keeps a 5min freshness for activity
|
||||
// DISPLAY, but "safe to send NOW" needs a tight window: a stale "idle" read must not authorize a send
|
||||
@@ -448,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(";");
|
||||
@@ -466,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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -569,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;
|
||||
@@ -969,7 +981,7 @@ export async function probeSessionActivity(input: {
|
||||
};
|
||||
}
|
||||
const readTarget = binding?.pane ?? sessionName;
|
||||
if (attachmentType === "external_cli") {
|
||||
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
|
||||
return {
|
||||
state: "unknown",
|
||||
reason: "unsupported_attachment",
|
||||
@@ -2569,7 +2581,7 @@ export class SessionTransport {
|
||||
const binding = this.sessionRegistry.getBindingForNode(node.id);
|
||||
const latestSession = latestByNode.get(node.id);
|
||||
|
||||
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
|
||||
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
|
||||
targets.push({
|
||||
sessionName: binding.externalSessionName,
|
||||
rigName,
|
||||
@@ -2900,7 +2912,7 @@ export class SessionTransport {
|
||||
// External CLI seats never wait: main refuses them before anything else,
|
||||
// and a probe or wait would only delay that refusal (or mistake it for
|
||||
// an absent session).
|
||||
if (meta.attachmentType === "external_cli") {
|
||||
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
|
||||
return { ok: false, failure: {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -3094,7 +3106,7 @@ export class SessionTransport {
|
||||
const waitMode = waitForIdleMs !== undefined;
|
||||
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
|
||||
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -4360,7 +4372,7 @@ export class SessionTransport {
|
||||
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
|
||||
sessionName = physicalSessionName(this.db, sessionName);
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
|
||||
@@ -400,11 +400,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
}
|
||||
const body = (await c.req.json<{ requester?: string; human?: string; timeoutMs?: number }>().catch(() => ({}))) as { requester?: string; human?: string; timeoutMs?: number };
|
||||
const registry = (opts?.humanRegistry ?? loadHumanRegistry)();
|
||||
// Nobody registered on this host (never, or no longer): the fix is here, and the requester's CLI prints this.
|
||||
const noPerson = { error: "pair_human_required", addresses: [] as string[],
|
||||
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." };
|
||||
if (!registry.ok) return registry.noHumans ? c.json(noPerson, 409) : c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
|
||||
if (registry.entities.length === 0) return c.json(noPerson, 409);
|
||||
if (!registry.ok) return c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
|
||||
// Named, or else the default person (chosen, or the only one); never the first of several.
|
||||
const fallback = body.human ? null : resolveDefaultPerson(registry.entities).person;
|
||||
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : [];
|
||||
@@ -443,9 +439,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
return c.json({ error: "pair_approval_item_failed", message }, 500);
|
||||
}
|
||||
|
||||
// Who was asked, and who reads their answer here (or why nobody does yet), for the requester to show.
|
||||
return c.json({ pairId, code, approvalQitemId: qitemId, person: rec.person, owner: rec.owner,
|
||||
...(rec.owner ? (rec.ownerNote ? { ownerNote: rec.ownerNote } : {}) : { noReader: noReader(rec) }) });
|
||||
return c.json({ pairId, code, approvalQitemId: qitemId });
|
||||
});
|
||||
|
||||
router.get("/pair-request/:pairId", async (c) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -17,7 +17,6 @@ import net from "node:net";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { loadHumanRegistry } from "../src/domain/gateway/human-registry.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
@@ -461,35 +460,6 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
expect(repo.list({ limit: 100 })[0]?.destinationSession).toBe("blair@external");
|
||||
});
|
||||
|
||||
it("a target with nobody registered (never, or no longer) says so from the requester's side; several with no default keep today's text", async () => {
|
||||
const neverHome = mkdtempSync(join(tmpdir(), "pair-never-registered-"));
|
||||
try {
|
||||
expect(loadHumanRegistry(neverHome)).toMatchObject({ ok: false, noHumans: true });
|
||||
for (const registry of [() => loadHumanRegistry(neverHome), () => ({ ok: true as const, entities: [] })]) {
|
||||
const res = await buildApp(repo, BEARER, registry).request("/api/hosts/pair-request", { method: "POST", body: "{}" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({ error: "pair_human_required", addresses: [],
|
||||
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." });
|
||||
}
|
||||
expect(repo.list({ limit: 100 })).toHaveLength(0);
|
||||
} finally { rmSync(neverHome, { recursive: true, force: true }); }
|
||||
const several = await buildApp(repo, BEARER, () => ({ ok: true, entities: [human, { ...human, entityId: "blair", address: "blair@external" }] }))
|
||||
.request("/api/hosts/pair-request", { method: "POST", body: "{}" });
|
||||
expect(((await several.json()) as { message: string }).message).toContain("Pair approval needs one registered human, or a default when several exist.");
|
||||
});
|
||||
|
||||
it("the reply names who was asked and who reads their answer, or why nobody does yet", async () => {
|
||||
const res = await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) });
|
||||
expect(await res.json()).toMatchObject({ person: "alex@external", owner: null,
|
||||
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" });
|
||||
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@rig");
|
||||
try {
|
||||
const owned = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) })).json() as Record<string, unknown>;
|
||||
expect(owned).toMatchObject({ person: "alex@external", owner: "lead@rig" });
|
||||
expect(owned.noReader).toBeUndefined();
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("issuance mints the ONE human approval moment: a human-routed qitem with code + summary + evidence_ref", async () => {
|
||||
const create = vi.spyOn(repo, "create");
|
||||
const { code, approvalQitemId } = await issue();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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 } from "../src/domain/seat-kinds.js";
|
||||
|
||||
describe("resolveSeatKind", () => {
|
||||
it.each([
|
||||
[null, { kind: "tmux" }],
|
||||
[undefined, { kind: "tmux" }],
|
||||
["tmux", { kind: "tmux" }],
|
||||
["external_cli", { kind: "external_cli" }],
|
||||
["herdr", { kind: "unknown", stored: "herdr" }],
|
||||
["", { kind: "unknown", stored: "" }],
|
||||
["TMUX", { kind: "unknown", stored: "TMUX" }],
|
||||
] as const)("reads %j as %j", (stored, expected) => {
|
||||
expect(resolveSeatKind(stored)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("gives an external CLI seat no inbound transport, and every other kind today's tmux path", () => {
|
||||
expect(seatInboundTransport({ kind: "external_cli" })).toBe("none");
|
||||
expect(seatInboundTransport({ kind: "tmux" })).toBe("tmux");
|
||||
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
|
||||
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
|
||||
});
|
||||
});
|
||||
@@ -2741,6 +2741,27 @@ describe("SessionTransport", () => {
|
||||
expect(hasSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A stored kind that isn't one of today's (only reachable by writing the column directly) takes today's path, the tmux
|
||||
// one, for send and capture: the seat boundary adds no refusal and changes nothing for it.
|
||||
it("a binding whose stored kind isn't tmux or external_cli sends and captures through tmux, as before the boundary", async () => {
|
||||
const { node } = seedCanonicalRig();
|
||||
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(node.id);
|
||||
const callOrder: string[] = [];
|
||||
const tmux = mockTmux({
|
||||
sendText: async () => { callOrder.push("sendText"); return { ok: true }; },
|
||||
sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; },
|
||||
capturePaneContent: async () => "line1",
|
||||
});
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const sent = await transport.send("dev-impl@my-rig", "hello");
|
||||
expect(sent.reason).not.toBe("transport_unavailable");
|
||||
expect(callOrder).toContain("sendText");
|
||||
const captured = await transport.capture("dev-impl@my-rig");
|
||||
expect(captured.ok).toBe(true);
|
||||
expect(captured.content).toContain("line1");
|
||||
});
|
||||
|
||||
// Test 11: resolveSessions by rig returns running sessions
|
||||
it("resolveSessions by rig returns running sessions", async () => {
|
||||
seedCanonicalRig();
|
||||
|
||||
Reference in new issue
Block a user