@eir-labs/coltrane 0.24.30 → 0.24.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/change-verifier.json +4 -2
- package/agents/deploy-scout.json +2 -2
- package/agents/red-law-reviewer.json +54 -0
- package/agents/red-spec-attester.json +53 -0
- package/agents/red-spec-builder.json +56 -0
- package/agents/red-spec-drafter.json +1 -1
- package/agents/spec-reviewer.json +1 -1
- package/dist/src/chart.js +4 -0
- package/dist/src/chart.js.map +1 -1
- package/dist/src/chat_completions_port.d.ts +41 -0
- package/dist/src/chat_completions_port.js +221 -0
- package/dist/src/chat_completions_port.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +18 -0
- package/dist/src/claude_invoker.js +771 -22
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +49 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/completions_invoker.d.ts +79 -0
- package/dist/src/completions_invoker.js +220 -0
- package/dist/src/completions_invoker.js.map +1 -0
- package/dist/src/composition.d.ts +13 -0
- package/dist/src/composition.js +16 -0
- package/dist/src/composition.js.map +1 -1
- package/dist/src/genome_schema.d.ts +200 -0
- package/dist/src/genome_schema.js +37 -0
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_store.d.ts +30 -2
- package/dist/src/genome_store.js +51 -8
- package/dist/src/genome_store.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +9 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/invoker_selection.d.ts +58 -0
- package/dist/src/invoker_selection.js +110 -0
- package/dist/src/invoker_selection.js.map +1 -0
- package/dist/src/ledger.d.ts +45 -2
- package/dist/src/ledger.js +23 -3
- package/dist/src/ledger.js.map +1 -1
- package/dist/src/mcp.js +2 -2
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/outputs.d.ts +24 -0
- package/dist/src/outputs.js +1 -0
- package/dist/src/outputs.js.map +1 -1
- package/dist/src/registry.js +16 -0
- package/dist/src/registry.js.map +1 -1
- package/dist/src/repo_index.d.ts +13 -0
- package/dist/src/repo_index.js +90 -10
- package/dist/src/repo_index.js.map +1 -1
- package/dist/src/reside_backing.d.ts +3 -2
- package/dist/src/reside_backing.js +8 -2
- package/dist/src/reside_backing.js.map +1 -1
- package/dist/src/reuse.d.ts +8 -0
- package/dist/src/reuse.js.map +1 -1
- package/dist/src/run_deps.d.ts +10 -10
- package/dist/src/run_deps.js +25 -15
- package/dist/src/run_deps.js.map +1 -1
- package/dist/src/runtime.d.ts +205 -109
- package/dist/src/runtime.js +904 -145
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/server.js +148 -45
- package/dist/src/server.js.map +1 -1
- package/dist/src/server_relay.js +28 -0
- package/dist/src/server_relay.js.map +1 -1
- package/dist/src/skill_subprocess.d.ts +1 -0
- package/dist/src/skill_subprocess.js +5 -0
- package/dist/src/skill_subprocess.js.map +1 -1
- package/dist/src/transcript_store.d.ts +14 -0
- package/dist/src/transcript_store.js +39 -0
- package/dist/src/transcript_store.js.map +1 -0
- package/dist/src/turn_loop.d.ts +173 -0
- package/dist/src/turn_loop.js +283 -0
- package/dist/src/turn_loop.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/worker.js +6 -0
- package/dist/src/worker.js.map +1 -1
- package/dist/src/worker_env.d.ts +4 -2
- package/dist/src/worker_env.js +42 -0
- package/dist/src/worker_env.js.map +1 -1
- package/domain_types/change-set.json +33 -22
- package/domain_types/red-spec.json +32 -23
- package/domain_types/seat-primer.json +65 -0
- package/package.json +1 -1
- package/standards/build-from-red-spec-v0.json +93 -0
- package/standards/draft-red-laws-v0.json +68 -0
|
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { writeFileSync, unlinkSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
10
10
|
import { abortReasonText } from "./runtime.js";
|
|
11
11
|
import { resolveAgentGrants, hostBuiltinDenials, toolBaseName, ENGINE_MCP_SERVER } from "./tool_providers.js";
|
|
12
12
|
import { venueEffectiveTools } from "./chart.js";
|
|
@@ -110,12 +110,45 @@ export function buildPrompt(ctx, outputSchema,
|
|
|
110
110
|
// single object — the runtime then seals one record per key.
|
|
111
111
|
outputSchemas, seal) {
|
|
112
112
|
const a = ctx.agent;
|
|
113
|
+
// contract-amend-resume-prompt-v1 (O1/O2/I1) — a RESUMED amend carries ONLY what is new. The
|
|
114
|
+
// resumed conversation already holds # Disposition / # Identity / # Method / # Context and the gig
|
|
115
|
+
// input from round one, so re-sending them re-pays the whole cold read the resume exists to avoid
|
|
116
|
+
// (measured: the amend prompt was byte-for-byte the full round-one prompt). Emit a trimmed prompt:
|
|
117
|
+
// a short statement that this is an amend round of the same chair, plus the failing verdict's
|
|
118
|
+
// content (its pass:false and failing checks) — the one thing round one did not yet have. The cold
|
|
119
|
+
// fallback for a lost session re-invokes buildPrompt with resume OFF, so the FULL prompt is still
|
|
120
|
+
// reachable when the resume's conversation is gone (I2).
|
|
121
|
+
// contract-resumed-gig-session-v1 (O1) — a re-VERIFY resume keeps the FULL prompt: it re-invokes
|
|
122
|
+
// with `resume` (so buildInvokerArgs emits --resume) but `resume_keep_prompt` set, meaning it re-reads
|
|
123
|
+
// the amended tree under its own identity rather than being handed a trimmed "here is the one new
|
|
124
|
+
// input" continuation. The trimmed branch below is for a MAKER amend, whose one new thing IS the
|
|
125
|
+
// failing verdict; a stateless door (chat-completions) that holds no conversation must still carry the
|
|
126
|
+
// seat's identity, which the trim would strip — so a keep-prompt resume falls through to the full stack.
|
|
127
|
+
if (ctx.resume === true && ctx.resume_keep_prompt !== true) {
|
|
128
|
+
const failing = ctx.inputs.find((o) => o.data?.pass === false);
|
|
129
|
+
const verdictBlock = failing
|
|
130
|
+
? JSON.stringify(failing.data)
|
|
131
|
+
: "(the failing verdict was not carried into this amend round)";
|
|
132
|
+
return [
|
|
133
|
+
`# Amend round`,
|
|
134
|
+
`This is an amend round of the same "${ctx.role ?? a.slug}" chair, resuming the conversation ` +
|
|
135
|
+
`that already holds your disposition, identity, method, tools and the gig input. Fix ONLY ` +
|
|
136
|
+
`what the verify below caught, then re-seal your output exactly as you did before.`,
|
|
137
|
+
`# Failing verdict\nThe verify FAILED (pass: false). Its failing checks are what to fix:\n${verdictBlock}`,
|
|
138
|
+
].join("\n\n");
|
|
139
|
+
}
|
|
113
140
|
const layers = [];
|
|
114
141
|
// 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
|
|
115
142
|
const dispo = a.behavioral_primitives.map((r) => `- **${r}**: ${BELBIN_DESCRIPTIONS[r] ?? r}`).join("\n");
|
|
116
143
|
layers.push(`# Disposition\nYou hold these cognitive modes in equal tension:\n${dispo}\nHold every mode active throughout your work; none dominates.`);
|
|
117
|
-
// 2. Identity — who you are: the slug line plus the agent's own prose.
|
|
118
|
-
|
|
144
|
+
// 2. Identity — who you are: the slug line plus the agent's own prose. When the context carries
|
|
145
|
+
// a chair role, name the seat this invocation holds — and ONLY this seat. Two chairs seating the
|
|
146
|
+
// same agent in one phase share every other layer, so the seat line is what splits their prompts;
|
|
147
|
+
// without it the division of labour a standard declares between them exists only in the role
|
|
148
|
+
// names and each chair does the same work. A ctx without a role (hand-built literals, the
|
|
149
|
+
// text-seal path) renders no seat line, so those prompts stay valid and byte-identical.
|
|
150
|
+
const seatLine = ctx.role ? `\nYou are seated as the "${ctx.role}" chair in this phase.` : "";
|
|
151
|
+
layers.push(`# Identity\nYou are the agent "${a.slug}"${a.domain ? ` in the "${a.domain}" domain` : ""}.${seatLine}\n\n${a.identity}`);
|
|
119
152
|
// 3. Method — how THIS agent does its job, the step-by-step.
|
|
120
153
|
layers.push(`# Method\n${a.method}`);
|
|
121
154
|
// 4. Skills — content the agent's bound skills contribute to the prompt. Each
|
|
@@ -229,8 +262,57 @@ outputSchemas, seal) {
|
|
|
229
262
|
layers.push(`# Task\nProduce exactly one "${outType}".${schemaHint}\n` +
|
|
230
263
|
`Respond with ONLY a single JSON object (the output's data) — no prose, no code fence.`);
|
|
231
264
|
}
|
|
265
|
+
// contract-seat-primer-v1 (O4) — a FORK warm-starts from a primer that already read this area; the
|
|
266
|
+
// files whose working-tree blob CHANGED since priming are named here so the seat re-reads exactly
|
|
267
|
+
// those, not the whole area cold. An empty stale set adds nothing (the primed reading still holds).
|
|
268
|
+
if (ctx.fork?.stale_paths && ctx.fork.stale_paths.length > 0) {
|
|
269
|
+
layers.push(`# Changed since priming\nYou forked a primer that had already read this area. These files have ` +
|
|
270
|
+
`CHANGED since it was primed — RE-READ them before relying on them, the primer's copy is stale:\n` +
|
|
271
|
+
ctx.fork.stale_paths.map((p) => `- ${p}`).join("\n"));
|
|
272
|
+
}
|
|
232
273
|
return layers.join("\n\n");
|
|
233
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* contract-reverify-resume-prompt-v1 (O1) — the SHORT prompt a RESUMED Claude re-verify spawn carries.
|
|
277
|
+
* A re-verify RESUMES the verifier's own round-one conversation (`ctx.resume` + `ctx.resume_keep_prompt`),
|
|
278
|
+
* which already holds its disposition, identity, method, tools and the gig input, so this re-sends NONE
|
|
279
|
+
* of them — no buildPrompt layer, no gig input. It states only what is new: the makers AMENDED their
|
|
280
|
+
* work, so the verdict must be re-derived from the CURRENT working tree; plus the chair's output
|
|
281
|
+
* contract (its output type, and the in-band `output_write` seal directive when this door seals that way).
|
|
282
|
+
*
|
|
283
|
+
* The trim lives HERE, on the resuming Claude side, NOT in the shared buildPrompt: buildPrompt keeps the
|
|
284
|
+
* full prompt for a keep-prompt resume (its trim branch keys on `resume_keep_prompt !== true`), so the
|
|
285
|
+
* stateless chat-completions door — which builds via buildPrompt and never reaches this function — keeps
|
|
286
|
+
* the full prompt it needs to place the seat (O2), and the cold fallback for a lost resume re-sends the
|
|
287
|
+
* full prompt built with resume OFF (F1). By construction this is a small fraction of the round-one
|
|
288
|
+
* prompt (I1). buildPrompt's Task/seal directive is mirrored here rather than shared because the two
|
|
289
|
+
* shapes diverge in what they re-send: the full stack vs. only-what-is-new.
|
|
290
|
+
*/
|
|
291
|
+
function buildReverifyResumePrompt(sealTypes, outputSchema, outputSchemas, seal) {
|
|
292
|
+
const types = sealTypes.length ? sealTypes : ["output"];
|
|
293
|
+
const contract = seal
|
|
294
|
+
? `Re-seal each of your output types by calling the \`output_write\` tool — one call per type, ` +
|
|
295
|
+
`exactly as you did in round one:\n` +
|
|
296
|
+
types
|
|
297
|
+
.map((t) => {
|
|
298
|
+
const s = outputSchemas?.[t] ?? (types.length === 1 ? outputSchema : undefined);
|
|
299
|
+
const core = seal.core_by_type[t] ?? "";
|
|
300
|
+
return (`- output_write({ "core_type": "${core}", "domain_type": "${t}", ` +
|
|
301
|
+
`"gig_id": "${seal.gig_id}", "phase": "${seal.phase}", "agent_slug": "${seal.agent_slug}", ` +
|
|
302
|
+
`"data": <object${s ? ` matching ${JSON.stringify(s)}` : ""}> })`);
|
|
303
|
+
})
|
|
304
|
+
.join("\n")
|
|
305
|
+
: `Re-seal your output — ${types.map((t) => `"${t}"`).join(", ")} — exactly as you did in round one: ` +
|
|
306
|
+
`respond with ONLY the single JSON object (the output's data), no prose, no code fence.`;
|
|
307
|
+
return [
|
|
308
|
+
`# Re-verify (amend round)`,
|
|
309
|
+
`The makers AMENDED their work in response to your failing verdict. You are RESUMING the conversation ` +
|
|
310
|
+
`that already holds your disposition, identity, method, tools and the gig input, so this prompt ` +
|
|
311
|
+
`carries only what is new: re-derive your verdict from the CURRENT working tree — read the amended ` +
|
|
312
|
+
`artifact as it now stands, do not rely on what you saw in round one — and rule again.`,
|
|
313
|
+
`# Output contract\n${contract}`,
|
|
314
|
+
].join("\n\n");
|
|
315
|
+
}
|
|
234
316
|
// ───────────────────────── JSON extraction (#221, #226) ─────────────────────────
|
|
235
317
|
//
|
|
236
318
|
// The old implementation took "the first balanced brace run" — string-blind, anchored on
|
|
@@ -742,6 +824,181 @@ export function captureOutputWrites(stdout, sealTypes) {
|
|
|
742
824
|
}
|
|
743
825
|
return blob;
|
|
744
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* contract-seat-primer-v1 (O1/I2) — the file paths a PRIME seat Read, parsed from the run's stdout the
|
|
829
|
+
* SAME way `captureOutputWrites` parses its `output_write` calls: a `Read` tool_use's `file_path`. The
|
|
830
|
+
* primer's `files` list is the SET of files the seat read, so paths are de-duped preserving first
|
|
831
|
+
* appearance. Read through the RETURNED stdout (not the streaming `onEvent` path) because an injected
|
|
832
|
+
* `run` seam bypasses streaming — the sealer must see what the run actually returned.
|
|
833
|
+
*/
|
|
834
|
+
function captureReadPaths(stdout) {
|
|
835
|
+
const seen = new Set();
|
|
836
|
+
const out = [];
|
|
837
|
+
for (const raw of stdout.split("\n")) {
|
|
838
|
+
const line = raw.trim();
|
|
839
|
+
if (!line)
|
|
840
|
+
continue;
|
|
841
|
+
let e;
|
|
842
|
+
try {
|
|
843
|
+
e = JSON.parse(line);
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const type = typeof e["type"] === "string" ? e["type"] : "";
|
|
849
|
+
const msg = e["message"];
|
|
850
|
+
if ((type !== "assistant" && type !== "user") || !msg || typeof msg !== "object")
|
|
851
|
+
continue;
|
|
852
|
+
const content = msg.content ?? [];
|
|
853
|
+
for (const b of content) {
|
|
854
|
+
if (String(b["type"] ?? "") !== "tool_use" || String(b["name"] ?? "") !== "Read")
|
|
855
|
+
continue;
|
|
856
|
+
const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
|
|
857
|
+
const path = input["file_path"];
|
|
858
|
+
if (typeof path === "string" && path.length > 0 && !seen.has(path)) {
|
|
859
|
+
seen.add(path);
|
|
860
|
+
out.push(path);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* contract-rolling-seat-primer-v1 (O3) — the context size (input + cache_read + cache_creation) of the
|
|
868
|
+
* LAST assistant usage in the run's stdout, parsed the SAME way `captureReadPaths` parses its Read
|
|
869
|
+
* events. Every seat-primer records this as `context_tokens`, so the next build's `max_context_tokens`
|
|
870
|
+
* ceiling can weigh the primer. Read through the RETURNED stdout (not the streaming `onEvent` path)
|
|
871
|
+
* because an injected `run` seam bypasses streaming — the sealer must see what the run actually
|
|
872
|
+
* returned. `undefined` when the run reported no usage (the runtime then falls back to its own
|
|
873
|
+
* streamed `lastAssistantContext`, or 0).
|
|
874
|
+
*/
|
|
875
|
+
function captureLastContext(stdout) {
|
|
876
|
+
let last;
|
|
877
|
+
for (const raw of stdout.split("\n")) {
|
|
878
|
+
const line = raw.trim();
|
|
879
|
+
if (!line)
|
|
880
|
+
continue;
|
|
881
|
+
let e;
|
|
882
|
+
try {
|
|
883
|
+
e = JSON.parse(line);
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (String(e["type"] ?? "") !== "assistant")
|
|
889
|
+
continue;
|
|
890
|
+
const msg = e["message"];
|
|
891
|
+
if (!msg || typeof msg !== "object")
|
|
892
|
+
continue;
|
|
893
|
+
const usage = msg.usage;
|
|
894
|
+
if (usage && typeof usage === "object") {
|
|
895
|
+
last = (usage["input_tokens"] ?? 0) + (usage["cache_read_input_tokens"] ?? 0) + (usage["cache_creation_input_tokens"] ?? 0);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return last;
|
|
899
|
+
}
|
|
900
|
+
/** The write tools whose first call ends a seat's READING (contract-primer-reading-frontier-v1 O1). */
|
|
901
|
+
const FRONTIER_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
902
|
+
/**
|
|
903
|
+
* The input+cache_read+cache_creation of an assistant line's usage, or undefined when it carries none.
|
|
904
|
+
* contract-fork-continuation-is-exact-v1 (O2) — a usage reporting NONE of the three context fields
|
|
905
|
+
* (e.g. a write line carrying only `output_tokens`) contributes NO reading: it returns `undefined`, not
|
|
906
|
+
* `0`, so `usage ?? lastUsageBefore` falls through to the last REPORTED reading rather than discarding
|
|
907
|
+
* it. A usage reporting ANY of the three sums the ones present (a missing field as `0`), as today.
|
|
908
|
+
*/
|
|
909
|
+
function assistantContextOf(msg) {
|
|
910
|
+
const u = msg?.usage;
|
|
911
|
+
if (!u || typeof u !== "object")
|
|
912
|
+
return undefined;
|
|
913
|
+
if (u["input_tokens"] === undefined && u["cache_read_input_tokens"] === undefined && u["cache_creation_input_tokens"] === undefined) {
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
return (u["input_tokens"] ?? 0) + (u["cache_read_input_tokens"] ?? 0) + (u["cache_creation_input_tokens"] ?? 0);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* contract-primer-reading-frontier-v1 (O1/O3/O4/I2/I3/F1) — a seat-primer is forked at its READING
|
|
920
|
+
* FRONTIER: the point where the seat stopped reading and started writing. Parsed over the concatenated
|
|
921
|
+
* seal stdout (every spawn, in order — so a first write in a reserve continuation is found, I2):
|
|
922
|
+
* · `frontier` — the uuid of the last `type:"user"` line before the FIRST assistant line whose content
|
|
923
|
+
* calls a write tool (Write/Edit/MultiEdit/NotebookEdit). No write, or no user line before it (F1),
|
|
924
|
+
* yields no frontier.
|
|
925
|
+
* · `context_tokens` — WITH a frontier, the context of that first-write assistant line (its own usage,
|
|
926
|
+
* or the last usage before it); WITHOUT one, the seat's LAST usage exactly as today (I3/F1).
|
|
927
|
+
* · `reads` — WITH a frontier, the Read paths on assistant lines BEFORE the FRONTIER line — the reads
|
|
928
|
+
* the cut conversation holds. contract-fork-continuation-is-exact-v1 (O3): the frontier is the last
|
|
929
|
+
* `user` line before the first write, so a Read in the write's OWN turn (after that user line, with
|
|
930
|
+
* no user line between it and the write) is AFTER the frontier and is NOT recorded. Reads since the
|
|
931
|
+
* last user line are PENDING; a user line commits them, and the first write discards the pending set.
|
|
932
|
+
* WITHOUT a frontier, every Read as today.
|
|
933
|
+
* Derived by the engine from the stream, never typed by the model.
|
|
934
|
+
*/
|
|
935
|
+
function captureReadingFrontier(stdout) {
|
|
936
|
+
let lastUserUuid;
|
|
937
|
+
let lastUsageBefore;
|
|
938
|
+
const readsBefore = []; // reads BEFORE the frontier line (committed at each user line)
|
|
939
|
+
let pendingReads = []; // reads SINCE the last user line — not yet before a frontier
|
|
940
|
+
const seen = new Set();
|
|
941
|
+
let frontier;
|
|
942
|
+
let firstWriteContext;
|
|
943
|
+
let foundWrite = false;
|
|
944
|
+
const commitPending = () => {
|
|
945
|
+
for (const p of pendingReads)
|
|
946
|
+
if (!seen.has(p)) {
|
|
947
|
+
seen.add(p);
|
|
948
|
+
readsBefore.push(p);
|
|
949
|
+
}
|
|
950
|
+
pendingReads = [];
|
|
951
|
+
};
|
|
952
|
+
for (const raw of stdout.split("\n")) {
|
|
953
|
+
const line = raw.trim();
|
|
954
|
+
if (!line)
|
|
955
|
+
continue;
|
|
956
|
+
let e;
|
|
957
|
+
try {
|
|
958
|
+
e = JSON.parse(line);
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const type = typeof e["type"] === "string" ? e["type"] : "";
|
|
964
|
+
if (type === "user") {
|
|
965
|
+
if (typeof e["uuid"] === "string")
|
|
966
|
+
lastUserUuid = e["uuid"];
|
|
967
|
+
// reads seen since the last user line are now BEFORE a user line ⇒ before any later frontier (O3)
|
|
968
|
+
commitPending();
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const msg = e["message"];
|
|
972
|
+
if (type !== "assistant" || !msg || typeof msg !== "object")
|
|
973
|
+
continue;
|
|
974
|
+
const content = msg.content ?? [];
|
|
975
|
+
const usage = assistantContextOf(msg);
|
|
976
|
+
if (content.some((b) => String(b["type"] ?? "") === "tool_use" && FRONTIER_WRITE_TOOLS.has(String(b["name"] ?? "")))) {
|
|
977
|
+
foundWrite = true;
|
|
978
|
+
frontier = lastUserUuid; // undefined ⇒ no user line before the write ⇒ no frontier (F1)
|
|
979
|
+
firstWriteContext = usage ?? lastUsageBefore;
|
|
980
|
+
// pendingReads (this write's OWN turn, after the frontier) are DISCARDED — the cut ends before them
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
for (const b of content) {
|
|
984
|
+
if (String(b["type"] ?? "") !== "tool_use" || String(b["name"] ?? "") !== "Read")
|
|
985
|
+
continue;
|
|
986
|
+
const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
|
|
987
|
+
const path = input["file_path"];
|
|
988
|
+
if (typeof path === "string" && path.length > 0 && !seen.has(path) && !pendingReads.includes(path)) {
|
|
989
|
+
pendingReads.push(path);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (usage !== undefined)
|
|
993
|
+
lastUsageBefore = usage;
|
|
994
|
+
}
|
|
995
|
+
// A frontier requires BOTH a write AND a user line before it. Otherwise the primer is as today:
|
|
996
|
+
// the last usage over the whole run, and every Read.
|
|
997
|
+
if (foundWrite && frontier !== undefined) {
|
|
998
|
+
return { frontier, context_tokens: firstWriteContext, reads: readsBefore };
|
|
999
|
+
}
|
|
1000
|
+
return { context_tokens: captureLastContext(stdout), reads: captureReadPaths(stdout) };
|
|
1001
|
+
}
|
|
745
1002
|
// The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
|
|
746
1003
|
// terminus (it can search/loop), and the gig runs the spawn synchronously — so without
|
|
747
1004
|
// this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
|
|
@@ -849,6 +1106,145 @@ export function promptViaStdin(prompt) {
|
|
|
849
1106
|
return true;
|
|
850
1107
|
return prompt.length > promptArgLimit();
|
|
851
1108
|
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The `claude` session id a chair's conversation is NAMED by, derived deterministically from
|
|
1111
|
+
* `(gig_id, role)`. The same seat used twice in one gig derives the SAME uuid, so its second reach
|
|
1112
|
+
* can `--resume` the conversation the first opened instead of re-reading cold; two roles in one gig,
|
|
1113
|
+
* or one role across two gigs, never collide (a v5-shaped uuid over a `gig_id\x1frole` string).
|
|
1114
|
+
*
|
|
1115
|
+
* A gig-less invocation (no `gig_id`) returns undefined: with nothing to key the session on there is
|
|
1116
|
+
* nothing to name and nothing to resume, so the spawn carries neither `--session-id` nor `--resume`.
|
|
1117
|
+
* The uuid is a valid RFC 4122 string (version nibble 5, variant 8–b) so the CLI accepts it as a
|
|
1118
|
+
* session id and the spec's UUID shape holds.
|
|
1119
|
+
*/
|
|
1120
|
+
export function sessionUuidFor(gig_id, role) {
|
|
1121
|
+
if (!gig_id)
|
|
1122
|
+
return undefined;
|
|
1123
|
+
const h = createHash("sha1").update(`coltrane-chair-session${gig_id}${role ?? ""}`).digest();
|
|
1124
|
+
h[6] = (h[6] & 0x0f) | 0x50; // version 5
|
|
1125
|
+
h[8] = (h[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
1126
|
+
const hex = h.subarray(0, 16).toString("hex");
|
|
1127
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Rewrite a built arg list to RESUME a session rather than open one: it must carry EXACTLY ONE
|
|
1131
|
+
* `--resume <uuid>` naming the session to CONTINUE, and none of the flags that open or fork a fresh
|
|
1132
|
+
* conversation. A resume must not ALSO name a fresh session (`--session-id`, which the CLI reads as
|
|
1133
|
+
* opening, not continuing), nor re-run a warm start: for a FORK chair `baseArgs` is the first-spawn
|
|
1134
|
+
* warm start `--resume <primer> --resume-session-at <frontier> --fork-session --session-id <own>`
|
|
1135
|
+
* (contract-fork-continuation-is-exact-v1 O1), and a continuation of that chair's OWN session carries
|
|
1136
|
+
* none of it — the warm start belongs to the FIRST spawn only. So every `--session-id`,
|
|
1137
|
+
* `--resume-session-at` and inherited `--resume` PAIR is dropped (flag and value), `--fork-session`
|
|
1138
|
+
* (valueless) is dropped, and a single `--resume <sessionId>` is appended.
|
|
1139
|
+
*/
|
|
1140
|
+
function withResume(args, sessionId) {
|
|
1141
|
+
const out = [];
|
|
1142
|
+
for (let i = 0; i < args.length; i++) {
|
|
1143
|
+
// drop each opening/warm-start flag AND its value — a continuation resumes ONE session and no more
|
|
1144
|
+
if (args[i] === "--session-id" || args[i] === "--resume" || args[i] === "--resume-session-at") {
|
|
1145
|
+
i++;
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (args[i] === "--fork-session")
|
|
1149
|
+
continue; // valueless flag: the warm start belongs to the first spawn
|
|
1150
|
+
out.push(args[i]);
|
|
1151
|
+
}
|
|
1152
|
+
out.push("--resume", sessionId);
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Did a `--resume` run fail because its session could not be found? The CLI reports a missing resume
|
|
1157
|
+
* target as an error result whose text names it ("No conversation found with session ID …"). Read
|
|
1158
|
+
* from the child's stream so it is caught whether the run resolved with the error result or threw a
|
|
1159
|
+
* non-zero exit carrying the same stdout. Deliberately narrow — a result event's text, not any line —
|
|
1160
|
+
* so an ordinary failure is never mistaken for a lost session (F1).
|
|
1161
|
+
*/
|
|
1162
|
+
function resumeSessionLost(stdout) {
|
|
1163
|
+
for (const raw of stdout.split("\n")) {
|
|
1164
|
+
const line = raw.trim();
|
|
1165
|
+
if (!line)
|
|
1166
|
+
continue;
|
|
1167
|
+
let e;
|
|
1168
|
+
try {
|
|
1169
|
+
e = JSON.parse(line);
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (e["type"] !== "result")
|
|
1175
|
+
continue;
|
|
1176
|
+
const txt = typeof e["result"] === "string" ? e["result"] : "";
|
|
1177
|
+
if (/no conversation found|no such session|session .*not found/i.test(txt))
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* contract-primer-reading-frontier-v1 (F2) — did a fork fail because its `--resume-session-at` named a
|
|
1184
|
+
* message uuid the primer's session does not hold? claude 2.1.274 (probed 2026-09-18) exits 1 with the
|
|
1185
|
+
* notice `No message found with message.uuid of: <uuid>` on stderr (so in the ChildExitError message),
|
|
1186
|
+
* and a stdout result event that carries it ONLY in `errors: [...]` — there is no `result` text, so
|
|
1187
|
+
* `resumeSessionLost` (which reads `result` alone) cannot see it. Detect it in EITHER place, next to
|
|
1188
|
+
* `resumeSessionLost`, so the unresolvable-frontier fork takes the cold fallback rather than failing.
|
|
1189
|
+
*/
|
|
1190
|
+
function frontierNotFound(stdout, message) {
|
|
1191
|
+
const re = /no message found with message\.uuid of/i;
|
|
1192
|
+
if (re.test(message))
|
|
1193
|
+
return true;
|
|
1194
|
+
for (const raw of stdout.split("\n")) {
|
|
1195
|
+
const line = raw.trim();
|
|
1196
|
+
if (!line)
|
|
1197
|
+
continue;
|
|
1198
|
+
let e;
|
|
1199
|
+
try {
|
|
1200
|
+
e = JSON.parse(line);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (e["type"] !== "result")
|
|
1206
|
+
continue;
|
|
1207
|
+
const errs = e["errors"];
|
|
1208
|
+
if (Array.isArray(errs) && errs.some((x) => typeof x === "string" && re.test(x)))
|
|
1209
|
+
return true;
|
|
1210
|
+
const txt = typeof e["result"] === "string" ? e["result"] : "";
|
|
1211
|
+
if (re.test(txt))
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Did a spawn fail because the `--session-id` it opened with is ALREADY IN USE? A chair's session id
|
|
1218
|
+
* is deterministic in `(gig_id, role)`, so a KILLED attempt that already opened it leaves the id live;
|
|
1219
|
+
* a resumed gig's first spawn re-opens it and the CLI refuses it (`Error: Session ID <uuid> is already
|
|
1220
|
+
* in use.`). Reported both as an error result in the stream AND in the non-zero exit message, so read
|
|
1221
|
+
* both — the exit `message` and the result stream (like `resumeSessionLost`) — and catch it whichever
|
|
1222
|
+
* carries it. This is the COLLISION case the LOST case (`resumeSessionLost`) does NOT cover; the two
|
|
1223
|
+
* are disjoint (one names "already in use", the other "no conversation found").
|
|
1224
|
+
*/
|
|
1225
|
+
function sessionIdInUse(stdout, message) {
|
|
1226
|
+
const inUse = /session id .*is already in use/i;
|
|
1227
|
+
if (inUse.test(message))
|
|
1228
|
+
return true;
|
|
1229
|
+
for (const raw of stdout.split("\n")) {
|
|
1230
|
+
const line = raw.trim();
|
|
1231
|
+
if (!line)
|
|
1232
|
+
continue;
|
|
1233
|
+
let e;
|
|
1234
|
+
try {
|
|
1235
|
+
e = JSON.parse(line);
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (e["type"] !== "result")
|
|
1241
|
+
continue;
|
|
1242
|
+
const txt = typeof e["result"] === "string" ? e["result"] : "";
|
|
1243
|
+
if (inUse.test(txt))
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
852
1248
|
export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
|
|
853
1249
|
// `-p` is a BOOLEAN flag and the prompt is a POSITIONAL argument, which is what makes the
|
|
854
1250
|
// large-prompt path clean: keep the flag, drop the positional, write it to stdin. The
|
|
@@ -857,9 +1253,38 @@ export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
|
|
|
857
1253
|
const args = promptViaStdin(prompt) ? ["-p"] : ["-p", prompt];
|
|
858
1254
|
if (opts.model)
|
|
859
1255
|
args.push("--model", opts.model);
|
|
1256
|
+
// contract-chair-session-continuity-v1 — NAME the chair's conversation so a second reach can
|
|
1257
|
+
// resume it. Every spawn with a session id opens one with `--session-id`; a re-invocation that
|
|
1258
|
+
// is resuming (the amend round) carries `--resume` instead — never both, or the CLI opens a
|
|
1259
|
+
// fresh session rather than continuing. A gig-less spawn has no session id and carries neither.
|
|
1260
|
+
// contract-seat-primer-v1 (O2) — a FORK chair's FIRST spawn WARM-STARTS: it `--resume`s the primer
|
|
1261
|
+
// seat's session, `--fork-session`s that conversation into a NEW branch, and names THAT branch with
|
|
1262
|
+
// its own (gig, role) `--session-id`. Distinct from an amend `--resume` (which continues the SAME
|
|
1263
|
+
// session under the same id): a fork opens its own session forked FROM another's.
|
|
1264
|
+
if (opts.fork_from_session && opts.session_id) {
|
|
1265
|
+
args.push("--resume", opts.fork_from_session);
|
|
1266
|
+
// contract-primer-reading-frontier-v1 (O2) — a fork of a primer that recorded a reading FRONTIER
|
|
1267
|
+
// CUTS the resumed conversation there: `--resume-session-at <frontier>` starts the branch with the
|
|
1268
|
+
// conversation as it stood after that message, so the fork loads what the primer READ, never what
|
|
1269
|
+
// it then DID. A frontier-less primer carries none, so the whole session resumes exactly as today.
|
|
1270
|
+
if (opts.resume_session_at)
|
|
1271
|
+
args.push("--resume-session-at", opts.resume_session_at);
|
|
1272
|
+
args.push("--fork-session", "--session-id", opts.session_id);
|
|
1273
|
+
}
|
|
1274
|
+
else if (opts.session_id) {
|
|
1275
|
+
if (opts.resume)
|
|
1276
|
+
args.push("--resume", opts.session_id);
|
|
1277
|
+
else
|
|
1278
|
+
args.push("--session-id", opts.session_id);
|
|
1279
|
+
}
|
|
860
1280
|
// per-agent blast-radius cap: a runaway agent can't burn past its own turn budget.
|
|
861
1281
|
if (opts.max_tool_calls !== undefined)
|
|
862
1282
|
args.push("--max-turns", String(opts.max_tool_calls));
|
|
1283
|
+
// #seat-effort (O3/I2) — the spawn ALWAYS carries exactly one --effort, whatever depth or turn
|
|
1284
|
+
// budget applies. Floored to `medium` here so a hand-built ctx that resolved to no effort still
|
|
1285
|
+
// gets an explicit level rather than inheriting the operator's ~/.claude/settings.json effortLevel
|
|
1286
|
+
// — the measured defect. The invoke door passes the resolved `ctx.effort ?? "medium"`.
|
|
1287
|
+
args.push("--effort", opts.effort ?? "medium");
|
|
863
1288
|
// the cage floor: no ambient MCP servers leak into the spawn, ever.
|
|
864
1289
|
args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
|
|
865
1290
|
// The OTHER half of that floor, and it was missing. A seat's cwd is a freshly cloned repository
|
|
@@ -878,6 +1303,15 @@ export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
|
|
|
878
1303
|
//
|
|
879
1304
|
// This is what makes one gig's write to a repo stop being every later gig's execution.
|
|
880
1305
|
args.push("--setting-sources", "user");
|
|
1306
|
+
// contract-seat-memory-v1 (O1/I1) — a seat's writes are its grants; the operator's auto-memory is
|
|
1307
|
+
// NOT a seat's to write. Measured: a `claude -p` seat spawned as chairs are (`--setting-sources
|
|
1308
|
+
// user`) wrote ~/.claude/projects/<repo>/memory/, loaded by every later session in the repo.
|
|
1309
|
+
// `--setting-sources user` bounds which settings FILES load; it does not turn the auto-memory tool
|
|
1310
|
+
// off. Pass exactly ONE `--settings` whose JSON disables it, here at the single point every spawn
|
|
1311
|
+
// kind is built from (first run, resumed amend, reserve continuation, cold fallback), so the pair
|
|
1312
|
+
// rides through every arg-list transform. Disjoint from --setting-sources / --effort / the session
|
|
1313
|
+
// flags, so the effort and session-continuity contracts are untouched.
|
|
1314
|
+
args.push("--settings", JSON.stringify({ autoMemoryEnabled: false }));
|
|
881
1315
|
if (opts.allowed_tools && opts.allowed_tools.length > 0)
|
|
882
1316
|
args.push("--allowedTools", opts.allowed_tools.join(","));
|
|
883
1317
|
if (opts.disallowed_tools && opts.disallowed_tools.length > 0)
|
|
@@ -1090,7 +1524,25 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1090
1524
|
core_by_type: Object.fromEntries(sealTypes.map((t) => [t, coreTypeOf(t)])),
|
|
1091
1525
|
}
|
|
1092
1526
|
: undefined;
|
|
1093
|
-
|
|
1527
|
+
// contract-amend-resume-prompt-v1 — on an amend RESUME (ctx.resume, and a session to resume) the
|
|
1528
|
+
// spawn carries a TRIMMED prompt (buildPrompt keys on ctx.resume); the FULL prompt is built with
|
|
1529
|
+
// resume OFF so the cold fallback for a lost resume session can re-send it (I2). On every
|
|
1530
|
+
// non-resume spawn the two are identical, so nothing else changes shape.
|
|
1531
|
+
//
|
|
1532
|
+
// contract-reverify-resume-prompt-v1 (O1/I1/F1) — a re-VERIFY resume (ctx.resume_keep_prompt) is a
|
|
1533
|
+
// DIFFERENT trim: buildPrompt keeps the FULL prompt for a keep-prompt resume (so the stateless
|
|
1534
|
+
// completions door keeps everything it needs — O2), so the trim to only-what-is-new has to be
|
|
1535
|
+
// applied HERE, on the resuming Claude side. A MAKER amend resume (no keep_prompt) still takes
|
|
1536
|
+
// buildPrompt's own trim. fullPrompt stays the full round-one prompt in every case, so the cold
|
|
1537
|
+
// fallback for a lost resume re-sends the verifier's whole context (F1).
|
|
1538
|
+
const resumingWithSession = ctx.resume === true && sessionUuidFor(ctx.gig_id, ctx.role) !== undefined;
|
|
1539
|
+
const reverifyResume = resumingWithSession && ctx.resume_keep_prompt === true;
|
|
1540
|
+
const fullPrompt = buildPrompt(resumingWithSession ? { ...ctx, resume: false } : ctx, schema, outputSchemas, seal);
|
|
1541
|
+
const prompt = reverifyResume
|
|
1542
|
+
? buildReverifyResumePrompt(sealTypes, schema, outputSchemas, seal)
|
|
1543
|
+
: resumingWithSession
|
|
1544
|
+
? buildPrompt(ctx, schema, outputSchemas, seal)
|
|
1545
|
+
: fullPrompt;
|
|
1094
1546
|
// #221 — the key signal for candidate selection, derived from what we just resolved.
|
|
1095
1547
|
// Threaded into BOTH extract calls below; threading only the injected-run one would
|
|
1096
1548
|
// leave every real chair unscored.
|
|
@@ -1175,18 +1627,63 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1175
1627
|
];
|
|
1176
1628
|
// NO OVER-DENIAL (LAW 5, and LAW 2's structural half): nothing the seat legitimately holds may be
|
|
1177
1629
|
// denied — most sharply OUTPUT_WRITE_TOOL, which effectiveAllowed now carries on the seal path.
|
|
1178
|
-
// Subtract the effective allow set
|
|
1179
|
-
// `
|
|
1180
|
-
//
|
|
1630
|
+
// Subtract the effective allow set, then dedupe. The base-name half is right ONLY for a BARE deny
|
|
1631
|
+
// (`Write`): emitting it would kill a scoped `Write(src/**)` grant of the same tool too, so a
|
|
1632
|
+
// grant of that base — scoped or not — protects it. Applied to a SCOPED deny it is wrong:
|
|
1633
|
+
// `Write(tests/**)` and `Write(src/**)` share the base `Write`, yet name different surfaces, so a
|
|
1634
|
+
// scoped deny is removed ONLY by an EXACT-string grant. Without that split a seat could never be
|
|
1635
|
+
// granted `src/**` while denied `tests/**` — the one shape a builder that must not weaken its own
|
|
1636
|
+
// laws needs. code_tool_access-kept tools are NOT subtracted here: a venue that excludes a code
|
|
1637
|
+
// tool must still deny it even under access "full".
|
|
1181
1638
|
const allowExact = new Set(effectiveAllowed ?? []);
|
|
1182
1639
|
const allowBase = new Set((effectiveAllowed ?? []).map(toolBaseName));
|
|
1183
|
-
const disallowedTools = [...new Set(denyUnion)].filter((t) =>
|
|
1184
|
-
|
|
1640
|
+
const disallowedTools = [...new Set(denyUnion)].filter((t) => {
|
|
1641
|
+
if (allowExact.has(t))
|
|
1642
|
+
return false; // an exact grant of the very string always protects it
|
|
1643
|
+
const isBare = t === toolBaseName(t); // no scope parens → a bare tool name
|
|
1644
|
+
return !(isBare && allowBase.has(toolBaseName(t)));
|
|
1645
|
+
});
|
|
1646
|
+
// contract-chair-session-continuity-v1 — the chair's own session, deterministic in (gig_id,
|
|
1647
|
+
// role). A first invocation OPENS it (--session-id); an amend re-invocation (ctx.resume, set
|
|
1648
|
+
// by the runtime) RESUMES it (--resume). Absent gig_id ⇒ no session ⇒ neither flag.
|
|
1649
|
+
const sessionId = sessionUuidFor(ctx.gig_id, ctx.role);
|
|
1650
|
+
const resumeRound = ctx.resume === true && sessionId !== undefined;
|
|
1651
|
+
// contract-seat-primer-v1 (O2/I1) — a FORK chair (ctx.fork threaded, and this seat has a session)
|
|
1652
|
+
// WARM-STARTS its first spawn from the primer's session. A plain chair carries no ctx.fork, so
|
|
1653
|
+
// isFork is false and the spawn is byte-identical to today (no --fork-session — the I1 control).
|
|
1654
|
+
const forkFromSession = ctx.fork?.primer_session_id;
|
|
1655
|
+
const isFork = forkFromSession !== undefined && sessionId !== undefined;
|
|
1656
|
+
// contract-primer-reading-frontier-v1 (O2) — the primer's reading frontier, if it recorded one:
|
|
1657
|
+
// the fork's first spawn CUTS the resumed conversation there. Absent ⇒ the whole session resumes.
|
|
1658
|
+
const forkFrontier = ctx.fork?.frontier;
|
|
1659
|
+
// #seat-effort (O3) — the runtime already resolved precedence onto ctx.effort; floor to medium
|
|
1660
|
+
// so an undeclared, untiered seat (or any hand-built ctx) still spawns with an explicit
|
|
1661
|
+
// --effort rather than the operator's settings-file effort. Hoisted so baseArgs and the cold
|
|
1662
|
+
// arg list below share ONE opts object rather than two parallel derivations.
|
|
1663
|
+
const invokerOpts = {
|
|
1185
1664
|
model: resolveModel(a.model_tier, opts.model),
|
|
1186
1665
|
allowed_tools: effectiveAllowed,
|
|
1187
1666
|
disallowed_tools: disallowedTools,
|
|
1188
1667
|
max_tool_calls: maxToolCalls,
|
|
1668
|
+
effort: ctx.effort ?? "medium",
|
|
1669
|
+
};
|
|
1670
|
+
const baseArgs = buildInvokerArgs(prompt, cfgPath, {
|
|
1671
|
+
...invokerOpts,
|
|
1672
|
+
...(sessionId !== undefined ? { session_id: sessionId, resume: resumeRound } : {}),
|
|
1673
|
+
// contract-seat-primer-v1 (O2) — the fork warm-start rides on the FIRST spawn (baseArgs); the
|
|
1674
|
+
// cold arg list below carries no fork_from_session, so an unresumable primer (F2) falls back to
|
|
1675
|
+
// a plain fresh-session spawn with no --fork-session.
|
|
1676
|
+
...(isFork ? { fork_from_session: forkFromSession } : {}),
|
|
1677
|
+
// contract-primer-reading-frontier-v1 (O2) — cut the fork at the primer's frontier when it has one.
|
|
1678
|
+
...(isFork && forkFrontier !== undefined ? { resume_session_at: forkFrontier } : {}),
|
|
1189
1679
|
});
|
|
1680
|
+
// contract-amend-resume-prompt-v1 (I2) — the cold arg list for a resume whose session is gone:
|
|
1681
|
+
// a FRESH --session-id spawn (resume:false) carrying the FULL prompt, because nothing else
|
|
1682
|
+
// carries the chair's context once the resume fell through. Identical to baseArgs on every
|
|
1683
|
+
// non-resume spawn (same session flag, same full prompt), so it changes shape only on an amend.
|
|
1684
|
+
const coldArgs = sessionId !== undefined
|
|
1685
|
+
? buildInvokerArgs(fullPrompt, cfgPath, { ...invokerOpts, session_id: sessionId, resume: false })
|
|
1686
|
+
: baseArgs;
|
|
1190
1687
|
// SEAT IN THE ROOM. When the substrate stood up a SEAT-BEARING room, ctx.seatExec names its
|
|
1191
1688
|
// container and per-realization workspace, and the chair runs INSIDE it:
|
|
1192
1689
|
// `docker exec -i -w <workspace> <container> claude …` — so the seat's cwd is the room's own
|
|
@@ -1236,20 +1733,156 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1236
1733
|
const recoverable = seal !== undefined &&
|
|
1237
1734
|
e instanceof ChildExitError &&
|
|
1238
1735
|
finalText(e.stdout).errorSubtype === BUDGET_STOP_SUBTYPE;
|
|
1239
|
-
if (
|
|
1240
|
-
|
|
1241
|
-
|
|
1736
|
+
if (recoverable)
|
|
1737
|
+
return { stdout: e.stdout, budgetStopped: true };
|
|
1738
|
+
// A non-recoverable non-zero exit still carries the child's stream, and on the provider-
|
|
1739
|
+
// usage-limit path that stream — NOT stderr — holds the only account of why the run stopped:
|
|
1740
|
+
// the CLI emits the notice as a synthetic assistant message and/or an is_error result and
|
|
1741
|
+
// writes nothing to stderr. The default runner builds its failure from stderr alone, so the
|
|
1742
|
+
// operator was handed `claude exited 1:` with a blank reason for a failure that had a precise
|
|
1743
|
+
// one. Fold the stream's notice into the failure so the reason — and when the limit resets —
|
|
1744
|
+
// survives; keep it a ChildExitError so the stdout it carries is preserved. When the stream
|
|
1745
|
+
// says nothing, the original error (which carries stderr) is rethrown unchanged, so an
|
|
1746
|
+
// ordinary non-zero exit still reports stderr (the control).
|
|
1747
|
+
if (e instanceof ChildExitError) {
|
|
1748
|
+
const notice = providerNoticeFrom(e.stdout);
|
|
1749
|
+
if (notice) {
|
|
1750
|
+
const sep = e.message === "" || e.message.endsWith(" ") ? "" : " ";
|
|
1751
|
+
throw new ChildExitError(`${e.message}${sep}${notice}`, e.stdout);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
throw e;
|
|
1242
1755
|
}
|
|
1243
1756
|
};
|
|
1244
|
-
|
|
1757
|
+
// F1 — the cold fallback for a resume whose session is gone: a FRESH spawn with --session-id
|
|
1758
|
+
// and the FULL prompt (coldArgs/fullPrompt), recorded loudly so the fallback is never silent.
|
|
1759
|
+
// Shared by the main amend resume below AND the reserve continuation so the fallback is one
|
|
1760
|
+
// shape; on the reserve path coldArgs === baseArgs and fullPrompt === prompt, so it is unchanged
|
|
1761
|
+
// there. Defined before the first run so the main amend path can reach it.
|
|
1762
|
+
const resumeColdFallback = async () => {
|
|
1763
|
+
ctx.onEvent?.({
|
|
1764
|
+
type: "resume_fallback",
|
|
1765
|
+
raw: {
|
|
1766
|
+
agent: a.slug,
|
|
1767
|
+
session_id: sessionId,
|
|
1768
|
+
resumed: false,
|
|
1769
|
+
reason: "the session named for --resume could not be found; re-running cold with " +
|
|
1770
|
+
"--session-id and the full prompt",
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1773
|
+
return runTolerantOfBudgetStop(coldArgs, fullPrompt);
|
|
1774
|
+
};
|
|
1775
|
+
// contract-seat-primer-v1 (F2) — a FORK whose primer session cannot be resumed (the run seam
|
|
1776
|
+
// reports "no conversation" for its id) falls back COLD: a FRESH --session-id spawn with the full
|
|
1777
|
+
// prompt (coldArgs carries no fork_from_session, so no --fork-session), NEVER failing the chair.
|
|
1778
|
+
// The `fork_fallback` event names the unresumable primer session so chair_complete records the
|
|
1779
|
+
// reason — a resume that did not happen, not a fork the record falsely claims.
|
|
1780
|
+
// contract-primer-reading-frontier-v1 (F2) — an `reason` overrides the default when the fallback is
|
|
1781
|
+
// due to an unresolvable reading FRONTIER (not an unresumable session), so chair_complete names the
|
|
1782
|
+
// frontier the fork could not cut at rather than a session that never failed.
|
|
1783
|
+
const forkColdFallback = async (reason) => {
|
|
1784
|
+
ctx.onEvent?.({
|
|
1785
|
+
type: "fork_fallback",
|
|
1786
|
+
raw: {
|
|
1787
|
+
agent: a.slug,
|
|
1788
|
+
primer_session_id: forkFromSession,
|
|
1789
|
+
forked: false,
|
|
1790
|
+
reason: reason ??
|
|
1791
|
+
`the primer session ${forkFromSession} could not be resumed; re-running cold with ` +
|
|
1792
|
+
`--session-id and the full prompt`,
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
return runTolerantOfBudgetStop(coldArgs, fullPrompt);
|
|
1796
|
+
};
|
|
1797
|
+
// contract-primer-reading-frontier-v1 (F2) — the reason for an unresolvable-frontier cold fallback,
|
|
1798
|
+
// naming the frontier so chair_complete.fork_fallback records exactly what could not be resolved.
|
|
1799
|
+
const frontierFallbackReason = () => `the primer's reading frontier ${forkFrontier} could not be resolved in its session; re-running ` +
|
|
1800
|
+
`cold with --session-id and the full prompt`;
|
|
1801
|
+
// contract-resumed-gig-session-v1 (O2/O3/F1) — a FIRST `--session-id` open can COLLIDE: the
|
|
1802
|
+
// chair's session id is deterministic in (gig_id, role), so a KILLED attempt that already opened
|
|
1803
|
+
// it leaves the id live, and a resumed gig's first spawn re-opens it — the CLI refuses it
|
|
1804
|
+
// ("already in use"). That is a RESUME, never a failure: re-spawn ONCE with `--resume` and a
|
|
1805
|
+
// SHORT prompt (the previous attempt was interrupted, the current tree is authoritative), and
|
|
1806
|
+
// emit a resume-on-collision event so chair_complete records the chair CONTINUED its session.
|
|
1807
|
+
// If the resume then finds no conversation, the same cold fallback (F1) runs. Only a non-resume
|
|
1808
|
+
// spawn with a session id can collide (a resume carries `--resume`, never `--session-id`).
|
|
1809
|
+
const collisionResume = async () => {
|
|
1810
|
+
ctx.onEvent?.({
|
|
1811
|
+
type: "resume_on_collision",
|
|
1812
|
+
raw: {
|
|
1813
|
+
agent: a.slug,
|
|
1814
|
+
session_id: sessionId,
|
|
1815
|
+
resumed: true,
|
|
1816
|
+
reason: "the session named for --session-id was already in use — a killed prior attempt " +
|
|
1817
|
+
"created it; re-spawning with --resume to continue that conversation",
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
const retryPrompt = `This chair's previous attempt was interrupted. The conversation you are resuming already ` +
|
|
1821
|
+
`holds your disposition, identity, method, tools and the gig input, so this prompt carries ` +
|
|
1822
|
+
`only what is new: the current working tree is authoritative — re-derive your output from ` +
|
|
1823
|
+
`it and re-seal exactly as before.`;
|
|
1824
|
+
const retryArgs = buildInvokerArgs(retryPrompt, cfgPath, { ...invokerOpts, session_id: sessionId, resume: true });
|
|
1825
|
+
try {
|
|
1826
|
+
const retry = await runTolerantOfBudgetStop(retryArgs, retryPrompt);
|
|
1827
|
+
return resumeSessionLost(retry.stdout) ? await resumeColdFallback() : retry;
|
|
1828
|
+
}
|
|
1829
|
+
catch (e) {
|
|
1830
|
+
if (e instanceof ChildExitError && resumeSessionLost(e.stdout))
|
|
1831
|
+
return await resumeColdFallback();
|
|
1832
|
+
throw e;
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
// contract-amend-resume-prompt-v1 (I2/F1) — run the chair once. On an amend RESUME whose session
|
|
1836
|
+
// the seam reports gone (whether the run resolved with the error result or threw a non-zero exit
|
|
1837
|
+
// carrying it), fall back COLD rather than failing the chair. On a FIRST open whose --session-id
|
|
1838
|
+
// COLLIDES (contract-resumed-gig-session-v1 O2), resume it instead of failing. Every other spawn
|
|
1839
|
+
// kind is untouched.
|
|
1840
|
+
const collided = (s, m) => !resumeRound && sessionId !== undefined && sessionIdInUse(s, m);
|
|
1841
|
+
let stdout;
|
|
1842
|
+
let budgetStopped;
|
|
1843
|
+
try {
|
|
1844
|
+
const first = await runTolerantOfBudgetStop(baseArgs, prompt);
|
|
1845
|
+
if (resumeRound && resumeSessionLost(first.stdout)) {
|
|
1846
|
+
({ stdout, budgetStopped } = await resumeColdFallback());
|
|
1847
|
+
}
|
|
1848
|
+
else if (isFork && forkFrontier !== undefined && frontierNotFound(first.stdout, "")) {
|
|
1849
|
+
// contract-primer-reading-frontier-v1 (F2) — the fork's --resume-session-at named a uuid the
|
|
1850
|
+
// primer session does not hold; re-run cold, naming the frontier.
|
|
1851
|
+
({ stdout, budgetStopped } = await forkColdFallback(frontierFallbackReason()));
|
|
1852
|
+
}
|
|
1853
|
+
else if (isFork && resumeSessionLost(first.stdout)) {
|
|
1854
|
+
// contract-seat-primer-v1 (F2) — the fork's --resume of the primer found no conversation.
|
|
1855
|
+
({ stdout, budgetStopped } = await forkColdFallback());
|
|
1856
|
+
}
|
|
1857
|
+
else if (collided(first.stdout, "")) {
|
|
1858
|
+
({ stdout, budgetStopped } = await collisionResume());
|
|
1859
|
+
}
|
|
1860
|
+
else {
|
|
1861
|
+
({ stdout, budgetStopped } = first);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
catch (e) {
|
|
1865
|
+
if (resumeRound && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
|
|
1866
|
+
({ stdout, budgetStopped } = await resumeColdFallback());
|
|
1867
|
+
}
|
|
1868
|
+
else if (isFork && forkFrontier !== undefined && e instanceof ChildExitError && frontierNotFound(e.stdout, e.message)) {
|
|
1869
|
+
// contract-primer-reading-frontier-v1 (F2) — the fork exited 1 because --resume-session-at named
|
|
1870
|
+
// a uuid the primer session does not hold (notice on stderr and in the result event's `errors`).
|
|
1871
|
+
({ stdout, budgetStopped } = await forkColdFallback(frontierFallbackReason()));
|
|
1872
|
+
}
|
|
1873
|
+
else if (isFork && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
|
|
1874
|
+
({ stdout, budgetStopped } = await forkColdFallback());
|
|
1875
|
+
}
|
|
1876
|
+
else if (e instanceof ChildExitError && collided(e.stdout, e.message)) {
|
|
1877
|
+
({ stdout, budgetStopped } = await collisionResume());
|
|
1878
|
+
}
|
|
1879
|
+
else {
|
|
1880
|
+
throw e;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1245
1883
|
// Every stream whose writes count toward the seal. Diverges from `stdout` only when a reserve
|
|
1246
1884
|
// was granted, which is the one case where a chair's output spans more than one invocation.
|
|
1247
1885
|
let sealStdout = stdout;
|
|
1248
|
-
// THE RESERVE GRANT. The chair spent its declared budget; rather than losing whatever it was
|
|
1249
|
-
// mid-way through, it is told where it stands and given a bounded extension to close out.
|
|
1250
|
-
// Once. The continuation names what already sealed so the chair does not redo it, and says
|
|
1251
|
-
// plainly that nothing follows — a chair that believes another extension is coming will spend
|
|
1252
|
-
// this one reaching rather than landing.
|
|
1253
1886
|
if (budgetStopped && reserveTurns > 0 && seal !== undefined) {
|
|
1254
1887
|
const sealedSoFar = captureOutputWrites(stdout, sealTypes);
|
|
1255
1888
|
const already = Object.keys(sealedSoFar);
|
|
@@ -1257,6 +1890,12 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1257
1890
|
type: "budget_reserve_granted",
|
|
1258
1891
|
raw: { agent: a.slug, reserve_turns: reserveTurns, sealed_before_grant: already },
|
|
1259
1892
|
});
|
|
1893
|
+
// contract-chair-session-continuity-v1 (O2) — the continuation RESUMES the chair's own
|
|
1894
|
+
// session and carries ONLY the reserve text. The original prompt is NOT re-sent: the resumed
|
|
1895
|
+
// conversation already holds it, so re-sending it would pay the whole cold read the resume
|
|
1896
|
+
// exists to avoid. When there is a session to resume (there always is on the seal path, which
|
|
1897
|
+
// requires a gig_id), swap --session-id for --resume; otherwise the old fresh-spawn shape
|
|
1898
|
+
// stands and the prompt is re-sent as before.
|
|
1260
1899
|
const continuation = `You reached your turn budget and were stopped mid-run. You are now in RESERVE: ` +
|
|
1261
1900
|
`${reserveTurns} turns remain and this is the LAST extension — it will not be extended ` +
|
|
1262
1901
|
`again, so land the work rather than reaching for more.\n\n` +
|
|
@@ -1264,9 +1903,30 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1264
1903
|
? `Already sealed through the write boundary, do NOT redo: [${already.join(", ")}].\n\n`
|
|
1265
1904
|
: `Nothing sealed yet.\n\n`) +
|
|
1266
1905
|
`Close out now: seal what you already have, and state plainly what you did NOT reach so ` +
|
|
1267
|
-
`the record shows the boundary instead of implying coverage
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1906
|
+
`the record shows the boundary instead of implying coverage.` +
|
|
1907
|
+
(sessionId !== undefined ? `` : `\n\n${prompt}`);
|
|
1908
|
+
const reserveArgs = sessionId !== undefined
|
|
1909
|
+
? withPrompt(withMaxTurns(withResume(baseArgs, sessionId), reserveTurns), continuation)
|
|
1910
|
+
: withPrompt(withMaxTurns(baseArgs, reserveTurns), continuation);
|
|
1911
|
+
// F1 — a resume whose session cannot be found must fall back COLD (a fresh spawn with
|
|
1912
|
+
// --session-id and the FULL original prompt) and never fail the chair. Detect the lost
|
|
1913
|
+
// session from the reserve run's stream, whether it resolved with the error result or threw
|
|
1914
|
+
// a non-zero exit carrying it, and re-run cold; loudly, so the fallback is recorded.
|
|
1915
|
+
let second;
|
|
1916
|
+
try {
|
|
1917
|
+
second = await runTolerantOfBudgetStop(reserveArgs, continuation);
|
|
1918
|
+
if (sessionId !== undefined && resumeSessionLost(second.stdout)) {
|
|
1919
|
+
second = await resumeColdFallback();
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
catch (e) {
|
|
1923
|
+
if (sessionId !== undefined && e instanceof ChildExitError && resumeSessionLost(e.stdout)) {
|
|
1924
|
+
second = await resumeColdFallback();
|
|
1925
|
+
}
|
|
1926
|
+
else {
|
|
1927
|
+
throw e;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1270
1930
|
// Two streams, two different questions, and conflating them is a bug: the OUTCOME (did the
|
|
1271
1931
|
// run complete?) is the last pass's to answer, while the WRITES are cumulative — the first
|
|
1272
1932
|
// pass's payloads passed the boundary too, and a continuation that sealed nothing must not
|
|
@@ -1301,6 +1961,32 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1301
1961
|
throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
|
|
1302
1962
|
`answer: ${outcome.apiErrorText.slice(0, 300)}`);
|
|
1303
1963
|
}
|
|
1964
|
+
// contract-seat-primer-v1 (O1/I2) — a PRIME seat's Read events, parsed from the run's stdout and
|
|
1965
|
+
// emitted so the runtime seals the seat-primer from EXACTLY the files this seat read. Emitted on
|
|
1966
|
+
// both seal and text paths (an injected `run` bypasses streaming, so the returned stdout is the
|
|
1967
|
+
// one place the reads are), and before the seal branches so the reads reach the runtime whatever
|
|
1968
|
+
// the seat produced.
|
|
1969
|
+
if (ctx.prime) {
|
|
1970
|
+
// contract-primer-reading-frontier-v1 (O1/O3/O4/I2/I3/F1) — parse the reading frontier over the
|
|
1971
|
+
// concatenated seal stdout: the reads are cut to those before the first write, the context is the
|
|
1972
|
+
// first-write context (or the last usage when there is no frontier), and the frontier uuid is
|
|
1973
|
+
// forwarded on the seat_reads event so the runtime seals it onto the primer. A no-write/no-user
|
|
1974
|
+
// seat yields no frontier and the reads/context are exactly as the rolling-primer laws expect.
|
|
1975
|
+
const rf = captureReadingFrontier(sealStdout);
|
|
1976
|
+
ctx.onEvent?.({
|
|
1977
|
+
type: "seat_reads",
|
|
1978
|
+
raw: {
|
|
1979
|
+
agent: a.slug, area: ctx.prime.area, reads: rf.reads,
|
|
1980
|
+
...(rf.frontier !== undefined ? { frontier: rf.frontier } : {}),
|
|
1981
|
+
},
|
|
1982
|
+
});
|
|
1983
|
+
// contract-rolling-seat-primer-v1 (O3) — forward the seat's context size at seal the SAME way, so
|
|
1984
|
+
// the runtime records it on the seat-primer even though the injected `run` seam bypassed the
|
|
1985
|
+
// streamed usages. Emitted only when the run reported a usage; otherwise the runtime falls back.
|
|
1986
|
+
if (rf.context_tokens !== undefined) {
|
|
1987
|
+
ctx.onEvent?.({ type: "seat_context", raw: { context_tokens: rf.context_tokens } });
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1304
1990
|
if (seal) {
|
|
1305
1991
|
// THE IN-BAND WRITE BOUNDARY. The chair sealed each output by calling output_write, whose
|
|
1306
1992
|
// payload the engine adjudicated against the FULL seal predicate (checkWritable, run in
|
|
@@ -1355,7 +2041,13 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
1355
2041
|
`The work you already did still counts. Do NOT redo it. Seal it now, by calling ` +
|
|
1356
2042
|
`output_write — the only channel that seals:\n${calls}\n\n` +
|
|
1357
2043
|
`This is the LAST attempt; it will not be offered again.\n\n${prompt}`;
|
|
1358
|
-
|
|
2044
|
+
// contract-fork-continuation-is-exact-v1 (O1) — the repair CONTINUES the chair's own
|
|
2045
|
+
// session, exactly as the reserve continuation does: resume `<own>`, and carry none of the
|
|
2046
|
+
// warm start (`--session-id`, `--fork-session`, `--resume-session-at`, the primer `--resume`)
|
|
2047
|
+
// that `baseArgs` holds for a fork chair. Only a gig-less spawn (no session) reuses baseArgs.
|
|
2048
|
+
const repairArgs = sessionId !== undefined
|
|
2049
|
+
? withPrompt(withMaxTurns(withResume(baseArgs, sessionId), SEAL_REPAIR_TURNS), correction)
|
|
2050
|
+
: withPrompt(withMaxTurns(baseArgs, SEAL_REPAIR_TURNS), correction);
|
|
1359
2051
|
const repaired = await runTolerantOfBudgetStop(repairArgs, correction);
|
|
1360
2052
|
// Cumulative, exactly as the reserve path is: a write that passed in either pass counts.
|
|
1361
2053
|
sealStdout = `${sealStdout}\n${repaired.stdout}`;
|
|
@@ -1660,4 +2352,61 @@ function finalText(stdout) {
|
|
|
1660
2352
|
return { text: result };
|
|
1661
2353
|
return { text: answerBlock(assistant) };
|
|
1662
2354
|
}
|
|
2355
|
+
/**
|
|
2356
|
+
* The provider's own account of why a run stopped, read from the child's STREAM rather than stderr.
|
|
2357
|
+
*
|
|
2358
|
+
* When the Claude CLI hits the account's usage limit it writes NOTHING to stderr — it emits the
|
|
2359
|
+
* notice as a synthetic assistant message (model `<synthetic>`) and/or an is_error result whose
|
|
2360
|
+
* `result` field is the notice, then exits non-zero (measured on build gig 13ea0d99: its verify seat
|
|
2361
|
+
* failed with `claude exited 1: `, and the session transcript ended on "You've hit your session
|
|
2362
|
+
* limit · resets …"). Both carriers are read because the exact fields of the CLI's final event on
|
|
2363
|
+
* this path were not captured, so a fix that read only one could miss whichever shape the day's CLI
|
|
2364
|
+
* happens to take. Returns "" when the stream carries no such notice, so an ordinary non-zero exit's
|
|
2365
|
+
* failure keeps reporting stderr unchanged.
|
|
2366
|
+
*
|
|
2367
|
+
* Deliberately narrow — a `<synthetic>` assistant, not any assistant text, and an is_error result,
|
|
2368
|
+
* not any result — so a budget-stopped TEXT run's partial reasoning is never mistaken for a reason.
|
|
2369
|
+
*/
|
|
2370
|
+
function providerNoticeFrom(stdout) {
|
|
2371
|
+
const texts = [];
|
|
2372
|
+
const push = (v) => {
|
|
2373
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
2374
|
+
texts.push(v.trim());
|
|
2375
|
+
};
|
|
2376
|
+
for (const raw of stdout.split("\n")) {
|
|
2377
|
+
const line = raw.trim();
|
|
2378
|
+
if (!line)
|
|
2379
|
+
continue;
|
|
2380
|
+
let e;
|
|
2381
|
+
try {
|
|
2382
|
+
e = JSON.parse(line);
|
|
2383
|
+
}
|
|
2384
|
+
catch {
|
|
2385
|
+
continue; /* non-json */
|
|
2386
|
+
}
|
|
2387
|
+
const type = typeof e["type"] === "string" ? e["type"] : "";
|
|
2388
|
+
if (type === "result" && e["is_error"] === true) {
|
|
2389
|
+
push(e["result"]);
|
|
2390
|
+
}
|
|
2391
|
+
else if (type === "assistant" && e["message"] && typeof e["message"] === "object") {
|
|
2392
|
+
const msg = e["message"];
|
|
2393
|
+
if (msg.model === "<synthetic>") {
|
|
2394
|
+
for (const b of msg.content ?? [])
|
|
2395
|
+
if (b["type"] === "text")
|
|
2396
|
+
push(b["text"]);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
// Distinct, first-seen order — the limit notice usually rides BOTH carriers, and repeating it
|
|
2401
|
+
// would only pad the failure line.
|
|
2402
|
+
const seen = new Set();
|
|
2403
|
+
const distinct = [];
|
|
2404
|
+
for (const t of texts) {
|
|
2405
|
+
if (seen.has(t))
|
|
2406
|
+
continue;
|
|
2407
|
+
seen.add(t);
|
|
2408
|
+
distinct.push(t);
|
|
2409
|
+
}
|
|
2410
|
+
return distinct.join(" — ");
|
|
2411
|
+
}
|
|
1663
2412
|
//# sourceMappingURL=claude_invoker.js.map
|