@automatalabs/workflows 0.46.3 → 0.46.4
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/README.md +13 -6
- package/dist/mcp-server.js +1452 -118
- package/package.json +2 -2
package/dist/mcp-server.js
CHANGED
|
@@ -21996,7 +21996,7 @@ var ENTRY_DISPATCH_FLAG = "__agentprismEntryDispatch";
|
|
|
21996
21996
|
globalThis[ENTRY_DISPATCH_FLAG] = true;
|
|
21997
21997
|
|
|
21998
21998
|
// ../mcp-server/src/entry.ts
|
|
21999
|
-
import { realpathSync as
|
|
21999
|
+
import { realpathSync as realpathSync4 } from "node:fs";
|
|
22000
22000
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
22001
22001
|
|
|
22002
22002
|
// ../mcp-server/src/daemon/commands.ts
|
|
@@ -31737,6 +31737,10 @@ import {
|
|
|
31737
31737
|
WorkflowErrorCode,
|
|
31738
31738
|
WorkflowManager as WorkflowManager3
|
|
31739
31739
|
} from "@automatalabs/workflows";
|
|
31740
|
+
import {
|
|
31741
|
+
createEvalBreakChannel,
|
|
31742
|
+
loadShippedWasm
|
|
31743
|
+
} from "@automatalabs/repl-engine";
|
|
31740
31744
|
|
|
31741
31745
|
// ../../node_modules/.pnpm/@modelcontextprotocol+ext-apps@1.7.4_@modelcontextprotocol+sdk@1.29.0_zod@4.4.3__react-_b5c68214f1621bc5f24903738e19c6a8/node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.js
|
|
31742
31746
|
init_v4();
|
|
@@ -31983,6 +31987,279 @@ import {
|
|
|
31983
31987
|
WorkflowManager,
|
|
31984
31988
|
workflowHomeDir
|
|
31985
31989
|
} from "@automatalabs/workflows";
|
|
31990
|
+
|
|
31991
|
+
// ../mcp-server/src/repl-project.ts
|
|
31992
|
+
import {
|
|
31993
|
+
Broker,
|
|
31994
|
+
ReplWorkspaceStore,
|
|
31995
|
+
SnapshotEnvelopeError,
|
|
31996
|
+
Workspace
|
|
31997
|
+
} from "@automatalabs/repl-engine";
|
|
31998
|
+
import { workflowProjectKey } from "@automatalabs/workflows";
|
|
31999
|
+
|
|
32000
|
+
// ../mcp-server/src/lifecycle.ts
|
|
32001
|
+
var SHUTDOWN_DEADLINE_MS = 5e3;
|
|
32002
|
+
function isDisposableRunner(runner) {
|
|
32003
|
+
return "dispose" in runner && typeof runner.dispose === "function";
|
|
32004
|
+
}
|
|
32005
|
+
function isForceKillableRunner(runner) {
|
|
32006
|
+
return "forceKill" in runner && typeof runner.forceKill === "function";
|
|
32007
|
+
}
|
|
32008
|
+
function exitCodeFor(reason) {
|
|
32009
|
+
if (reason === "SIGINT") return 130;
|
|
32010
|
+
if (reason === "SIGTERM") return 143;
|
|
32011
|
+
return 0;
|
|
32012
|
+
}
|
|
32013
|
+
async function disposeRunnerWithDeadline(runner, deadlineMs = SHUTDOWN_DEADLINE_MS) {
|
|
32014
|
+
const dispose = isDisposableRunner(runner) ? Promise.resolve().then(() => runner.dispose()).catch(() => void 0) : Promise.resolve();
|
|
32015
|
+
let deadlineTimer;
|
|
32016
|
+
const deadline = new Promise((resolve) => {
|
|
32017
|
+
deadlineTimer = setTimeout(() => {
|
|
32018
|
+
if (isForceKillableRunner(runner)) {
|
|
32019
|
+
try {
|
|
32020
|
+
runner.forceKill();
|
|
32021
|
+
} catch {
|
|
32022
|
+
}
|
|
32023
|
+
}
|
|
32024
|
+
resolve();
|
|
32025
|
+
}, deadlineMs);
|
|
32026
|
+
});
|
|
32027
|
+
await Promise.race([dispose, deadline]);
|
|
32028
|
+
if (deadlineTimer !== void 0) clearTimeout(deadlineTimer);
|
|
32029
|
+
}
|
|
32030
|
+
function installMcpServerLifecycle(options) {
|
|
32031
|
+
const processHandle = options.process ?? process;
|
|
32032
|
+
const deadlineMs = options.deadlineMs ?? SHUTDOWN_DEADLINE_MS;
|
|
32033
|
+
let shutdownPromise;
|
|
32034
|
+
let shuttingDown = false;
|
|
32035
|
+
const onStdinClose = () => {
|
|
32036
|
+
void lifecycle.shutdown("stdin-close");
|
|
32037
|
+
};
|
|
32038
|
+
const onStdinEnd = () => {
|
|
32039
|
+
void lifecycle.shutdown("stdin-end");
|
|
32040
|
+
};
|
|
32041
|
+
const onSigint = () => {
|
|
32042
|
+
void lifecycle.shutdown("SIGINT");
|
|
32043
|
+
};
|
|
32044
|
+
const onSigterm = () => {
|
|
32045
|
+
void lifecycle.shutdown("SIGTERM");
|
|
32046
|
+
};
|
|
32047
|
+
const previousTransportOnClose = options.transport.onclose;
|
|
32048
|
+
const onTransportClose = () => {
|
|
32049
|
+
try {
|
|
32050
|
+
previousTransportOnClose?.();
|
|
32051
|
+
} catch {
|
|
32052
|
+
}
|
|
32053
|
+
void lifecycle.shutdown("transport-close");
|
|
32054
|
+
};
|
|
32055
|
+
const removeListeners = () => {
|
|
32056
|
+
processHandle.stdin.removeListener("close", onStdinClose);
|
|
32057
|
+
processHandle.stdin.removeListener("end", onStdinEnd);
|
|
32058
|
+
processHandle.removeListener("SIGINT", onSigint);
|
|
32059
|
+
processHandle.removeListener("SIGTERM", onSigterm);
|
|
32060
|
+
};
|
|
32061
|
+
const lifecycle = {
|
|
32062
|
+
shutdown(reason) {
|
|
32063
|
+
if (shutdownPromise) return shutdownPromise;
|
|
32064
|
+
shuttingDown = true;
|
|
32065
|
+
options.server.stopAcceptingWork();
|
|
32066
|
+
shutdownPromise = disposeRunnerWithDeadline(options.runner, deadlineMs).then(() => {
|
|
32067
|
+
removeListeners();
|
|
32068
|
+
void options.server.disposeReplEvalBreakChannel?.().catch(() => void 0);
|
|
32069
|
+
processHandle.exit(exitCodeFor(reason));
|
|
32070
|
+
});
|
|
32071
|
+
return shutdownPromise;
|
|
32072
|
+
},
|
|
32073
|
+
isShuttingDown() {
|
|
32074
|
+
return shuttingDown;
|
|
32075
|
+
}
|
|
32076
|
+
};
|
|
32077
|
+
options.transport.onclose = onTransportClose;
|
|
32078
|
+
processHandle.stdin.once("close", onStdinClose);
|
|
32079
|
+
processHandle.stdin.once("end", onStdinEnd);
|
|
32080
|
+
processHandle.once("SIGINT", onSigint);
|
|
32081
|
+
processHandle.once("SIGTERM", onSigterm);
|
|
32082
|
+
return lifecycle;
|
|
32083
|
+
}
|
|
32084
|
+
|
|
32085
|
+
// ../mcp-server/src/repl-project.ts
|
|
32086
|
+
var TruncationRefStore = class {
|
|
32087
|
+
/** The workspace-namespace prefix (the canonical projectDir's
|
|
32088
|
+
* workflow project key — see `workflowProjectKey`). */
|
|
32089
|
+
constructor(namespace) {
|
|
32090
|
+
this.namespace = namespace;
|
|
32091
|
+
}
|
|
32092
|
+
namespace;
|
|
32093
|
+
refs = /* @__PURE__ */ new Map();
|
|
32094
|
+
seq = 0;
|
|
32095
|
+
/** Snapshot the dropped entries under a fresh namespaced ref id
|
|
32096
|
+
* (`<namespace>:t<seq>`). Retained until `clear` — never evicted
|
|
32097
|
+
* (round 3: the old bounded ring evicted still-advertised refs). */
|
|
32098
|
+
set(values) {
|
|
32099
|
+
const ref = `${this.namespace}:t${++this.seq}`;
|
|
32100
|
+
this.refs.set(ref, values);
|
|
32101
|
+
return ref;
|
|
32102
|
+
}
|
|
32103
|
+
get(ref) {
|
|
32104
|
+
return this.refs.get(ref);
|
|
32105
|
+
}
|
|
32106
|
+
/** Drop every snapshot (the `reset` tool's engine-side — the dropped
|
|
32107
|
+
* workspace's old metadata must not remain retrievable after the
|
|
32108
|
+
* tool reports the workspace state was reset). */
|
|
32109
|
+
clear() {
|
|
32110
|
+
this.refs.clear();
|
|
32111
|
+
}
|
|
32112
|
+
};
|
|
32113
|
+
function createReplProjectState(projectDir, options = {}) {
|
|
32114
|
+
return {
|
|
32115
|
+
projectDir,
|
|
32116
|
+
store: ReplWorkspaceStore.open(projectDir, options),
|
|
32117
|
+
workspace: null,
|
|
32118
|
+
broker: null,
|
|
32119
|
+
source: null,
|
|
32120
|
+
reconcileReport: null,
|
|
32121
|
+
restoreError: null,
|
|
32122
|
+
clients: /* @__PURE__ */ new Set(),
|
|
32123
|
+
firstTouch: null,
|
|
32124
|
+
generation: 0,
|
|
32125
|
+
drained: false,
|
|
32126
|
+
drainError: null,
|
|
32127
|
+
truncationRefs: new TruncationRefStore(workflowProjectKey(projectDir))
|
|
32128
|
+
};
|
|
32129
|
+
}
|
|
32130
|
+
var DEFAULT_REPL_EVAL_TIMEOUT_MS = 3e4;
|
|
32131
|
+
async function ensureReplWorkspace(state, wasm, runner, evalTimeoutMs = DEFAULT_REPL_EVAL_TIMEOUT_MS, evalBreakChannel) {
|
|
32132
|
+
const flight = state.firstTouch;
|
|
32133
|
+
if (flight !== null) return flight;
|
|
32134
|
+
if (state.workspace !== null) return;
|
|
32135
|
+
if (state.restoreError !== null) return;
|
|
32136
|
+
const promise2 = doFirstTouch(state, wasm, runner, evalTimeoutMs, evalBreakChannel);
|
|
32137
|
+
state.firstTouch = promise2;
|
|
32138
|
+
try {
|
|
32139
|
+
await promise2;
|
|
32140
|
+
} finally {
|
|
32141
|
+
if (state.firstTouch === promise2) state.firstTouch = null;
|
|
32142
|
+
}
|
|
32143
|
+
}
|
|
32144
|
+
async function doFirstTouch(state, wasm, runner, evalTimeoutMs, evalBreakChannel) {
|
|
32145
|
+
const generation = state.generation;
|
|
32146
|
+
const attach = async (workspace2) => {
|
|
32147
|
+
if (state.generation !== generation) {
|
|
32148
|
+
workspace2.dispose();
|
|
32149
|
+
throw new Error("repl workspace touch aborted by reset/dispose");
|
|
32150
|
+
}
|
|
32151
|
+
const broker = await Broker.attach(workspace2, {
|
|
32152
|
+
runner,
|
|
32153
|
+
store: state.store.callStore(),
|
|
32154
|
+
snapshotSink: state.store.snapshotWriter(workspace2, wasm),
|
|
32155
|
+
// The eval-break signal no longer lives here — the broker owns it
|
|
32156
|
+
// (see `Broker.armEvalBreak`; phase-E review rejection: the
|
|
32157
|
+
// project-wide boolean used to be consumable by an unrelated eval
|
|
32158
|
+
// or drain). The per-eval wall-clock deadline still bounds every
|
|
32159
|
+
// eval and drain, and the OUT-OF-BAND eval-break channel (phase-F
|
|
32160
|
+
// review round 2) makes the interrupt tool's no-id path
|
|
32161
|
+
// deliverable to a synchronously running eval.
|
|
32162
|
+
evalTimeoutMs,
|
|
32163
|
+
evalBreakChannel
|
|
32164
|
+
});
|
|
32165
|
+
if (state.generation !== generation) {
|
|
32166
|
+
await broker.dispose();
|
|
32167
|
+
workspace2.dispose();
|
|
32168
|
+
throw new Error("repl workspace touch aborted by reset/dispose");
|
|
32169
|
+
}
|
|
32170
|
+
state.workspace = workspace2;
|
|
32171
|
+
state.broker = broker;
|
|
32172
|
+
};
|
|
32173
|
+
if (state.store.hasSnapshot()) {
|
|
32174
|
+
try {
|
|
32175
|
+
const restored = state.store.loadSnapshot(wasm);
|
|
32176
|
+
const workspace2 = await Workspace.restore(state.projectDir, restored.snapshot, { wasm });
|
|
32177
|
+
await attach(workspace2);
|
|
32178
|
+
const broker = state.broker;
|
|
32179
|
+
const report = await broker.reconcile();
|
|
32180
|
+
if (state.generation !== generation) {
|
|
32181
|
+
throw new Error("repl workspace touch aborted by reset/dispose");
|
|
32182
|
+
}
|
|
32183
|
+
state.source = "restored";
|
|
32184
|
+
state.reconcileReport = report;
|
|
32185
|
+
return;
|
|
32186
|
+
} catch (error51) {
|
|
32187
|
+
if (error51 instanceof SnapshotEnvelopeError) {
|
|
32188
|
+
state.restoreError = error51;
|
|
32189
|
+
return;
|
|
32190
|
+
}
|
|
32191
|
+
throw error51;
|
|
32192
|
+
}
|
|
32193
|
+
}
|
|
32194
|
+
const workspace = await Workspace.create(state.projectDir, { wasm });
|
|
32195
|
+
await attach(workspace);
|
|
32196
|
+
state.source = "fresh";
|
|
32197
|
+
}
|
|
32198
|
+
function touchReplProject(state, clientId) {
|
|
32199
|
+
state.clients.add(clientId);
|
|
32200
|
+
state.drained = false;
|
|
32201
|
+
}
|
|
32202
|
+
function disconnectReplProject(state, clientId) {
|
|
32203
|
+
state.clients.delete(clientId);
|
|
32204
|
+
}
|
|
32205
|
+
async function drainReplProject(state, boundMs) {
|
|
32206
|
+
if (state.broker === null || state.clients.size > 0) return;
|
|
32207
|
+
if (state.drained && state.broker.isDrained) return;
|
|
32208
|
+
try {
|
|
32209
|
+
const drained = await state.broker.drainForDisconnect(boundMs, () => state.clients.size > 0);
|
|
32210
|
+
if (state.broker !== null) {
|
|
32211
|
+
state.drained = drained;
|
|
32212
|
+
if (drained) state.drainError = null;
|
|
32213
|
+
}
|
|
32214
|
+
} catch (error51) {
|
|
32215
|
+
state.drainError = {
|
|
32216
|
+
name: error51 instanceof Error ? error51.name : "Error",
|
|
32217
|
+
message: error51 instanceof Error ? error51.message : String(error51)
|
|
32218
|
+
};
|
|
32219
|
+
throw error51;
|
|
32220
|
+
}
|
|
32221
|
+
}
|
|
32222
|
+
function detachFirstTouch(state) {
|
|
32223
|
+
const flight = state.firstTouch;
|
|
32224
|
+
state.firstTouch = null;
|
|
32225
|
+
if (flight !== null) {
|
|
32226
|
+
void flight.catch(() => void 0);
|
|
32227
|
+
}
|
|
32228
|
+
}
|
|
32229
|
+
async function disposeReplProjectState(state, boundMs = SHUTDOWN_DEADLINE_MS) {
|
|
32230
|
+
const { broker, workspace } = state;
|
|
32231
|
+
state.broker = null;
|
|
32232
|
+
state.workspace = null;
|
|
32233
|
+
state.generation++;
|
|
32234
|
+
detachFirstTouch(state);
|
|
32235
|
+
try {
|
|
32236
|
+
if (broker !== null) await broker.dispose(boundMs);
|
|
32237
|
+
} finally {
|
|
32238
|
+
workspace?.dispose();
|
|
32239
|
+
state.store.close();
|
|
32240
|
+
}
|
|
32241
|
+
}
|
|
32242
|
+
async function resetReplProjectState(state, boundMs = SHUTDOWN_DEADLINE_MS) {
|
|
32243
|
+
const { broker, workspace } = state;
|
|
32244
|
+
state.broker = null;
|
|
32245
|
+
state.workspace = null;
|
|
32246
|
+
state.generation++;
|
|
32247
|
+
detachFirstTouch(state);
|
|
32248
|
+
try {
|
|
32249
|
+
if (broker !== null) await broker.dispose(boundMs);
|
|
32250
|
+
} finally {
|
|
32251
|
+
workspace?.dispose();
|
|
32252
|
+
state.store.reset();
|
|
32253
|
+
}
|
|
32254
|
+
state.source = null;
|
|
32255
|
+
state.reconcileReport = null;
|
|
32256
|
+
state.restoreError = null;
|
|
32257
|
+
state.truncationRefs.clear();
|
|
32258
|
+
state.drained = false;
|
|
32259
|
+
state.drainError = null;
|
|
32260
|
+
}
|
|
32261
|
+
|
|
32262
|
+
// ../mcp-server/src/project-registry.ts
|
|
31986
32263
|
var MAX_BACKGROUND_RUNS = 4;
|
|
31987
32264
|
var BackgroundRunRegistry = class {
|
|
31988
32265
|
starting = 0;
|
|
@@ -32109,6 +32386,35 @@ var WorkflowProjectRegistry = class {
|
|
|
32109
32386
|
for (const context of this.contexts.values()) total += context.backgroundRuns.activeCount();
|
|
32110
32387
|
return total;
|
|
32111
32388
|
}
|
|
32389
|
+
/** Dispose every context's REPL workspace: each one DRAINS with the
|
|
32390
|
+
* shutdown bound first (in-flight subagent turns settle into the VM
|
|
32391
|
+
* and snapshot; the reviewer-mandated drain-then-close posture — the
|
|
32392
|
+
* old path cancelled busy sessions on disposal) — then the broker
|
|
32393
|
+
* teardown (releasing every held ACP session) and the store close.
|
|
32394
|
+
* Called by the daemon at shutdown; the workflow managers' own
|
|
32395
|
+
* lifecycle is untouched.
|
|
32396
|
+
*
|
|
32397
|
+
* ONE deadline spans the drain AND the teardown (phase-D review
|
|
32398
|
+
* round 7: the disposal used to run unbounded — a drain that failed
|
|
32399
|
+
* or consumed the whole bound then entered a teardown that awaited
|
|
32400
|
+
* hung cancel/release forever, so daemon shutdown could hang on the
|
|
32401
|
+
* exact hung backend the drain had already caught). A drain that
|
|
32402
|
+
* fails or times out leaves the teardown only the remaining bound;
|
|
32403
|
+
* an expired deadline skips straight to the disposal's bookkeeping
|
|
32404
|
+
* clear. `boundMs` defaults to the daemon's shutdown deadline (the
|
|
32405
|
+
* engine's own dispose default mirrors it). */
|
|
32406
|
+
async disposeReplStates(boundMs = SHUTDOWN_DEADLINE_MS) {
|
|
32407
|
+
const deadline = Date.now() + Math.max(0, boundMs);
|
|
32408
|
+
for (const context of this.contexts.values()) {
|
|
32409
|
+
const state = context.repl;
|
|
32410
|
+
if (state === void 0) continue;
|
|
32411
|
+
const broker = state.broker;
|
|
32412
|
+
if (broker !== null) {
|
|
32413
|
+
await broker.drainForDisconnect(Math.max(0, deadline - Date.now())).catch(() => void 0);
|
|
32414
|
+
}
|
|
32415
|
+
await disposeReplProjectState(state, Math.max(0, deadline - Date.now())).catch(() => void 0);
|
|
32416
|
+
}
|
|
32417
|
+
}
|
|
32112
32418
|
snapshot() {
|
|
32113
32419
|
return [...this.contexts.values()].map((context) => ({
|
|
32114
32420
|
projectDir: context.projectDir,
|
|
@@ -32755,7 +33061,7 @@ function callKey(scope, callIndex) {
|
|
|
32755
33061
|
}
|
|
32756
33062
|
|
|
32757
33063
|
// ../mcp-server/src/generated/authoring-prompt-content.ts
|
|
32758
|
-
var AUTHORING_PROMPT_CONTENT = '# Writing AgentPrism workflow scripts\n\nA workflow script is plain JavaScript, passed around as a **string**, not a module. The engine runs it in a deterministic sandboxed realm. Each `agent()` call opens a session on an [Agent Client Protocol](https://agentclientprotocol.com) (ACP) backend \u2014 Claude Code, OpenAI Codex, OpenCode, pi, or a custom ACP agent server. The backend runs its own tool loop to completion and returns final text or a schema-validated object. One script can mix backends per call.\n\nThe **Workflow script reference** section at the end of this document holds the exhaustive option tables, routing grammar, and error codes.\n\n## The guide, by task\n\nEvery section of the guide is inlined below, after the core: Running workflows (the MCP server and `workflow` tool), backends and structured output, composition and failure, quality helpers and checkpoints, the execution environment, determinism and resume, and worked examples with validation.\n\n## The mental model\n\n- **The script is the orchestrator; agents are workers.** All control flow \u2014 loops, fan-out, dedup, aggregation, conditionals \u2014 lives in script code. Agents cannot spawn agents and cannot see each other. Give each agent one self-contained task.\n- **Each `agent()` call opens a fresh session with no memory.** Interpolate everything a later call needs into its prompt. (Sole exception: resume can continue the same usage/auth-interrupted occurrence \u2014 see Determinism and resume.)\n- **Agents are real coding agents, not chat completions.** They have file access, shells, and tools, rooted at the run\'s working directory. "Read the failing test and fix it" is a valid prompt; the agent will edit files.\n- **The DSL primitives are realm globals, not imports.** There is nothing to `import` \u2014 `agent`, `parallel`, `pipeline`, `gate`, `checkpoint`, `args`, `budget`, \u2026 are injected. Top-level `await` and a top-level `return` are valid. The script\'s return value becomes the run\'s `result`.\n- **Scripts are plain JavaScript, not TypeScript.** Type annotations fail to parse. The realm has no Node APIs (no `require`, `import`, `fs`, `fetch`, timers). All side effects happen through agents.\n- **Live observability needs no script annotations.** Journaling runs publish redacted progress and transcript upserts at `workflow://runs/{runId}/events`. Author labels for human correlation, not to enable this behavior.\n\n## Minimal script\n\n```js\nexport const meta = {\n name: "repo-summary",\n description: "Summarize what a repository does",\n};\n\nconst summary = await agent(\n `Read the README and the package manifests under ${args.path}, then ` +\n `summarize what this project does in five sentences.`,\n { label: "summarize" },\n);\nreturn { summary };\n```\n\nRun scripts through the MCP server\'s `workflow` tool \u2014 registration, the run/await/inspect/stop actions, and the `args`/`cwd` globals are covered in the **Running workflows** section below.\n\n## Pre-flight checklist\n\n- [ ] `export const meta = { name, description }` is the first statement, a pure literal.\n- [ ] No `Date.now()` / `Math.random()` / no-arg `new Date()` / `Date()`; no imports, no Node APIs. Timestamps and randomness come in through `args`.\n- [ ] Every `parallel` element is a **thunk**; results are `.filter(Boolean)`-ed or null-checked.\n- [ ] Every prompt is self-contained: prior results are interpolated in, and every file path a prompt references was written by an earlier call, supplied through `args`, or created by that prompt\'s own instructions.\n- [ ] Schemas: object root, `additionalProperties: false`, everything `required`, a `description` on every field.\n- [ ] Model ids, effort values, and `configOptions` come from `npx @automatalabs/workflows config` or a validator report, never from memory. `mode` only on calls with a pinned `model`.\n- [ ] Worktree-isolated agents return their work as data \u2014 their edits are discarded when the call ends.\n- [ ] Replay is intentional: completed calls with matching identity and input fingerprints replay. Change a hashed field (normally the prompt) when a completed call must run again.\n- [ ] Budget loops guard on `budget.total`; caps and drops are `log()`-ed, not silent.\n- [ ] `checkpoint()` guards irreversible actions, with a sane headless `default` or an intentional `headless: "pause"`.\n- [ ] `return` a compact, structured result \u2014 it is the run\'s `result`, not a transcript.\n- [ ] `npx @automatalabs/workflows validate <file> --args \'<json>\'` exits 0 with no surprising warnings.\n\nFor the complete `agent()` option table, model-routing grammar, checkpoint options, error codes, `meta.backends` config fields, and the MCP tool input shapes, see the **Workflow script reference** section below.\n\n\n## Running workflows \u2014 the MCP `workflow` tool\n\nAgents run workflows through the single `workflow` tool served by `@automatalabs/mcp-server`. Register it once in the host\'s MCP configuration (project-scoped is typical):\n\n```json\n{ "mcpServers": { "agentprism-workflows": { "command": "npx", "args": ["-y", "@automatalabs/mcp-server@latest"] } } }\n```\n\nThe stdio command the host spawns is a thin **shim**. It proxies to a shared per-user **workflow daemon** (Streamable HTTP on loopback, auto-started on first use). Runs execute in the daemon, so they survive session end, host restarts, and tool timeouts; only daemon exit can interrupt in-flight work. Any later session can await, inspect, or stop a run. Runs, journals, and logs persist under `~/.agentprism/workflows/` per project namespace.\n\nEvery `run` call names its project with the required `projectDir` argument \u2014 an absolute path, normally the workspace root. One registration serves every project. `inspect`/`await`/`stop` take only a `runId`; the runId locates its project store automatically. Add `--in-process` to the args for the pre-daemon single-process behavior (`projectDir` is then optional), or register the daemon\'s HTTP endpoint directly in HTTP-capable hosts (`agentprism-workflow daemon url` prints snippets). The command resolves at spawn time, so a reconnect (`/mcp` in Claude Code) picks up the latest published version.\n\n### The `workflow` tool, by action\n\n- **Run** (default, no `action`): supply exactly one of `script` (the raw source string, no Markdown fences) or `scriptPath` (an absolute path on the server\'s filesystem), plus `projectDir`. A path is read once at admission and its content snapshotted; later edits affect only a new run. `args` arrives in the script as the `args` global; the run\'s base directory is the `cwd` global. Some hosts hand `args` through as a JSON **string** \u2014 tolerate both shapes (`typeof args === "string" ? JSON.parse(args) : args`). Foreground streams progress but is bound to the request and its timeout. Pass `background: true` for anything that may outlive one request; it acknowledges after durable admission with a `runId`.\n- **Await** (`{ action: "await", runId, waitMs }`): bounded collection for background runs. A timeout is progress, not failure \u2014 call again (`waitMs: 20000` is typical). At terminal status the response adds `outcome`: the authored result or pause context, plus `replayEligibility`, `resumeReport`, `fallbacks`, and `checkpointsTaken`.\n- **Inspect** (`{ action: "inspect", runId, lastN, labelGlob, logLines }`): a bounded snapshot \u2014 the latest matching calls with compact result previews plus the newest log lines. Use a narrow `labelGlob` to diagnose before deciding whether to resume, edit, or stop. Inspection never executes or resumes a script.\n- **Stop**: `{ action: "stop", runId }` durably aborts the whole run and returns its final snapshot; stopping a terminal run is a successful no-op. `{ action: "stop", runId, callIndex }` cancels exactly that in-flight agent: its slot settles to `null` with `AGENT_CANCELLED` and the run stays live. `labelGlob` only filters the returned snapshot; it never selects what to cancel.\n- **Resume**: a NEW run with `resumeFromRunId` plus the script content re-sent (the same `script` or `scriptPath`) and the desired `args` (+ `checkpointReplies` when answering a durable checkpoint). Read the returned `replayEligibility` for the predicted and observed replay prefix; never assume a prefix hit. Full semantics: **Determinism and resume**.\n\n### Operating rules\n\n- **Always retain the returned `runId`.** A paused, failed, or aborted response carries a redacted final-20 `logTail`. Read it before you change anything. Every admitted script is also an immutable resource at `workflow://runs/{runId}/script`, so a later session can recover a lost inline script.\n- **Two fingerprints control replay.** The identity hash covers the prompt, the resolved model, `mode` when set, non-empty sorted `configOptions`, `tier`, `phase`, `agentType`, the resolved agent definition, and the schema. The input fingerprint covers the resolved label, per-call `cwd` and isolation, `keepSession`, images, MCP servers, session/prompt metadata, and the approved script-backend digest.\n- **Operational bounds are not replay inputs.** Host `concurrency`, `agentRetries`, and `agentTimeoutMs`, plus per-call `timeoutMs` and `retries`, enter neither fingerprint. A resume does not inherit them from its source run; pass the values you want on every run. `agentTimeoutMs` caps the wall-clock time of each attempt; it is not an idle timer. A per-call `timeoutMs` can tighten that ceiling but cannot escape it. Each retry gets a fresh clock, so the envelope is `(resolved retries + 1) \xD7 resolved timeout`, with retries clamped to 3.\n- **Old journals stay usable.** Input formats below 2 replay positionally with `fallbackReason: "inputs-format-legacy"`. A current-format crash snapshot uses identity matching even without terminal-environment capture. Ancestor-scoped rows carried from \u22640.23 resume chains replay only while that ancestor run is still persisted. Journals resume across filesystem, environment, engine, Node, and V8 changes; `replayEligibility` reports those differences as diagnostics, never as gates.\n- **A background start returns immediately.** It sends no progress after it returns; collect progress with later bounded awaits. Background runs have no live checkpoint channel, so authored `headless` checkpoint modes apply. When a run\'s owner process dies, cold preflights reconcile stale `pending`/`running` state to `paused` with `pauseReason: "interrupted"`; a live owner is left alone.\n- A run paused with `reason: "auth_required"` resumes as a new run after you log in the backend\'s own CLI out of band.\n\n### Execution logs \u2014 the events resource\n\nEvery journaling run publishes an MCP resource at `workflow://runs/{runId}/events`. Subscribe to the canonical URI for advisory `resources/updated` hints, then read and paginate with `after`, `limit`, and `streamId`. Progress is coarse and redacted: `agentTranscript` rows are assistant/tool upserts partitioned by `(scope, callIndex, executionStartSeq)` and reduced by greatest revision per entry index. The durable cursor is authoritative when hints coalesce or a subscriber falls behind.\n\nEmbedding hosts can drive the same contract with `runDynamicWorkflow` / `WorkflowManager` from `@automatalabs/workflows`; the script contract is identical either way.\n\n## Choosing the agent for each call\n\nThe backend is selected **per `agent()` call** from its effective `model` string. One script can plan on one vendor\'s agent, implement on another\'s, and review on a third\'s, handing structured results between them.\n\nThe built-in names (`claude`, `codex`, `opencode`, `pi`) come from the runtime backend registry. Registered custom names extend that set.\n\n- **Omit `model` entirely** for maximum portability \u2014 the call runs on whatever default backend the host configured (`AGENTPRISM_DEFAULT_BACKEND`, or the host\'s session model). A script with no model specs anywhere runs unchanged on any backend.\n- **Route by one registered first segment.** Split on the first `/`; ASCII-case-insensitive `claude`, `codex`, `opencode`, `pi`, or a registered custom backend name selects that harness and is stripped exactly once. A custom registration wins on a built-in-name collision.\n- **Use a backend name alone** (`claude`, `codex`, `opencode`, `pi`, or a custom name) to preserve the harness\'s configured default model. No model config call is made.\n- **Everything else goes intact to the default backend.** `anthropic/\u2026`, `openai/\u2026`, bare `opus`, and bare `gpt-\u2026` are not routing aliases. When an id remains after routing, it is sent byte-for-byte: no catalog matching, case folding, bracket parsing, effort/Fast option driving, retry, or fallback. Harness rejection is an agent error.\n- **`tier`** (`"small" | "medium" | "big"`) is a coarse alternative resolved from the host\'s tier config \u2014 use it for "a cheap model" without naming a vendor.\n\nThe published examples use ids verified against live harness catalogs: `claude/opus[1m]`, `codex/gpt-5.6-sol`, and `opencode/zai/glm-5.2`. For Pi, `pi/openrouter/vendor/model-id` strips only `pi/`; Pi then splits provider `openrouter` from model id `vendor/model-id`. Prefer backend-only forms when the desired model is configured inside the harness.\n\nNever guess model ids, effort values, or option names from memory \u2014 read the live catalog first:\n\n```bash\nnpx @automatalabs/workflows config # every routable harness (claude, codex, opencode, pi + registered customs)\nnpx @automatalabs/workflows config codex --json # one harness, machine-readable\n```\n\nOne no-prompt session per harness, zero tokens: the table lists every negotiable session option \u2014 model ids (including bracket variants like `opus[1m]`), effort levels, modes \u2014 exactly as the installed harness advertises them. One caveat: the bare `config` probe reads each harness with its **default model** selected, and option domains are **model-specific**. An option can appear only after a particular model is selected. Ceilings differ per model. Provider-served variants of the same model can advertise different domains. The authoritative per-model probe is the validator run on your real script: it selects each authored `{ backend, model }` pair first and echoes that pair\'s advertised table. Confirm every pinned model against its own echoed table; do not read package internals to discover options.\n\n```js\nconst plan = await agent(PLAN_PROMPT, { label: "plan", model: "opencode/zai/glm-5.2", schema: PLAN });\nconst impl = await agent(implPrompt(plan), { label: "implement", model: "codex/gpt-5.6-sol" });\nconst review = await agent(reviewPrompt(impl), { label: "review", model: "claude/opus[1m]", schema: REVIEW });\n```\n\nUse `configOptions` only for exact ACP session options advertised by that routed harness. Read the per-harness advertised-options table first \u2014 `npx @automatalabs/workflows config <harness>`, or the same table in every validator report \u2014 before choosing ids or select values; catalogs vary by harness version, login, and machine.\n\n```js\nconst impl = await agent(implPrompt(plan), {\n label: "implement",\n model: "codex",\n configOptions: { "fast-mode": true, reasoning_effort: "high" },\n});\n```\n\nIds and string/boolean values pass through verbatim in ascending id order, after model selection and before the prompt. There are no aliases, coercion, client-side vocabulary, defaults, or cached catalogs. Copy option ids character-for-character from the catalog, punctuation included \u2014 `"fast-mode"`, not `fast_mode` \u2014 and quote ids that are not valid identifiers. Never put `"model"` in `configOptions`; use the dedicated `model` field. A harness rejection follows the ordinary agent-error path.\n\nPi\'s thought-level option is named `thinkingLevel`, and its choices depend on the exact model in the same call:\n\n```js\nconst review = await agent(REVIEW_PROMPT, {\n label: "pi-review",\n model: "pi/openrouter/vendor/model-id",\n configOptions: { thinkingLevel: "high" },\n});\n```\n\nValidation selects `openrouter/vendor/model-id` before reading Pi\'s choices. A listed value passes unchanged. A recognized value above an ordered model\'s ceiling, or in a model-specific gap, passes with a warning that names the effective clamp target. Pi advertises its SDK-derived domain directly. Claude and Codex are also ordered: when their options omit domain metadata, validation enumerates the advertised models and merges their per-model effort orders. A Claude model without an `effort` option does not support effort, and `default` never becomes a ceiling target. OpenCode and custom backends have no declared value order, so validation is exact-set. An unrecognized or unadvertised value fails with exit code `2`. Enumeration stops at 32 advertised models; a larger or inconsistently ordered catalog warns and falls back to exact advertised-value validation.\n\n**The harness is authoritative.** The client never substitutes a nearby model or silently falls back. A rejected id follows the existing agent-error path; a harness that accepts or ignores it determines the outcome. The public `fallbacks`/`onModelFallback` fields remain for compatibility but model resolution does not emit them.\n\n## Structured output\n\nPass `schema` \u2014 a **plain JSON Schema object literal** (no schema builders exist inside the realm) \u2014 and the call resolves to a **validated object** instead of text:\n\n```js\nconst FINDINGS = {\n type: "object",\n additionalProperties: false,\n required: ["findings"],\n properties: {\n findings: {\n type: "array",\n items: {\n type: "object",\n additionalProperties: false,\n required: ["file", "line", "summary"],\n properties: {\n file: { type: "string", description: "Repo-relative path \u2014 copy it exactly, never invent one" },\n line: { type: "number", description: "1-indexed line the finding anchors to" },\n summary: { type: "string", description: "One sentence stating the defect, grounded in code you actually read" },\n },\n },\n },\n },\n};\n\nconst report = await agent("Review the diff on this branch for correctness bugs.", {\n label: "review", schema: FINDINGS,\n});\nreport.findings.forEach((f) => log(`${f.file}:${f.line} ${f.summary}`));\n```\n\nThe same schema works on **every** backend; only the fulfillment channel differs, and the runner picks it for you: Claude uses its `outputFormat`, Codex its strict `outputSchema`, while Pi, OpenCode, and eligible custom ACP agents receive a client-hosted `StructuredOutput` MCP tool when they advertise HTTP MCP support. Pi accepts stdio, Streamable HTTP, and SSE MCP servers. If no valid tool capture exists, Pi retains the runner\'s common prompt-embedded schema and validated final-text JSON fallback. In every channel the runner validates the value client-side (with type coercion) and re-prompts a bounded number of times before failing the call with non-recoverable `SCHEMA_NONCOMPLIANCE`.\n\nSchema authoring rules that keep all channels healthy:\n\n- Root must be an object; set `additionalProperties: false` and list every property in `required`.\n- Put a `description` on every field \u2014 descriptions are the per-field prompt.\n- Keep schemas structurally simple. Exotic keywords (`oneOf`, `patternProperties`, unusual `format`s, backreference regexes) are normalized or stripped on the wire for some backends \u2014 validation still enforces them client-side, which shows up as re-prompt churn. Prefer `anyOf`, `enum`, and plain types.\n- Keep free-text fields small (tens of lines). An oversized structured output can exhaust schema repair and fail the call.\n- Validation checks structure, not truth. Check load-bearing values in script code (for example, reject findings whose `file` is not in a known file list) before spending more agents on them.\n\n## The `meta` header\n\nEvery script must **begin** with `export const meta = {...}` as a plain object literal (no computed values \u2014 it is parsed from the source text before anything runs):\n\n```js\nexport const meta = {\n name: "fix-flaky-tests", // required\n description: "Find flaky tests and fix them", // required\n phases: [ // optional; one { title, detail?, model? } entry\n { title: "Find", model: "opencode/zai/glm-5.2" }, // per phase() call, matched by exact title;\n { title: "Fix" }, // a phase model is that phase\'s default\n ],\n model: "claude/sonnet", // optional run-wide default model\n backends: { /* optional custom ACP agents \u2014 see "Custom ACP backends" */ },\n};\n```\n\nPer-agent model resolution order: explicit `agent({ model })` > `agent({ tier })` > the current phase\'s `model` > `meta.model` > the host session\'s default. So `meta.phases[].model` gives a whole phase a backend without repeating it on every call.\n\n## Fan-out: `parallel` and `pipeline`\n\n```js\n// parallel: an array of THUNKS (not promises!) run concurrently \u2014 a barrier that\n// resolves in input order. A failed slot resolves to null; filter before use.\nconst sweeps = (await parallel([\n () => agent("Audit error handling in src/server", { label: "sweep:errors", schema: FINDINGS }),\n () => agent("Audit input validation in src/api", { label: "sweep:input", schema: FINDINGS }),\n])).filter(Boolean);\n\n// pipeline: each item flows through the stages independently \u2014 NO barrier between\n// stages, so item A can be in stage 2 while item B is still in stage 1.\n// Stages receive (previousResult, originalItem, index).\nconst verified = (await pipeline(\n sweeps.flatMap((s) => s.findings),\n (f) => agent(`Adversarially verify this finding \u2014 try to refute it:\\n${JSON.stringify(f)}`,\n { label: `verify:${f.file}`, schema: VERDICT }),\n (verdict, f) => ({ ...f, real: verdict.real }),\n)).filter(Boolean).filter((f) => f.real);\n```\n\n**Default to `pipeline`** for multi-stage work. Add a `parallel` barrier only when the next stage needs *all* prior results at once: dedup across the full set, early-exit on a zero count, or prompts that compare "the other findings". The test is the **information dependency** \u2014 a barrier\'s cost is real, because the fastest worker idles for the slowest. All coordination lives in script code: agents cannot see each other, so never ask an agent to "check with the other reviewers" or "spawn helpers". Passing a promise instead of a thunk to `parallel` is a `TypeError` \u2014 wrap every call: `() => agent(...)`.\n\nFan-out also contends for the **working tree**, not just the concurrency limiter. Two agents running builds or test suites in the same checkout collide on build outputs, caches, and lockfiles, and concurrent `git fetch`es contend on the same `.git`. Give run-things agents `isolation: "worktree"` when the commits they must inspect are reachable from the run cwd\'s repository, or serialize them; fan out freely only the agents that just read.\n\nThe host caps concurrent agents per run (default 8); hand `parallel`/`pipeline` as many items as the task needs and let the limiter schedule them. The cap counts active agent attempts, not authored branches: queued branches begin as other attempts finish, and a branch that exhausts its timeout settles to `null` and frees its slot. `workflow(nameOrScript, args)` nests another workflow inline (one level deep, sharing this run\'s budget and limiter) \u2014 inline script strings always work; saved names resolve when the host serves a workflows folder (see the reference section below).\n\n## Failure semantics \u2014 design for `null`\n\n- A **recoverable** failure (timeout, empty output, transient execution error) is retried per the call\'s `retries` (default 0), then the call **resolves to `null`** \u2014 inside `parallel`/`pipeline` *and* as a bare `await agent(...)`. Null-check anything load-bearing, and set `retries: 1\u20132` on steps you can\'t afford to lose.\n- A host can settle one runaway in-flight call with MCP `{ action: "stop", runId, callIndex }` or SDK `manager.cancelAgentCall(runId, callIndex)`. The call resolves to `null` with `AGENT_CANCELLED`, skips every configured retry, and does not abort the run or its siblings. Its failed call record is not cached as a journal result, so a later resume runs that occurrence live.\n- A **non-recoverable** failure (schema never validated, script bug) throws and fails the run. You *may* `try/catch` around an `agent()` call to degrade gracefully \u2014 rethrow anything you can\'t meaningfully handle. In particular, **always rethrow pause-class errors** (`err.code === "PROVIDER_USAGE_LIMIT"` or `"AUTH_REQUIRED"`): they must propagate out of the script so the engine can pause the run resumably \u2014 swallowing one converts that pause into a fake, lossy completion.\n- A **provider quota wall, missing backend authentication, or opted-in durable checkpoint pauses a managed run instead of failing it** \u2014 the journal checkpoints and the host can resume after the budget refills, authentication completes, or a checkpoint decision is supplied. Direct `runner.run()` calls still receive the `AUTH_REQUIRED` error because they have no manager lifecycle.\n- Per-call knobs: `timeoutMs` and `retries`. A finite `timeoutMs` may shorten the host\'s run-level `agentTimeoutMs` ceiling; `null` or omission is uncapped only when the host supplied no ceiling. The timeout is total wall-clock time per attempt, and every retry gets a fresh clock.\n\n## Budgets and phases\n\n```js\nphase("Explore", { budget: 100_000 }); // soft per-phase token sub-budget\n// budget.total (null = unbounded) \xB7 budget.spent() \xB7 budget.remaining() (Infinity when unbounded)\n\nconst found = [];\nwhile (budget.total && budget.remaining() > 50_000 && found.length < 20) {\n const r = await agent("Find one more edge case not in: " + JSON.stringify(found.map((f) => f.name)),\n { label: `edge:${found.length}`, schema: EDGE });\n if (!r) break;\n found.push(r);\n}\n```\n\nGuard budget-driven loops on `budget.total` being set \u2014 with no budget, `remaining()` is `Infinity` and only your own counters stop the loop. The run-level token budget and agent-count cap are hard: once exhausted, further `agent()` calls throw. `phase()` also groups agents in progress UIs and run logs; `log(msg)` (and `console.log`) append to the run log \u2014 narrate what matters, especially anything you drop or cap.\n\n## Built-in quality loops\n\nThese helpers spawn their own subagents (on the default model \u2014 hand-roll with `parallel` + `agent` when you want panel members on specific backends). Full signatures in the reference section below.\n\n| helper | shape | use for |\n|---|---|---|\n| `gate(produce, validate, { attempts })` | produce \u2192 validate \u2192 feed `feedback` back; return `{ ok, value, verdict, attempts }` | produce-until-a-reviewer-approves loops that need the final review evidence |\n| `retry(thunk, { attempts, until })` | bounded retry until `until(result)` holds | flaky single steps |\n| `verify(item, { reviewers, threshold, lens })` | N adversarial reviewers vote `real`/not | killing plausible-but-wrong findings |\n| `judgePanel(attempts, { judges, rubric })` | score candidates 0\u20131 against a rubric, return the best | picking among independent solutions |\n| `loopUntilDry({ round, key, consecutiveEmpty, maxRounds })` | repeat a round, dedup by `key`, stop when dry | unknown-size discovery (bugs, edge cases) |\n| `completenessCheck(args, results)` | one critic lists what\'s still missing | a final "what did we not cover?" pass |\n\nThe `gate` pattern, spelled out \u2014 note how the producer thunk threads the validator\'s feedback into a *fresh* agent\'s prompt (sessions have no memory):\n\n```js\nconst outcome = await gate(\n (feedback, attempt) => agent(\n `Implement the fix described here:\\n${JSON.stringify(plan)}\\n` +\n (feedback ? `\\nA reviewer rejected attempt ${attempt}: ${feedback}\\nAddress every point.` : ""),\n { label: `fix:${attempt + 1}`, model: "codex/gpt-5.6-sol" },\n ),\n (result) => agent(\n `Run the test suite and review this change summary:\\n${result}\\n` +\n `Return ok=true only if tests pass and the fix is correct; include the reviewed commit SHA.`,\n { label: "gate-review", model: "claude/opus[1m]", schema: { type: "object", additionalProperties: false,\n required: ["ok"], properties: { ok: { type: "boolean" }, feedback: { type: "string" },\n commitSha: { type: "string" } } } },\n ),\n { attempts: 3 },\n);\nif (!outcome.ok) log(`reviewer never approved after ${outcome.attempts} attempts`);\nelse log(`reviewer approved commit ${outcome.verdict?.commitSha ?? "(unspecified)"}`);\n```\n\nFeedback is the producer\'s only context for the next attempt. Interpolate everything it needs, and name only files that provably exist.\n\n## Human gates: `checkpoint()`\n\n`checkpoint(promptText, options?)` is a zero-token, journaled human gate. With MCP elicitation (or a live SDK `confirm` callback) it waits for that reply; without a live channel, its default mode takes `default ?? true` immediately, so detached runs never hang.\n\n```js\nconst proceed = await checkpoint(`Apply this plan?\\n${JSON.stringify(plan, null, 2)}`, {\n kind: "confirm", // "confirm" | "input" | "select"\n default: false, // default headless mode takes this (or true)\n // headless: "abort", // abort when no live human is attached\n // headless: "pause", // or persist a resumable human-decision pause\n});\nif (!proceed) return { applied: false, plan };\n```\n\n`kind: "input"` resolves to free text, `kind: "select"` to one of `choices`. How the question reaches a human is the host\'s job (elicitation in the MCP server; `ExecOptions.confirm` in the SDK). With no live channel, `headless: "default"` (the default) takes `default ?? true`, `"abort"` aborts, and `"pause"` returns a managed run with `reason: "checkpoint_required"` plus non-secret `checkpointContext`. Resume the last mode with `checkpointReplies: { [context.callIndex]: decision }` or a live confirm. For `resumeFromRunId`, that key is the source context index; an unambiguous identity match may journal the injected answer at a shifted current index. Put a checkpoint before anything hard to reverse \u2014 applying diffs, pushing, publishing, or the first commit into a working copy the workflow did not create (`default: true` keeps detached runs moving).\n\n## Working directory, isolation, confinement\n\n- Every agent session runs in the run\'s base `cwd` unless the call narrows it: `agent({ cwd: "packages/api" })` (relative resolves against the base).\n- `isolation: "worktree"` runs the agent in a **throwaway git worktree** (`<repoRoot>/.agentprism/worktrees/\u2026`) so parallel agents can edit without colliding. The worktree and its branch are **always deleted when the call ends \u2014 an isolated agent\'s file edits are discarded**. Have isolated agents *return their work as data* (a unified diff, a file map, a report) and apply it in a later non-isolated step; use worktrees for experiments, builds, and verification, not for persistent edits. Outside a git repo, isolation degrades to the shared tree with a logged notice.\n- `resume: { filesystem: "read-only" }` is a deprecated compatibility annotation. It is not a runner mode and has no effect on replay; completed calls replay by journal correspondence whether they read or write. Use `mode`, tool policy, prompts, and worktrees when you actually need confinement.\n- `mode` requests an agent-advertised ACP session mode and is **strict** \u2014 an unsupported mode fails the call rather than running unconfined. Mode ids are backend-specific and drift with harness versions: read the advertised `mode` select from `npx @automatalabs/workflows config <harness>` or a validator report (Codex-family examples: `read-only`, `agent`; Claude-family advertises permission modes such as `plan` and `acceptEdits`; OpenCode via its mode option; Pi advertises thinking-level config rather than modes). Only set `mode` on calls whose `model` you also pin. Use read-only/plan modes for reviewers and auditors that must not write.\n- `agentType: "<name>"` binds a reusable subagent definition \u2014 a Markdown file at `<cwd>/.agentprism/agents/<name>.md` (project) or `~/.agentprism/agents/<name>.md` (user; project wins) whose frontmatter sets tool allow/deny lists, a model, and isolation, and whose body is the role prompt. An unknown name logs a warning and degrades to defaults.\n\n## Where a mutating workflow runs\n\nThe run\'s base `cwd` is the USER\'S checkout \u2014 the working copy they launched the host from. Treat it as borrowed: committing onto whatever branch is checked out, switching branches, or resetting it are defects unless the user asked for exactly that. A script that commits should verify its target workspace in a preflight step, or create its own workspace idempotently, and refuse on a mismatch rather than adapt. `isolation: "worktree"` is NOT such a workspace \u2014 it is per-call and throwaway. Note also that a throwaway worktree branches from the run cwd\'s repository: an isolated agent sees another agent\'s commits only when they are reachable there.\n\n## Wiring tools and inputs into a call\n\n- `mcpServers: [{ name, command, args: [], env: [] }]` attaches MCP servers to that agent\'s session \u2014 the portable way to hand any backend a capability (image generation, a browser, a ticket system). The agent sees the server\'s tools natively. Note `env` is a list of `{ name, value }` pairs (ACP shape), not an object map; HTTP/SSE servers use `{ type: "http", name, url, headers: [] }`.\n- `images: [...]` appends base64 image blocks to the prompt (backends without image support receive a bracketed text note instead).\n- `meta` / `promptMeta` pass generic ACP `_meta` through to `session/new` / `session/prompt` \u2014 the escape hatch for driving a custom agent\'s extension surface.\n- `keepSession: true` keeps a successful agent\'s ACP session re-openable after the run: the re-attach record (sessionId, backend, effective pool identity, cwd, reopen capabilities) lands in `WorkflowRunResult.agentSessions`, and the HOST can continue that conversation later via `runner.loadSession()`. Usage/auth pause failures are kept open automatically so managed resume can continue the interrupted occurrence. Scripts themselves never request reattach.\n\n### Custom ACP backends\n\nAny process that speaks ACP over stdio can serve `agent()` calls \u2014 an in-house browser-QA agent, an image generator, a domain-specific executor. Two ways in:\n\n1. **Host-registered** (preferred): the embedder passes `createAcpRunner({ backends: { browser: { command: "/abs/browser-acp" } } })`; the script just routes with `model: "browser"`.\n2. **Script-declared**: the script itself declares the backend in `meta.backends` \u2014 but declarations are **inert until the host approves them** (an elicitation in the MCP server; `allowScriptBackends` in the SDK), because they spawn commands on the host machine. Don\'t rely on them silently working.\n\n```js\nexport const meta = {\n name: "checkout-qa",\n description: "Implement, then QA the checkout flow in a real browser",\n backends: {\n browser: { command: "browser-acp", args: ["--headless"] }, // requires host approval\n },\n};\n\nconst change = await agent("Implement the coupon-code field per the spec in docs/coupon.md.",\n { label: "implement" }); // default backend\nconst verdict = await agent(\n `Open the app, walk through checkout with coupon SAVE20, and verify the discount line. Change summary:\\n${change}`,\n { label: "qa", model: "browser", // the custom agent\n schema: { type: "object", additionalProperties: false, required: ["passed"],\n properties: { passed: { type: "boolean" }, notes: { type: "string" } } } },\n);\nreturn { change, qa: verdict };\n```\n\nStructured output works on custom backends through the same injected-tool/fallback ladder as OpenCode \u2014 no special-casing in the script.\n\n## Determinism and resume\n\nRuns are journaled: every `agent()` and `checkpoint()` result is recorded under a deterministic call index. A new run may reuse eligible results from a terminal source run. Uncertainty always means live execution.\n\n> **Resume rule:** replay is content-addressed and fail-to-live on correspondence: a completed call replays when its identity and input fingerprint match uniquely. Filesystem or world state never gates replay.\n\n- Direct `Date.now()`, `Math.random()`, and no-arg `new Date()` / `Date()` calls fail static validation. The realm also blocks aliased or computed forms at runtime; `new Date(isoString)` is fine. Pass timestamps and random seeds through `args`.\n- The replay identity of an `agent()` call hashes: the prompt, the resolved `model`, `mode` when set, `configOptions` when non-empty (sorted keys), `tier`, `phase`, `agentType`, the resolved agent definition, and `schema`. The resolved agent definition includes its tool allowlist and denylist, model, isolation, and body prompt \u2014 editing a definition invalidates the calls that use it.\n- A separate input fingerprint hashes: the resolved label, per-call `cwd`, resolved isolation, `keepSession`, `images`, `mcpServers`, `meta`, `promptMeta`, and the approved script-backend digest.\n- Host `agentTimeoutMs`, `agentRetries`, and `concurrency`, plus per-call `timeoutMs` and `retries`, are operational bounds. They enter neither hash and may change freely on resume. A new run resolves them from its own request; it does not inherit the source values.\n- `args` is not hashed directly. New args that only raise a loop cap leave earlier identities unchanged, so those calls can replay. New args that change a prompt, model selection, phase, schema, call order, or runner-visible input make the affected calls run live. Unchanged independent calls may still replay.\n- Matching tries a unique exact `(kind, call path, identity hash)` row first (`"path-hash"`), then a unique `(kind, identity hash, input fingerprint)` row, so an unchanged call can replay as `"unique-hash"` after insertions or deletions. Source and current input fingerprints must be equal. Duplicate identities, duplicate content, consumed candidates, missing facts, and empty schema-less results run live. The engine never guesses by source order or occurrence.\n- Source admission requires: exact `cwd`, compatible call-path/input/checkpoint fingerprint formats, complete call/journal/allocation metadata, and a valid manifest and seed. Git HEAD and dirty digest, `environmentKey`, captured environment values, Node/V8, and producing engine version are diagnostics only. Environment differences may appear in `replayEligibility.provenanceChanges`; they never gate admission or matching.\n- A completed writer replays exactly like a reader. A live call, nested workflow, host checkpoint callback, or degraded worktree does not clear unrelated candidates. Nested child calls run live \u2014 they are outside the parent\'s journal \u2014 while matching root calls around them still replay. The engine does not reproduce file writes; a later live agent navigates the world it finds.\n- Replay preserves budget-driven control flow: a cached call adds its source logical debit to `budget.spent()`/`remaining()`, and zero current provider usage. Replayed session records keep their backend and session identity, rebound to the current call index, label, and phase.\n- A root call interrupted by `PROVIDER_USAGE_LIMIT` or `AUTH_REQUIRED` can continue its recorded session on either resume API. Continuation requires: the exact call index, identity hash, complete input fingerprint, non-worktree isolation, identical existing cwd, a coherent recorded session, and the runner\'s current backend/`poolKey`/reopen gates. A successful continuation finishes the unfinished turn and charges only its usage delta. Every failed gate runs fresh, and `fallbacks` records the reopen method or the exact skip reason. No script option controls this.\n- Completed checkpoint results replay when the identity and the `default`/`headless`/`timeoutMs` fingerprint match \u2014 headless results included. `checkpointReplies` keys always name the checkpoint index in the source run. A moved reply can follow intact prior correspondence; after a live divergence it must reach the exact recorded call site, so a different same-text branch cannot consume it.\n- `resumePolicy: "positional"` is a migration escape hatch for index/prefix matching. It cannot bypass format, metadata, manifest, cwd, or input checks. Marker-less, manual, and same-ID legacy journals keep historical hash-only positional behavior. Input formats below 2 use the `inputs-format-legacy` positional bridge and are rewritten under the current format on the next hop. A current-format crash snapshot with a valid identity manifest uses identity matching even without terminal-environment capture.\n- `label`, `cwd`, `mcpServers`, `images`, `meta`, `promptMeta`, and `keepSession` are not identity-hashed: changing one does not invalidate an ordinary replay. They are in the input fingerprint: changing one rejects continuation of an interrupted turn, and that occurrence runs fresh. To force a completed call to run again, change a hashed field \u2014 normally the prompt.\n- Keep call order deterministic. Derive iteration from `args` and prior agent results, never from ambient state.\n\nEvery `resumeFromRunId` result has a bounded `replayEligibility` summary. Background admission, foreground completion, both await shapes, and inspect expose the same fields: strategy, predicted replayable-prefix length, observed replayed prefix and counts, and the first non-replay when known. Active correspondence reasons include `strategy-live`, `positional-miss`, `positional-suffix`, `not-recorded`, `path-missing`, `inputs-missing`, `inputs-changed`, `ambiguous-identity`, `ambiguous-content`, `candidate-consumed`, `empty-output`, `worktree-degraded`, `seed-persistence-error`, and `resume-fatal-latch`. Older reason literals stay exported only so historical journals parse. Engine and input-format versions and environment provenance ride along as diagnostics.\n\nAn all-live outcome means correspondence could not be established \u2014 not that the world changed. Missing resume metadata, incompatible format literals, or an invalid manifest or seed disable new-format replay. If any source row lacks a captured path or input fact (possible past the raw-frame cap, or with a non-strict-JSON `meta` value), the whole source is `"manifest-invalid"`: dropping the row could make an ambiguous sibling look unique.\n\n### Worked resume \u2014 raise a loop cap\n\nThe following workflow (shipped as `examples/resume-loop-cap.workflow.js`) requires eight reviews but lets the caller cap how many are attempted in one run:\n\n```js\nexport const meta = {\n name: "resume-loop-cap",\n description: "Run expensive review rounds up to an args-controlled cap",\n phases: [{ title: "Review" }],\n};\n\nconst input = args && typeof args === "object" && !Array.isArray(args) ? args : {};\nconst numericCap = Number(input.maxRounds);\nconst maxRounds = Number.isInteger(numericCap) && numericCap > 0 ? numericCap : 8;\n\nphase("Review");\nconst rounds = [];\nfor (let i = 0; i < maxRounds; i += 1) {\n rounds.push(\n await agent(\n `Review round ${i + 1}: inspect the repository and report unresolved release blockers.`,\n { label: `review:${i + 1}`, phase: "Review" },\n ),\n );\n}\n\nif (maxRounds < 8) throw new Error(`review cap ${maxRounds} reached before 8 rounds`);\nreturn { rounds };\n```\n\nRun it with `args: { "maxRounds": 6 }`. Then send the same content (via `script`, or the absolute `scriptPath` you edit) with `args: { "maxRounds": 8 }` and the first result\'s `runId` as `resumeFromRunId`. Rounds 1\u20136 replay for zero current provider tokens; only rounds 7\u20138 run live, because the cap controls call count but is not interpolated into the round prompt. If every round prompt included `maxRounds`, all eight identities would change and all would run live. Resume always states its content; a bare `resumeFromRunId` never silently reuses the old script.\n\nGive repeated calls stable, descriptive labels and narrate decisions with `log()` \u2014 inspection by `labelGlob` then turns a pause or failure into a diagnosis instead of a guess.\n\n### Kill, patch, resume\n\nStop the live run with `{ action: "stop", runId }`. The returned `aborted` snapshot is the durable acknowledgement: resume is safe immediately, and a further await adds nothing. Edit the file. Start a new run with its absolute `scriptPath` and `resumeFromRunId`. Every completed call whose recorded identity and input fingerprint correspond replays, regardless of filesystem or environment drift. Read `replayEligibility` and the full `resumeReport` for the per-call decisions. A repeated stop of a terminal run is a successful no-op.\n\nRegistration, the per-action contracts, background collection, and the events resource are covered in the **Running workflows** section above. Resume a durable checkpoint pause by re-sending the script with `resumeFromRunId` and `checkpointReplies` keyed by the source run\'s `checkpointContext.callIndex`.\n\n## Worked example \u2014 cross-vendor build with every major primitive\n\n```js\nexport const meta = {\n name: "feature-build",\n description: "Plan, gate on approval, implement, cross-vendor review, fix until green",\n phases: [{ title: "Plan" }, { title: "Implement" }, { title: "Review" }],\n};\n\nconst PLAN = { type: "object", additionalProperties: false, required: ["steps", "risks"],\n properties: {\n steps: { type: "array", items: { type: "string", description: "One concrete implementation step" } },\n risks: { type: "array", items: { type: "string" } } } };\nconst VERDICT = { type: "object", additionalProperties: false, required: ["ok"],\n properties: { ok: { type: "boolean" },\n feedback: { type: "string", description: "Required when ok=false: concretely what to change" } } };\n\nphase("Plan");\nconst plan = await agent(\n `Study this repo, then write an implementation plan for: ${args.feature}. Keep steps concrete.`,\n { label: "plan", model: "opencode/zai/glm-5.2", schema: PLAN },\n);\n\nconst approved = await checkpoint(\n `Implement "${args.feature}" with this plan?\\n- ${plan.steps.join("\\n- ")}\\nRisks: ${plan.risks.join("; ")}`,\n { kind: "confirm", default: true },\n);\nif (!approved) return { implemented: false, plan };\n\nphase("Implement");\nconst outcome = await gate(\n (feedback, attempt) => agent(\n `Implement: ${args.feature}\\nPlan:\\n- ${plan.steps.join("\\n- ")}\\n` +\n `Run the project\'s tests before finishing and report results.` +\n (feedback ? `\\n\\nReviewer feedback on attempt ${attempt}:\\n${feedback}\\nAddress every point.` : ""),\n { label: `implement:${attempt + 1}`, model: "codex/gpt-5.6-sol", retries: 1 },\n ),\n async (report) => {\n if (!report) return { ok: false, feedback: "implementation agent produced no result" };\n phase("Review");\n const reviews = (await parallel([ // two reviewers on different vendors\n () => agent(`Review the working-tree diff for correctness. Implementer\'s report:\\n${report}`,\n { label: "review:correctness", model: "claude/opus[1m]", schema: VERDICT }),\n () => agent(`Review the working-tree diff for regressions and missing tests. Report:\\n${report}`,\n { label: "review:coverage", model: "opencode/zai/glm-5.2", schema: VERDICT }),\n ])).filter(Boolean);\n const rejections = reviews.filter((r) => !r.ok);\n return rejections.length\n ? { ok: false, feedback: rejections.map((r) => r.feedback).join("\\n"), reviews }\n : { ok: true, reviews };\n },\n { attempts: 3 },\n);\n\nreturn { implemented: outcome.ok, attempts: outcome.attempts, reviewVerdict: outcome.verdict, plan };\n```\n\n(The planner would ideally run read-only, but mode ids are backend-specific \u2014 this call routes to OpenCode, so it leaves `mode` unset rather than guessing; a Claude-routed planner could safely say `mode: "plan"`.)\n\n## Worked example \u2014 fully backend-agnostic audit\n\nNo `model` anywhere: this script runs unchanged on whatever backend the host defaults to.\n\n```js\nexport const meta = {\n name: "edge-case-audit",\n description: "Exhaustively hunt edge-case bugs in a target dir, verify each, report gaps",\n phases: [{ title: "Hunt" }, { title: "Verify" }],\n};\n\nconst BUGS = { type: "object", additionalProperties: false, required: ["bugs"],\n properties: { bugs: { type: "array", items: { type: "object", additionalProperties: false,\n required: ["file", "scenario"], properties: {\n file: { type: "string", description: "Repo-relative path you actually opened" },\n scenario: { type: "string", description: "Concrete input/state \u2192 wrong behavior" } } } } } };\n\nphase("Hunt");\nconst seen = []; // what earlier rounds reported, threaded into each new prompt\nconst candidates = await loopUntilDry({\n round: async (i) => {\n const r = await agent(\n `Round ${i + 1}: find edge-case bugs in ${args.target} not already in this list:\\n` +\n JSON.stringify(seen) + `\\nOnly report what you can ground in code you read.`,\n { label: `hunt:${i + 1}`, schema: BUGS },\n );\n const bugs = r ? r.bugs : [];\n seen.push(...bugs);\n return bugs; // loopUntilDry dedups these by `key` across rounds\n },\n key: (b) => `${b.file}:${b.scenario}`,\n consecutiveEmpty: 2,\n maxRounds: 8,\n});\n\nphase("Verify");\nconst confirmed = (await pipeline(\n candidates,\n (bug) => verify(bug, { reviewers: 3, threshold: 0.66, lens: ["correctness", "reproducibility"] }),\n (v, bug) => (v.real ? bug : null),\n)).filter(Boolean);\n\nconst gaps = await completenessCheck(args, confirmed);\nlog(`${confirmed.length}/${candidates.length} confirmed; complete=${gaps.complete}`);\nreturn { confirmed, missing: gaps.missing ?? [] };\n```\n\n## Full-scale example scripts\n\nWhen the inline examples above aren\'t enough, study the complete, validated scripts that ship with the published authoring skill:\n\n- [`repo-triage.workflow.js`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/repo-triage.workflow.js) \u2014 an autonomous cross-vendor repo triage and the broadest support-API tour: `pipeline` with no inter-stage barrier, a cross-vendor verification panel, `gate()` where writer and reviewer are different vendors, nesting a saved workflow by name, `completenessCheck()`, budget headroom reservation, string-form `args` hardening, path guards on schema outputs, and pause-class error rethrow.\n- `quick-wins.workflow.js` (included in full at the end of this document) \u2014 a small hunter that runs standalone *or* nested: `loopUntilDry()` with per-round vendor rotation, dedup threading via a `seen` list, and an in-round budget floor (nested runs share the parent\'s budget).\n- [`resume-loop-cap.workflow.js`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/resume-loop-cap.workflow.js) \u2014 content-addressed replay: run with a low `maxRounds`, resume with a higher one; unchanged rounds replay for zero tokens (worked through in Determinism and resume).\n\n[`examples/README.md`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/README.md) maps each script to what it teaches.\n\n## Validate before you run\n\nThe SDK ships a validator that costs **zero tokens** \u2014 always run it on a script you just wrote or edited:\n\n```bash\nnpx @automatalabs/workflows validate my-workflow.js --args \'{"target":"src/"}\'\n```\n\nIt does three passes. First, a **static parse**: the `meta` literal, syntax, and direct\nnondeterministic call expressions. Second, a **dry run**: the engine runs the script\'s control flow\nin its realm, with every `agent()` call served by a mock backend that fabricates\nschema-conforming results \u2014 no real agent runs, and validation is not an execution of the\nworkflow. Third, one no-prompt session for each distinct routed `{ backend, model\n}` pair. The third pass spends no tokens, selects each authored call model, and echoes that pair\'s\nmodel-specific config-options table in the report. Read that table before picking `configOptions`\nvalues; unknown ids, bad select values, wrong value types, and the reserved `"model"` key fail\nvalidation with the call label, authored value, and alternatives. If a routed pair cannot spawn,\nauthenticate, select its model, or open a session, validation emits one warning, marks it\n`probed:false`, skips only that pair\'s checks, and stays valid \u2014 the offline degradation behavior. A\nmock live confirm answers checkpoints with `default ?? true`, so `headless: "pause"` dry-runs\ncleanly; `headless: "abort"` warns because a truly unattended run would abort. Script-declared\n`meta.backends` are treated as approved. The report lists every call with its backend attribution,\nplus warnings for undeclared phases, `headless: "abort"` checkpoints, and zero agent calls.\n(Option-domain clamping rules are in Backends and structured output; the full flag table and\nmock-answer grammar are in `reference.md`.)\n\nThe default fabricator returns `true` for every boolean. Do not accept that all-true path as proof that a convergence loop works: script its control labels with `--mock-answers` or a reusable `--mock-answers-file`. Use a finite `$sequence` such as reject-then-approve so validation executes the revision branch and proves the loop stops; the report identifies every consumed and unused fixture without printing answer bodies.\n\nSave reusable mock answers beside the workflow file (`<name>.mock.json`). When a default-fabrication dry run leaves declared phases unexecuted, your guard branches fired \u2014 script the mocks that reach past them instead of shrugging at the warnings.\n\nExit codes: `0` valid \xB7 `1` parse failure \xB7 `2` dry-run or config-option failure. The full flag table, mock-answers grammar, and limits are in `reference.md`.\n\nThe third pass\'s table is also available standalone \u2014 before any script exists \u2014 as validate\'s sibling command: `npx @automatalabs/workflows config [harness ...]` (default: every routable harness; `--json`; exit `1` when a probe fails). Use `config` while authoring to pick values; validate\'s copy then confirms the script you wrote against the same live catalog.\n\nIf the script nests saved workflows by name (`workflow("review-pr")`), pass the folder so names resolve \u2014 and the positional itself may then be a name: `npx @automatalabs/workflows validate review-pr --workflows-dir ./workflows`. A green dry run proves structure, not judgment \u2014 prompts and schemas still deserve review.\n\n---\n\n# Workflow script reference\n\nExhaustive tables for the AgentPrism workflow script DSL. The guide above covers authoring; this section is the lookup companion. Everything here is verified against `@automatalabs/workflow-engine` / `@automatalabs/acp-agents` as shipped with `@automatalabs/workflows`.\n\n## `agent(prompt, options?)` \u2014 full option table\n\nReturns the agent\'s final assistant text, or the schema-validated object when `schema` is set. Resolves to `null` when a *recoverable* failure survives all retries.\n\n| option | type | meaning |\n|---|---|---|\n| `label` | `string` | Display/telemetry name; also stamped on every live ACP event for this call. Always set it. Not part of the resume hash. |\n| `phase` | `string` | Assign this call to a phase explicitly (needed inside concurrent stages where the global `phase()` state would race). |\n| `schema` | JSON Schema object | Structured output. Plain object literal only \u2014 no schema builders exist in the realm. Part of the resume hash. |\n| `model` | `string` | Model spec: optional registered harness prefix plus a verbatim id, or a backend-only name. See [Model specs & routing](#model-specs--routing). Part of the resume hash. |\n| `tier` | `"small" \\| "medium" \\| "big"` | Coarse tier resolved from host config; beats phase/meta model, loses to explicit `model`. Part of the resume hash. |\n| `mode` | `string` | ACP session mode id advertised by the selected backend. **Strict**: unsupported/unadvertised ids fail the call (never silently unconfined). Ids are backend-specific and drift with harness versions \u2014 read the advertised `mode` select from the config probe or a validator report (Codex-family examples: `read-only`, `agent`, `agent-full-access`; Claude-family advertises permission modes such as `plan`, `acceptEdits`, and `dontAsk`). Part of the resume hash when set. |\n| `configOptions` | `Record<string, string \\| boolean>` | Exact ACP session option ids and authored values. Applied in ascending id order after model and before the prompt, with no aliases or coercion. `"model"` is reserved for the dedicated `model` field. Part of the resume hash only when non-empty, with sorted keys. Read the advertised-options table first (`agentprism-workflows config <harness>`, or any validate report) before choosing values. |\n| `agentType` | `string` | Bind a named subagent definition (tools allow/deny, model, isolation, role prompt). See [agentType definitions](#agenttype-definitions). Part of the resume hash. |\n| `isolation` | `"worktree"` | Run in a throwaway git worktree branched from the run cwd. **Always removed (worktree + branch) when the call ends** \u2014 edits are discarded; return work as data. Degrades to the shared tree outside a git repo (logged). |\n| `resume` | `{ filesystem: "read-only" }` | Deprecated compatibility annotation. It is recorded as legacy diagnostic provenance, is not sent to the runner or hashed, and has no effect on replay. New scripts should omit it. |\n| `cwd` | `string` | Per-session working directory; relative resolves against the run\'s base cwd. Overridden by worktree isolation. Not hashed. |\n| `timeoutMs` | `number \\| null` | Total wall-clock cap for each attempt. A finite value may tighten a finite host `agentTimeoutMs` ceiling but cannot raise or disable it. With no host ceiling, a finite value applies and `null`/omitted is uncapped. |\n| `retries` | `number` | Retries after *recoverable* failures (default 0, host-overridable). Exhausted retries \u21D2 the call resolves `null`. |\n| `mcpServers` | `McpServerConfig[]` | MCP servers attached to this session. Stdio shape: `{ name, command, args: [], env: [{ name, value }] }` (`args`/`env` required, `env` is name/value pairs, not a map); `{ type: "http" \\| "sse", name, url, headers: [] }` also accepted. Not hashed. |\n| `images` | `PromptImage[]` | Base64 image blocks appended to the prompt; backends without image support get a bracketed text note. Not hashed. |\n| `meta` | `object` | ACP `_meta` merged into `session/new` \u2014 session-scoped extension passthrough (pairs with custom backends). Not hashed. |\n| `promptMeta` | `object` | ACP `_meta` merged into `session/prompt` \u2014 turn-scoped passthrough. Backend-computed keys win on conflict. Not hashed. |\n| `keepSession` | `boolean` | Skip release-time best-effort `session/close`; the non-secret re-attach record lands in `WorkflowRunResult.agentSessions` for host-side `loadSession()` / `resumeSession()`. Usage/auth pause failures are kept open automatically for managed continuation. Not identity-hashed; included in the input fingerprint. |\n\nThe timeout clock measures the whole attempt, including backend startup, model/config setup, tool\nwork, and streamed output; it is not an idle timer. Each retry starts a fresh clock, so the maximum\ntimeout envelope is `(retries + 1) \xD7 resolved timeoutMs` (retries are clamped to 3). An exhausted\ntimeout is recoverable `AGENT_TIMEOUT`: the call resolves to `null`, releases its concurrency slot,\nand asks the ACP session to cancel. A session that keeps running after the cancellation grace is\nclosed where supported and its pooled child is recycled.\n\nEvery new run, including one admitted with `resumeFromRunId`, resolves host limits from that run\'s\nrequest. It does not inherit `agentTimeoutMs`, retries, concurrency, agent-count, or token-budget\nvalues from its source, so pass every operational bound the resumed execution should use.\n\n## Model specs & routing\n\nA `model` string is resolved solely from its first segment, then delegated to the harness:\n\n| spec shape | routes to | notes |\n|---|---|---|\n| *(omitted)* | host default backend | `AGENTPRISM_DEFAULT_BACKEND` (`claude` \\| `codex` \\| `opencode` \\| `pi` \\| custom name; default `claude`), session default model. Most portable. |\n| `claude`, `codex`, `opencode`, `pi`, or `<custom-name>` | that registered harness | Backend-only: no model config call; the harness default remains active. |\n| `claude/<id>`, `codex/<id>`, `opencode/<id>`, `pi/<id>`, or `<custom-name>/<id>` | that registered harness | Match the first segment ASCII-case-insensitively and strip exactly one segment. Custom names take priority on collision. The remaining `<id>` is sent verbatim, including further `/` characters. For Pi, that remainder is its `<provider>/<model-id>` and Pi preserves any further slashes in the model id. |\n| any other string, including `anthropic/\u2026`, `openai/\u2026`, bare `opus`, or bare `gpt-\u2026` | host default backend | The **entire** authored string is sent verbatim; these are not routing aliases. |\n\nSelection is a single `session/set_config_option` with `configId: "model"` and the exact remaining string. There is no catalog matching, case folding, normalization, bracket parsing, nearest-neighbor selection, sibling effort/Fast option driving, retry, or echo verification. Brackets, dots, and provider-style prefixes are ordinary model-id characters.\n\nWhatever the harness returns is the outcome. A rejection follows the existing agent-error path with no resolution-specific code or model fallback event. `onModelFallback` and `WorkflowRunResult.fallbacks` remain public compatibility surfaces; model resolution does not emit entries, while pause recovery emits `kind: "continuation"` reattach/skip notices.\n\n## Structured output channels\n\nOne author API (`schema`), four fulfillment paths \u2014 chosen automatically per backend:\n\n| backend | channel |\n|---|---|\n| Claude | native `outputFormat`, schema normalized to Anthropic\'s structured-outputs subset (e.g. `oneOf` \u2192 `anyOf`; unsupported keywords/formats stripped on the wire) |\n| Codex | native strict `outputSchema` (OpenAI strict subset normalization) |\n| Pi | a client-hosted `StructuredOutput` MCP tool injected when the agent advertises HTTP MCP support; common prompt-embedded schema and validated final-text JSON fallback |\n| OpenCode / custom ACP | a client-hosted **`StructuredOutput` MCP tool** injected into the session when the agent advertises HTTP MCP support (an agent may show it as `structured_output_StructuredOutput`); otherwise prompt-embedded schema + JSON parse of the final message. Custom backends can opt out of tool injection with `structuredOutputTool: false`. |\n\nPi accepts stdio, Streamable HTTP, and SSE MCP servers; ACP-transport MCP hosting remains client-side.\n\nIn every channel the runner coerces + validates client-side and re-prompts a bounded number of times; the final miss fails the call with non-recoverable `SCHEMA_NONCOMPLIANCE`. Constraints stripped from the wire are still enforced client-side \u2014 an exotic schema keyword shows up as re-prompt churn, so keep schemas simple.\n\n## DSL globals \u2014 complete signatures\n\n```\nagent(prompt, options?) \u2192 Promise<string | object | null>\nparallel(thunks) \u2192 Promise<results[]> // barrier; input order; failed slot = null\npipeline(items, ...stages) \u2192 Promise<results[]> // no inter-stage barrier; stage(prev, original, index); failed item = null\nworkflow(nameOrScript, args?) \u2192 Promise<unknown> // one nesting level; names resolve from the host\'s workflows folder, inline scripts always work\ngate(thunk, validator, { attempts = 3 }) \u2192 { ok, value, verdict, attempts }\n // thunk(feedback, attempt); validator(result) \u2192 { ok, feedback?, ... } | boolean | null (may be async / an agent call)\nretry(thunk, { attempts = 3, until? }) \u2192 last result // thunk(attempt); stops early when until(result)\nverify(item, { reviewers = 2, threshold = 0.5, lens? })\n \u2192 { real, realCount, total, votes: [{ real?, reason? }] }\n // N adversarial reviewers prompted to REFUTE; lens (string | string[]) rotates focus per reviewer\njudgePanel(attempts, { judges = 3, rubric = "overall quality and correctness" })\n \u2192 { index, attempt, score, judgments } // mean 0\u20131 score per candidate; stable tie-break by index\nloopUntilDry({ round, key = JSON.stringify, consecutiveEmpty = 2, maxRounds = 50 })\n \u2192 unique items[] // round(i) returns items; stops after N dry rounds; budget exhaustion returns the partial result\ncompletenessCheck(taskArgs, results) \u2192 { complete, missing?: string[] }\ncheckpoint(promptText, options?) \u2192 Promise<reply> // journaled human gate; zero tokens\nphase(title, { budget? }) \u2192 void // soft per-phase token sub-budget\nlog(message) \u2192 void // console.log/info/warn/error route here too\nargs // the host-provided input value, verbatim\ncwd // the run\'s base working directory (string); process.cwd() returns it too\nbudget.total | budget.spent() | budget.remaining()\n```\n\nFor `gate()`, `value` is the final producer result and `verdict` is the exact last completed\nvalidator return, including any extra structured fields. `{ ok: true }` and bare `true` pass;\n`{ ok: false, feedback? }`, bare `false`, and `null` reject. Only object feedback is threaded into\nthe next producer attempt. A producer result of `null` is still passed to the validator. Producer\nor validator exceptions propagate immediately, so no partial gate result is returned and no later\nattempt runs. An explicit unsupported `undefined` validator return is a rejection represented as\n`verdict: null`. If the script returns the gate result, its complete verdict is persisted and may\nreach the host; keep evidence concise and never put credentials or other secrets in verdict data.\n\n`verify`, `judgePanel`, and `completenessCheck` spawn their subagents on the run\'s default model \u2014 hand-roll with `parallel` + `agent` to pin panel members to specific backends.\n\n## `checkpoint()` options\n\n| option | type | meaning |\n|---|---|---|\n| `kind` | `"confirm" \\| "input" \\| "select"` | Reply shape: boolean-ish / free text / one of `choices`. Affects the journal hash and the host UI widget. |\n| `choices` | `string[]` | For `kind: "select"`. |\n| `default` | `unknown` | Reply taken in the default headless mode \u2014 journaled like a real reply. Defaults to `true`. |\n| `headless` | `"default" \\| "abort" \\| "pause"` | No live channel: `"default"` takes `default ?? true`, `"abort"` aborts, and `"pause"` creates a persisted `checkpoint_required` pause. Default `"default"`. |\n| `timeoutMs` | `number` | Deadline for the interactive prompt. |\n\nThe host supplies the live human channel (elicitation in the MCP server; `ExecOptions.confirm` in the SDK), and that channel wins even when `headless: "pause"` is declared. A durable pause carries non-secret `checkpointContext`; resume with `ExecOptions.checkpointReplies: { [context.callIndex]: decision }` or attach a live channel. On a new `resumeFromRunId` execution, reply keys always name indexes in the **source** recording; identity matching may inject that decision at a shifted current index. Completed host and headless checkpoint results both replay when identity and the checkpoint-options fingerprint over `default`, `headless`, and `timeoutMs` match. A changed option or ambiguous match runs fresh. Detached runs never pause for a checkpoint unless the author opts into `"pause"`.\n\n## Error codes (`WorkflowError.code`)\n\n| code | recoverable | engine behavior |\n|---|---|---|\n| `AGENT_TIMEOUT` | yes | Total wall-clock attempt cap exhausted. Every retry gets a fresh clock; after the final attempt the call resolves `null`, and ACP cancel escalates to close/recycle when the turn does not stop. |\n| `AGENT_CANCELLED` | yes | The host selected this in-flight call for cancellation. It resolves `null` immediately through an engine race, skips retries, leaves the run live, and is recorded as a failed call rather than a replayable journal result. |\n| `AGENT_EMPTY_OUTPUT` | yes | No assistant text on a schema-less call; same retry-then-`null`. |\n| `AGENT_EXECUTION_ERROR` | yes* | Generic agent failure (*refusal/truncation variants are non-recoverable). |\n| `SCHEMA_NONCOMPLIANCE` | no | Structured output never validated after the re-prompt ladder. Halts the run (catchable in-script). |\n| `PROVIDER_USAGE_LIMIT` | no | Quota/rate wall \u2014 the run **pauses** (journaled, resumable), with the provider\'s reset hint. |\n| `TOKEN_BUDGET_EXHAUSTED` | no | Run (or phase) token cap hit; further `agent()` calls throw. |\n| `AGENT_LIMIT_EXCEEDED` | no | `maxAgents` cap hit. |\n| `AUTH_REQUIRED` | no | Backend needs authentication. `WorkflowManager` returns a resumable pause with `reason: "auth_required"` and redacted `authContext`; a direct runner throws. The host completes auth before resuming/retrying. |\n| `CHECKPOINT_REQUIRED` | no | `headless: "pause"` reached without a live channel. `WorkflowManager` returns `reason: "checkpoint_required"` plus non-secret `checkpointContext`; resume with `checkpointReplies` or live confirm. |\n| `SCRIPT_VALIDATION_ERROR` | no | Script failed parse/validation (bad meta, nondeterministic API, bad `meta.backends` shape). |\n| `SCRIPT_ERROR` | no | The script itself crashed (uncaught throw, floated rejection). |\n| `WORKFLOW_ABORTED` | \u2014 | Real cancellation (pause/stop/host signal) \u2014 never used for crashes. |\n\n`loopUntilDry` absorbs `TOKEN_BUDGET_EXHAUSTED` / `AGENT_LIMIT_EXCEEDED` from its rounds and returns the partial result; everywhere else those propagate.\n\n## Determinism & the resume journal\n\n> **Resume rule:** replay is content-addressed and fail-to-live on correspondence: a completed call replays when its identity and input fingerprint match uniquely. Filesystem or world state never gates replay.\n\nThe guide section **Determinism and resume** carries the full semantics: what each hash contains, matching, admission, continuation of interrupted calls, and checkpoint replay. Wire-level specifics for lookup:\n\n- Each `agent()` result is journaled under a monotonic call index and a SHA-256 identity hash. The canonical identity fields, in order, are `prompt`, resolved `model`, `mode` only when set, `configOptions` only when non-empty, `tier`, `phase`, `agentType`, resolved `agentDef`, and `schema`. Config-option keys are sorted before serialization. Missing fields other than `mode` and `configOptions` serialize as `null`; an unset `mode` and an unset/empty `configOptions` key are omitted for compatibility with older journals.\n- `agentDef` is the resolved definition\'s tools, disallowed tools, model, isolation, and body prompt. Changing a named definition therefore invalidates its call even when the `agentType` name is unchanged.\n- The legacy `resume: { filesystem: "read-only" }` annotation has no effect on admission or matching. Writers, readers, worktree calls, and unannotated calls follow the same journal rule.\n- `resumePolicy: "positional"` requests index/prefix correspondence but cannot bypass new-format format, metadata, manifest, cwd, or input checks. Marker-less journals and permanently marked manual/same-run legacy resumes retain historical hash-only positional behavior. Sources below input format 2 use `inputs-format-legacy`. Ancestor-scoped rows carried by a \u22640.23 resume hop replay only while that ancestor is still persisted; engine-minted nested scopes and deleted ancestor scopes stay live.\n- There is no `require`, `import`, Node API, or network API in the realm. `Date.now()`, `Math.random()`, and no-arg `new Date()` / `Date()` fail static validation; aliased or computed forms are blocked at runtime; `new Date(value)` works.\n\nEvery new-run resume exposes `replayEligibility` on admission, polling, inspection, and the terminal result. It reports strategy, predicted/observed replayable prefix and counts, first non-replay/reason/detail, engine/input-format diagnostics, non-gating runtime/environment `provenanceChanges`, and non-gating operational changes; `resumeReport` retains the complete terminal per-call correspondence.\n\nAn all-live outcome is expected when correspondence cannot be established, not when the world changed. Missing resume metadata, incompatible format literals, or an invalid manifest/seed can disable reuse. A new-format source containing any result row without a captured call path/input fact\u2014possible with a call stack deeper than the raw-frame cap or a non-strict-JSON `meta` value\u2014is source-wide `"manifest-invalid"`; excluding the row could make an ambiguous sibling look unique. Format-1 bytes are never reinterpreted; they enter the positional bridge and replayed rows are recorded under format 2.\n\nAn args-controlled cap is the useful case: a cap that changes how many calls are reachable, but\ndoes not appear in an earlier call\'s prompt, lets those calls replay on resume. The worked example\nlives in the determinism-and-resume guide document and ships as\n`examples/resume-loop-cap.workflow.js`. This changed-args pattern is specific to new-run entry\npoints that accept current args with `resumeFromRunId`. The MCP `workflow` tool does, as does\n`WorkflowManager.runSync(script, newArgs, { resumeFromRunId })`. MCP resume always requires\nexplicit content; a bare `resumeFromRunId` is invalid. `WorkflowManager.resume(runId)` is a\ndifferent same-ID recovery API: it reloads the persisted original script/args and permanently uses\nlegacy positional replay semantics, while the independent default-on channel may still continue an\neligible usage/auth-interrupted live call.\n\n## <a name="custom-backends-metabackends"></a>Custom backends \u2014 `meta.backends`\n\n```js\nexport const meta = {\n name: "\u2026", description: "\u2026",\n backends: {\n browser: {\n command: "browser-acp", // required: executable (absolute or on PATH)\n args: ["--headless"], // default []\n env: { BROWSER_PROFILE: "qa" }, // merged OVER the child\'s inherited env \u2014 per-backend secrets go here\n sessionMeta: { viewport: "desktop" }, // static ACP _meta on every session/new (per-call `meta` merges over it)\n structuredOutputTool: true, // default true; false = keep this backend on the prompt/_meta schema fallback\n },\n },\n};\n```\n\nScript-declared backends are **trust-gated**: they spawn commands on the host machine, so they stay inert until the composition root approves them \u2014 elicitation approval in the MCP server, `allowScriptBackends: true` (or a per-backend callback) on `runDynamicWorkflow`, `ExecOptions.scriptBackends` on a manager, or `AGENTPRISM_ALLOW_SCRIPT_BACKENDS=1`. A *declined* backend aborts the run rather than silently rerouting its calls to the default backend. Host-registered names always win over script declarations. Prefer host registration (`createAcpRunner({ backends })` / `AGENTPRISM_BACKENDS` env JSON) when you control the host.\n\n## <a name="agenttype-definitions"></a>`agentType` definitions\n\nMarkdown files at `<runCwd>/.agentprism/agents/<name>.md` (project) and `~/.agentprism/agents/<name>.md` (user); project wins on name collision. Frontmatter + body:\n\n```markdown\n---\ndescription: Read-only security auditor\ntools: [read, grep, glob] # allowlist of tool names (omit = all)\ndisallowedTools: [bash] # denylist, applied after the allowlist\nmodel: claude/opus[1m] # verified id; agent({ model }) overrides it\nisolation: worktree # optional\n---\nYou are a security auditor. Report findings; never modify files.\n```\n\nThe body is prepended to the agent\'s task as role guidance. An unknown `agentType` logs a warning and runs with default tools/model (the name degrades to a prose hint).\n\n## How hosts run scripts (what authors can assume)\n\nThe MCP route (`npx @automatalabs/mcp-server`, tool name `workflow`) is the canonical way an agent\nruns an authored script; registration and the per-action contracts are in the Running workflows\nguide section. The `workflow` tool is the server\'s whole tool surface: run/resume/inspect/await/stop\nare action branches, not separate tools, and this input does not resolve a saved workflow name. A\nrun that pauses with `reason: "auth_required"` resumes via a new run after the backend\'s own CLI is\nlogged in out-of-band (see below). Prompt-capable MCP hosts (e.g. Claude Code, where it surfaces as\na slash command) also get this entire guide from the server itself as the **`author-workflow`**\nprompt, with an optional `task` argument.\n\nEnvironment knobs shared by the MCP server and the SDK: `AGENTPRISM_DEFAULT_BACKEND`,\n`AGENTPRISM_ACP_POOL_SIZE` (schema-run parallelism on OpenCode/custom backends scales with the\npool; one injected-tool registry per process), `AGENTPRISM_BACKENDS`,\n`AGENTPRISM_ALLOW_SCRIPT_BACKENDS`, `AGENTPRISM_PERSISTENCE_ROOT`, plus per-backend spawn\noverrides. Pi uses `AGENTPRISM_PI_ACP_CMD` with optional `AGENTPRISM_PI_ACP_ARGS`; otherwise the\ninstalled exact-pinned package bin is used before the `npx -y @automatalabs/pi-acp` fallback.\n\nEmbedding hosts drive the same contract directly through the SDK \u2014 `runDynamicWorkflow` /\n`WorkflowManager` from `@automatalabs/workflows`, with `exec` limits (`tokenBudget`, `maxAgents`,\n`concurrency`, `agentTimeoutMs`, `agentRetries`), a live `confirm` checkpoint channel, and\n`exec.resumeFromRunId` for edited-script resume. See `docs/api.md` in the repository. The shapes\nbelow are the MCP tool surface, which is what script authors interact with.\n\nExact MCP tool input/output types:\n\n```ts\ninterface WorkflowExecuteToolInputBase {\n action?: "run";\n args?: unknown;\n maxAgents?: number;\n concurrency?: number;\n agentRetries?: number;\n agentTimeoutMs?: number | null;\n tokenBudget?: number | null;\n resumeFromRunId?: string;\n resumePolicy?: "auto" | "positional";\n checkpointReplies?: Record<number, unknown>;\n background?: boolean; // default false\n}\n\ntype WorkflowExecuteToolInput = WorkflowExecuteToolInputBase & (\n | { script: string; scriptPath?: never }\n | { script?: never; scriptPath: string } // absolute path on the server\n);\n// WorkflowExecuteToolInputBase also carries projectDir?: string \u2014 the absolute project\n// directory selecting the project-scoped run store and default execution cwd. REQUIRED for\n// run on the shared workflow daemon (one registration serves every project); optional on a\n// single-project (--in-process) server. inspect/await/stop never take it: a runId locates\n// its project store automatically.\n\ninterface WorkflowAwaitToolInput {\n action: "await";\n runId: string;\n waitMs?: number; // default 20_000; integer 0..25_000\n lastN?: number; // default 20; integer 1..50\n labelGlob?: string; // same whole-label glob as inspect\n logLines?: number; // default 20; integer 0..50\n}\n\ninterface WorkflowBackgroundAccepted {\n runId: string;\n status: "running";\n scriptSource: "inline" | "path";\n scriptUri: string;\n limits: WorkflowRunLimits;\n replayEligibility?: WorkflowReplayEligibility;\n}\n\ninterface WorkflowAwaitMetadata {\n requestedMs: number;\n elapsedMs: number;\n returnedBecause: "terminal" | "timeout" | "immediate";\n}\n\ninterface WorkflowRunAwaitResult<T = unknown> extends WorkflowRunStatus {\n wait: WorkflowAwaitMetadata;\n tokenUsage?: TokenUsage;\n outcome?: Omit<WorkflowExecutionToolResult<T>, "scriptSource">; // exactly when terminal\n scriptUri: string;\n lineage: Array<{ runId: string; uri: string; available: boolean }>;\n}\n\ninterface WorkflowStopToolInput {\n action: "stop";\n runId: string;\n callIndex?: number; // omitted = whole-run abort; present = cancel one in-flight agent\n lastN?: number;\n labelGlob?: string;\n logLines?: number;\n script?: never;\n scriptPath?: never;\n waitMs?: never;\n}\n```\n\nThe selected stop form requires a live, uniquely addressable agent attempt. Settled/unallocated\nindexes, checkpoints, duplicate scoped indexes, and terminal runs are errors that enumerate the\ncurrently in-flight call-index/label pairs. A successful selected cancellation returns the ordinary\nlive `WorkflowRunStatus`; whole-run stop returns the terminal `WorkflowStopResult`.\n\n`WorkflowRunResult.fallbacks?: WorkflowRunFallback[]` retains the compatibility shape\n`{ callIndex, label, phase?, requestedSpec, resolvedModel?, backendId?, kind, message, continuation? }`.\n`kind` is `model | modifier | continuation`; continuation details report either a reattached\n`resume | load` method or an exact skip reason. The model-resolution pipeline itself produces no entries.\n`WorkflowRunResult.checkpointsTaken?: WorkflowCheckpointTaken[]` records resolved checkpoints as\n`{ callIndex, kind, decision, source }`, where source is `live`, `headless-default`,\n`journal-replay`, or `injected`. A paused checkpoint is not resolved. Both fields are persisted and\nappear in foreground results plus terminal await `outcome`; neither appears on `WorkflowRunStatus`.\n\nAt most four background runs may be active or starting per server instance. Foreground, inspect,\nawait, and stop consume no slot; a durably stopped background run frees its slot immediately even\nwhile backend session wind-down remains. A timeout returns the freshest status and partial cumulative usage; replay\nhits cost/add zero. Terminal results have no MCP TTL and are reconstructed after restart while the\nproject run record remains readable. The inherited status fields stay redacted/bounded at 24,576\nstructured bytes and 8,192 text bytes. The full script lineage is never truncated; when lineage\nalone exceeds the status budget, `truncation.maxStructuredBytes` reports the larger actual envelope\nlimit. Terminal `outcome` preserves the raw authored result/full logs and has no new total cap, but\nit is never copied into text. It includes `scriptUri` but not the unpersisted admission-only\n`scriptSource`.\n\nThe background start has no enduring request signal, progress channel, or live checkpoint channel.\nIt returns immediately and emits no progress after returning, even if the initiating request\nsupplied a progress token. A later bounded `action:"await"` is a separate request; when that await\ncarries a progress token, it can stream coarse phase and distinct started/ended-call progress while\npending. The legacy/inconsistent-log polling fallback emits no progress notifications. A headless\ncheckpoint default continues; abort fails with `WORKFLOW_ABORTED`; pause returns\n`checkpoint_required` plus `outcome.checkpointContext`. Auth pauses return non-secret\n`outcome.authContext`; log the backend CLI in before resume. Background execution lives in the\nserving process (the daemon, or the single process under `--in-process`): that process\'s death can\ninterrupt an in-flight call, and stale durable `pending`/`running` state reconciles under its lease\nto `paused` / `interrupted`.\n\nEvery resumed background run durably seeds its inherited prefix (including a manager-owned\ncheckpoint injection) beneath its new run ID before acknowledgement, so later resume hops remain\nself-contained. The MCP layer never rewrites that seed. Await and inspect never execute or resume\nthe script; their cold preflight may only reconcile a dead owner\'s stale `pending`/`running` state\nto `paused` / `interrupted`.\n\nEvery admitted script is an immutable persistence-backed MCP resource at\n`workflow://runs/{runId}/script`. Run results link the new script; inspect/await link the full\nresume lineage oldest-to-newest as structured `{ runId, uri, available }` entries. Listing and\ncompletion include only the 50 newest runs, but a direct URI read works for any retained project\nrun. A path is never persisted or implicitly re-read, and the MCP layer retains no scripts, args,\nor synthetic lineage metadata in process memory.\n\n`action:"stop"` durably aborts a `running` or `paused` run live in the serving process: it cancels\nany pending agent/checkpoint request, appends `stopped`, releases the lease, and returns the final\ninspection projection with `stopped:true`. Only backend session wind-down can remain, observable\nthrough inspect\'s agent states. A repeated stop on a terminal run succeeds with `stopped:false,\nalreadyTerminal:true`. An in-flight stop may lack a quiescent terminal-environment proof, so the\nmanager can conservatively run the following resume live; inspect `replayEligibility` and\n`resumeReport` rather than assuming a prefix replay.\n\nRetain the run ID and inspect halted runs before guessing. The exact inspection input is:\n\n```ts\ninterface WorkflowInspectToolInput {\n action: "inspect";\n runId: string; // /^[a-z0-9]+-[a-z0-9]+$/, at most 128 characters\n lastN?: number; // default 20; integer 1..50\n labelGlob?: string; // non-empty; at most 128 Unicode code points\n logLines?: number; // default 20; integer 0..50\n script?: never;\n scriptPath?: never;\n}\n```\n\n`labelGlob` matches the whole raw agent label case-sensitively: `*` is zero or more Unicode code\npoints, `?` is exactly one, and backslash escapes the next character (a trailing backslash is\nliteral). Checkpoints and unknown legacy calls are excluded when a glob is present. Filtering\nhappens before `lastN`; selected calls return in ascending call-index order.\n\n```ts\ninterface WorkflowLogTail {\n lines: string[];\n totalLines: number;\n omittedLines: number;\n truncatedLines: number;\n redactedLines: number;\n}\n\ninterface WorkflowRunCallStatus {\n index: number;\n kind: "agent" | "checkpoint" | "unknown";\n label?: string;\n phase?: string;\n model?: string;\n backendId?: string;\n timeoutMs?: number | null;\n errorCode?: string;\n resultPreview: string;\n resultRedacted: boolean;\n resultTruncated: boolean;\n}\n\ninterface WorkflowRunStatus {\n runId: string;\n status: "pending" | "running" | "paused" | "completed" | "failed" | "aborted";\n workflowName: string;\n phases: string[];\n currentPhase?: string;\n reason?: string;\n errorCode?: string;\n limits?: WorkflowRunLimits; // absent only on legacy persisted records\n replayEligibility?: WorkflowReplayEligibility;\n logTail: WorkflowLogTail;\n calls: WorkflowRunCallStatus[];\n filter: { lastN: number; logLines: number; labelGlob?: string };\n truncation: {\n maxStructuredBytes: number;\n byteCapApplied: boolean;\n phases: { total: number; returned: number; shortened: number };\n logs: { total: number; returned: number; shortened: number; redacted: number };\n calls: {\n total: number;\n matched: number;\n returned: number;\n shortenedResults: number;\n redactedResults: number;\n };\n };\n}\n\ninterface WorkflowRunLimits {\n maxAgents: number;\n tokenBudget: number | null;\n concurrency: number;\n agentRetries: number;\n agentTimeoutMs: number | null;\n}\n```\n\nInspection returns only this allowlisted projection: never raw script, args, prompts, histories,\nhashes, session IDs, cwd, checkpoint/auth details, or raw results. Credential-shaped data is\nredacted, results are structurally compacted, every outward text scalar/preview is capped at 512\nUTF-8 bytes, inherited status JSON at 24,576 bytes, and inspection text at 8,192 bytes. Full lineage\ncan raise the structured envelope limit as reported by `truncation.maxStructuredBytes`. An unknown ID is\na tool error with no structured content; reading an existing failed run succeeds and reports\n`status:"failed"`. Every paused, failed, or aborted execution result also carries a redacted\nfinal-20 `logTail` (present when empty) and renders it in the immediate terminal text. Completed\nexecution results omit that extra field while retaining their full `logs` array.\n\nBackend auth comes from the machine the host runs on: Claude via a logged-in Claude Code install or `ANTHROPIC_API_KEY`; Codex via `~/.codex/auth.json`; OpenCode via `opencode auth login` (its CLI must be installed \u2014 it is not bundled); Pi via one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`, or ambient credentials in `~/.pi/agent/auth.json`. A script only needs auth for the backends it actually routes to.\n\n## The validator \u2014 `agentprism-workflows validate`\n\n```bash\nnpx @automatalabs/workflows validate <workflow-file> [options]\n```\n\nZero tokens; three passes \u2014 static parse, mocked dry run, then one no-prompt config probe per\nrouted `{ backend, model }` pair \u2014 described in the guide\'s Validate before you run section. The\ntables and grammar below are the exhaustive contract.\n\n| flag | meaning |\n|---|---|\n| `--args <json>` / `--args-file <path>` | the script\'s `args` global for the dry run |\n| `--mock-answers <json>` | label-glob answers for dry-run calls; mutually exclusive with the file form |\n| `--mock-answers-file <path>` | read the same JSON object from a UTF-8 file resolved against the process cwd |\n| `--workflows-dir <dir>` | repeatable; a folder of workflow scripts (name = filename stem). Lets the positional be a NAME and resolves nested `workflow("<name>")` calls |\n| `--parse-only` | static parse only |\n| `--cwd <dir>` | dry-run base cwd (default: throwaway temp dir, so `isolation: "worktree"` no-ops; a real repo cwd creates and cleans up real worktrees) |\n| `--token-budget <n>` | sets `budget.total`; the mock reports 1000 tokens per agent call |\n| `--max-agents <n>` | cap on dry-run agent calls |\n| `--timeout-ms <n>` | dry-run wall-clock limit (default 30000) |\n| `--json` | machine-readable `ValidateWorkflowReport` on stdout |\n\nInline false-branch fixture (exact shell form):\n\n```bash\nagentprism-workflows validate flow.workflow.js \\\n --mock-answers \'{"refute:*":{"real":false}}\'\n```\n\nEquivalent reusable file with a reject-then-approve sequence:\n\n```json\n{\n "refute:*": { "real": false },\n "quality:review": {\n "$sequence": [\n { "ok": false, "feedback": "exercise the revision path" },\n { "ok": true }\n ]\n }\n}\n```\n\n```bash\nagentprism-workflows validate flow.workflow.js --mock-answers-file mock-answers.json\n```\n\nRules match the final resolved label case-sensitively across the whole string. `*` matches zero or more characters (including `:` and `/`), `?` one character, and `\\` escapes the next character; empty globs and trailing escapes are invalid. Object order is captured once and the **last matching rule wins**, so put `"*"` before narrower exceptions. Raw canonical array-index keys (`"0"` or a non-zero, no-leading-zero decimal through `"4294967294"`) are reserved because ECMAScript reorders them. To match numeric label `10`, use JSON key `"\\\\10"`; `"01"` and `"4294967295"` are ordinary keys.\n\nA single answer is reusable. `{ "$sequence": [...] }` is finite and only the winning rule consumes it; a raw array is one array result, and a sequence element is ordinary answer data even when it contains `$sequence`. Exhaustion fails instead of repeating the last item or falling back. The machine report uses zero-based `sequenceIndex`; human lines render one-based `[position/length]`. Earlier matching rules count the match even when shadowed, and `dryRun.mockAnswers.unused` distinguishes `no-match`, `shadowed`, and partially consumed `not-reached` items. Unused fixtures warn but do not fail validation.\n\nFor schema calls, each answer deep-merges over a **fresh** fabricated base: JSON objects merge recursively; arrays, `null`, falsy primitives, and other scalars replace. The merged value is TypeBox-checked without coercion. Any answer-caused violation fails non-recoverably with `SCHEMA_NONCOMPLIANCE`; a failure already present at the identical untouched path/message in the simple fabricated base may be accepted with a grouped inherited-fabrication warning. A valid override can repair such a base limitation. Schema-less answers must be nonblank strings. Fixture failure messages, attribution, and warnings contain only labels, globs, positions, paths, and counts\u2014not answer values.\n\nLimits: 256 KiB raw UTF-8 for either CLI source and canonical JSON for programmatic input; 256 rules; 1\u2013256 UTF-16 code units per glob; 256 entries per sequence; answer depth 32. Inputs must be plain JSON data. Mock-enabled validation serves agent calls serially for deterministic FIFO sequence allocation; it is not a concurrency/load simulation, and the soft token gate may admit work differently than an unscripted concurrent dry run. Fixture values still flow into the script like real agent results, so author code can expose them via `log()` or its returned result\u2014never store credentials or production data in fixtures.\n\nExit codes: `0` valid \xB7 `1` parse/static failure \xB7 `2` dry-run failure \xB7 `3` usage error. The report also lists every checkpoint with the mock reply (`default ?? true`) and warnings for backend approval, phase mismatch, `headless: "abort"`, and agent-less scripts. `headless: "pause"` dry-runs cleanly. A saved nested workflow still needs `--workflows-dir`.\n\nProgrammatic: `validateWorkflowScript(script, { args, workflows, dryRun, cwd, tokenBudget, maxAgents, timeoutMs, mockAnswers })` from `@automatalabs/workflows` returns the same report. Invalid workflow scripts resolve to reports; invalid `mockAnswers` supplied from untyped JavaScript throws `TypeError` before parsing.\n\n## Harness config discovery \u2014 `agentprism-workflows config`\n\nValidate\'s sibling: the same no-prompt config probe, standalone \u2014 no script required. Run it BEFORE authoring to read each harness\'s advertised, negotiable session surface (model ids including bracket variants, effort levels, modes, boolean knobs) instead of guessing values or writing a throwaway probe workflow.\n\n```bash\nnpx @automatalabs/workflows config # every routable harness\nnpx @automatalabs/workflows config codex opencode # only the named harnesses\nnpx @automatalabs/workflows config claude --json # machine-readable report\n```\n\nHarness names are the routing names: built-in `claude` / `codex` / `opencode` / `pi` plus any custom backend registered via the `AGENTPRISM_BACKENDS` env var (registered customs also join the no-argument default set). Each harness opens one session without a prompt \u2014 zero tokens \u2014 and its catalog is read fresh; a harness that cannot spawn or authenticate reports `probed: false` with the reason and never blocks the others.\n\nThe no-argument built-in sequence comes from `BUILTIN_BACKEND_IDS`; authoring prose describes the\ncurrent registry rows and does not define a separate supported-backend list.\n\n| flag | meaning |\n|---|---|\n| `--cwd <dir>` | session cwd for the probes (default: the current directory \u2014 harnesses may resolve project-level config, and hence their catalog, from it) |\n| `--timeout-ms <n>` | per-harness probe bound (default 60000); a timed-out harness reports `probed:false` |\n| `--json` | machine-readable `HarnessConfigReport` on stdout (`harnessOptions` uses the same per-harness shape as validate\'s report) |\n\nExit codes: `0` all probed \xB7 `1` at least one probe failed \xB7 `3` usage error.\n\nProgrammatic: `probeHarnessConfig({ harnesses, backends, cwd, timeoutMs })` from `@automatalabs/workflows` returns the same report (`backends` merges over `AGENTPRISM_BACKENDS` exactly like `createAcpRunner`); `formatHarnessConfigReport(report)` renders the human table.\n\n## Workflow folders\n\nHosts that keep versioned folders of workflow scripts serve them by name (the SDK\'s\n`openWorkflowDir` \u2014 see `docs/api.md`). The filename stem is the name (`review-pr.workflow.js` \u21D2\n`review-pr`; `.workflow.js` beats `.js`). For script AUTHORS the takeaway is simply:\n`workflow("<name>")` works when the host serves a folder; keep names equal to filename stems.\n\n---\n\n# Complete example \u2014 quick-wins.workflow.js\n\nA complete, validated script (`loopUntilDry()` with per-round vendor rotation, dedup threading via a `seen` list, and an in-round budget floor; runs standalone or nested):\n\n```js\n// quick-wins \u2014 a small, self-contained hunter that repo-triage nests by name\n// (`workflow("quick-wins", {...})`) and that also runs standalone:\n//\n// npm start -- --workflow quick-wins\n// npx agentprism-workflows validate quick-wins --workflows-dir workflows\n//\n// Demonstrates loopUntilDry(): keep spawning hunt rounds \u2014 each on the next vendor\n// in the pool \u2014 until two consecutive rounds add nothing new (or the round cap /\n// token budget stops it first). Workflow scripts are self-contained strings with no\n// imports, so the vendor pool is repeated here rather than shared with repo-triage.\nexport const meta = {\n name: "quick-wins",\n description: "Hunt small, high-confidence quick wins across the repo until two consecutive rounds come up dry",\n phases: [{ title: "Hunt" }],\n};\n\n// args \u2014 every knob optional; hosts may hand args through as a JSON string.\nconst raw = typeof args === "string" ? (() => { try { return JSON.parse(args); } catch { return {}; } })() : args;\nconst opt = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};\nconst rounds = Number.isFinite(Number(opt.rounds)) && Number(opt.rounds) >= 1 ? Math.floor(Number(opt.rounds)) : 4;\nconst focus =\n typeof opt.focus === "string" && opt.focus.trim().length > 0\n ? opt.focus.trim()\n : "small, safe, high-confidence improvements";\nconst avoid = Array.isArray(opt.avoid) ? opt.avoid.filter((x) => typeof x === "string") : [];\n\n// These registered-prefix specs use ids verified against each live harness catalog.\nconst POOL = [\n { name: "claude", model: "claude/opus[1m]", mode: "plan" },\n { name: "codex", model: "codex/gpt-5.6-sol", mode: "read-only" },\n { name: "opencode", model: "opencode/zai/glm-5.2" },\n];\n\nconst WINS = {\n type: "object",\n additionalProperties: false,\n required: ["wins"],\n properties: {\n wins: {\n type: "array",\n items: {\n type: "object",\n additionalProperties: false,\n required: ["file", "summary", "action"],\n properties: {\n file: {\n type: "string",\n description: "Repo-relative path of a file you actually opened \u2014 copy it exactly, never invent one",\n },\n summary: { type: "string", description: "One sentence: the small problem or missed improvement" },\n action: { type: "string", description: "The concrete, low-risk change that fixes it, in one clause" },\n },\n },\n },\n },\n};\n\nphase("Hunt");\nconst seen = [];\nconst wins = await loopUntilDry({\n round: async (i) => {\n // Budget floor: leave headroom for whatever runs after this hunt. When nested\n // inside repo-triage, budget.* reads the PARENT run\'s shared budget.\n if (budget.total && budget.remaining() < 30_000) {\n log(`Hunt round ${i + 1}: stopping \u2014 only ${budget.remaining()} tokens left`);\n return [];\n }\n const v = POOL[i % POOL.length];\n const r = await agent(\n `Hunt round ${i + 1}: find up to 3 quick wins in this repository \u2014 ${focus}. ` +\n "A quick win is a small, safe, self-contained improvement (a missing guard, a stale doc line, an obvious dead branch), " +\n "not a refactor. Open files and ground every entry in code you actually read; never emit a placeholder.\\n" +\n `Already known \u2014 do NOT repeat anything on this list: ${JSON.stringify([...avoid, ...seen])}`,\n { label: `hunt:${i + 1}:${v.name}`, phase: "Hunt", schema: WINS, model: v.model, mode: v.mode },\n );\n const found = (r?.wins ?? []).filter((w) => typeof w.file === "string" && w.file.length > 0 && !w.file.startsWith("/"));\n seen.push(...found.map((w) => `${w.file}: ${w.summary}`));\n return found.map((w) => ({ ...w, foundBy: v.name }));\n },\n key: (w) => `${w.file}::${w.summary}`,\n consecutiveEmpty: 2,\n maxRounds: rounds,\n});\n\nlog(`quick-wins: ${wins.length} unique wins across the hunt`);\nreturn { wins };\n```\n';
|
|
33064
|
+
var AUTHORING_PROMPT_CONTENT = '# Writing AgentPrism workflow scripts\n\nA workflow script is plain JavaScript, passed around as a **string**, not a module. The engine runs it in a deterministic sandboxed realm. Each `agent()` call opens a session on an [Agent Client Protocol](https://agentclientprotocol.com) (ACP) backend \u2014 Claude Code, OpenAI Codex, OpenCode, pi, or a custom ACP agent server. The backend runs its own tool loop to completion and returns final text or a schema-validated object. One script can mix backends per call.\n\nThe **Workflow script reference** section at the end of this document holds the exhaustive option tables, routing grammar, and error codes.\n\n## The guide, by task\n\nEvery section of the guide is inlined below, after the core: Running workflows (the MCP server and `workflow` tool), backends and structured output, composition and failure, quality helpers and checkpoints, the execution environment, determinism and resume, and worked examples with validation.\n\n## The mental model\n\n- **The script is the orchestrator; agents are workers.** All control flow \u2014 loops, fan-out, dedup, aggregation, conditionals \u2014 lives in script code. Agents cannot spawn agents and cannot see each other. Give each agent one self-contained task.\n- **Each `agent()` call opens a fresh session with no memory.** Interpolate everything a later call needs into its prompt. (Sole exception: resume can continue the same usage/auth-interrupted occurrence \u2014 see Determinism and resume.)\n- **Agents are real coding agents, not chat completions.** They have file access, shells, and tools, rooted at the run\'s working directory. "Read the failing test and fix it" is a valid prompt; the agent will edit files.\n- **The DSL primitives are realm globals, not imports.** There is nothing to `import` \u2014 `agent`, `parallel`, `pipeline`, `gate`, `checkpoint`, `args`, `budget`, \u2026 are injected. Top-level `await` and a top-level `return` are valid. The script\'s return value becomes the run\'s `result`.\n- **Scripts are plain JavaScript, not TypeScript.** Type annotations fail to parse. The realm has no Node APIs (no `require`, `import`, `fs`, `fetch`, timers). All side effects happen through agents.\n- **Live observability needs no script annotations.** Journaling runs publish redacted progress and transcript upserts at `workflow://runs/{runId}/events`. Author labels for human correlation, not to enable this behavior.\n\n## Minimal script\n\n```js\nexport const meta = {\n name: "repo-summary",\n description: "Summarize what a repository does",\n};\n\nconst summary = await agent(\n `Read the README and the package manifests under ${args.path}, then ` +\n `summarize what this project does in five sentences.`,\n { label: "summarize" },\n);\nreturn { summary };\n```\n\nRun scripts through the MCP server\'s `workflow` tool \u2014 registration, the run/await/inspect/stop actions, and the `args`/`cwd` globals are covered in the **Running workflows** section below.\n\n## Pre-flight checklist\n\n- [ ] `export const meta = { name, description }` is the first statement, a pure literal.\n- [ ] No `Date.now()` / `Math.random()` / no-arg `new Date()` / `Date()`; no imports, no Node APIs. Timestamps and randomness come in through `args`.\n- [ ] Every `parallel` element is a **thunk**; results are `.filter(Boolean)`-ed or null-checked.\n- [ ] Every prompt is self-contained: prior results are interpolated in, and every file path a prompt references was written by an earlier call, supplied through `args`, or created by that prompt\'s own instructions.\n- [ ] Schemas: object root, `additionalProperties: false`, everything `required`, a `description` on every field.\n- [ ] Model ids, effort values, and `configOptions` come from `npx @automatalabs/workflows config` or a validator report, never from memory. `mode` only on calls with a pinned `model`.\n- [ ] Worktree-isolated agents return their work as data \u2014 their edits are discarded when the call ends.\n- [ ] Replay is intentional: completed calls with matching identity and input fingerprints replay. Change a hashed field (normally the prompt) when a completed call must run again.\n- [ ] Budget loops guard on `budget.total`; caps and drops are `log()`-ed, not silent.\n- [ ] `checkpoint()` guards irreversible actions, with a sane headless `default` or an intentional `headless: "pause"`.\n- [ ] `return` a compact, structured result \u2014 it is the run\'s `result`, not a transcript.\n- [ ] `npx @automatalabs/workflows validate <file> --args \'<json>\'` exits 0 with no surprising warnings.\n\nFor the complete `agent()` option table, model-routing grammar, checkpoint options, error codes, `meta.backends` config fields, and the MCP tool input shapes, see the **Workflow script reference** section below.\n\n\n## Running workflows \u2014 the MCP `workflow` tool\n\nAgents run workflows through the `workflow` tool served by `@automatalabs/mcp-server` (the server also registers a separate `repl` tool for interactive REPL orchestration, out of scope here). Register it once in the host\'s MCP configuration (project-scoped is typical):\n\n```json\n{ "mcpServers": { "agentprism-workflows": { "command": "npx", "args": ["-y", "@automatalabs/mcp-server@latest"] } } }\n```\n\nThe stdio command the host spawns is a thin **shim**. It proxies to a shared per-user **workflow daemon** (Streamable HTTP on loopback, auto-started on first use). Runs execute in the daemon, so they survive session end, host restarts, and tool timeouts; only daemon exit can interrupt in-flight work. Any later session can await, inspect, or stop a run. Runs, journals, and logs persist under `~/.agentprism/workflows/` per project namespace.\n\nEvery `run` call names its project with the required `projectDir` argument \u2014 an absolute path, normally the workspace root. One registration serves every project. `inspect`/`await`/`stop` take only a `runId`; the runId locates its project store automatically. Add `--in-process` to the args for the pre-daemon single-process behavior (`projectDir` is then optional), or register the daemon\'s HTTP endpoint directly in HTTP-capable hosts (`agentprism-workflow daemon url` prints snippets). The command resolves at spawn time, so a reconnect (`/mcp` in Claude Code) picks up the latest published version.\n\n### The `workflow` tool, by action\n\n- **Run** (default, no `action`): supply exactly one of `script` (the raw source string, no Markdown fences) or `scriptPath` (an absolute path on the server\'s filesystem), plus `projectDir`. A path is read once at admission and its content snapshotted; later edits affect only a new run. `args` arrives in the script as the `args` global; the run\'s base directory is the `cwd` global. Some hosts hand `args` through as a JSON **string** \u2014 tolerate both shapes (`typeof args === "string" ? JSON.parse(args) : args`). Foreground streams progress but is bound to the request and its timeout. Pass `background: true` for anything that may outlive one request; it acknowledges after durable admission with a `runId`.\n- **Await** (`{ action: "await", runId, waitMs }`): bounded collection for background runs. A timeout is progress, not failure \u2014 call again (`waitMs: 20000` is typical). At terminal status the response adds `outcome`: the authored result or pause context, plus `replayEligibility`, `resumeReport`, `fallbacks`, and `checkpointsTaken`.\n- **Inspect** (`{ action: "inspect", runId, lastN, labelGlob, logLines }`): a bounded snapshot \u2014 the latest matching calls with compact result previews plus the newest log lines. Use a narrow `labelGlob` to diagnose before deciding whether to resume, edit, or stop. Inspection never executes or resumes a script.\n- **Stop**: `{ action: "stop", runId }` durably aborts the whole run and returns its final snapshot; stopping a terminal run is a successful no-op. `{ action: "stop", runId, callIndex }` cancels exactly that in-flight agent: its slot settles to `null` with `AGENT_CANCELLED` and the run stays live. `labelGlob` only filters the returned snapshot; it never selects what to cancel.\n- **Resume**: a NEW run with `resumeFromRunId` plus the script content re-sent (the same `script` or `scriptPath`) and the desired `args` (+ `checkpointReplies` when answering a durable checkpoint). Read the returned `replayEligibility` for the predicted and observed replay prefix; never assume a prefix hit. Full semantics: **Determinism and resume**.\n\n### Operating rules\n\n- **Always retain the returned `runId`.** A paused, failed, or aborted response carries a redacted final-20 `logTail`. Read it before you change anything. Every admitted script is also an immutable resource at `workflow://runs/{runId}/script`, so a later session can recover a lost inline script.\n- **Two fingerprints control replay.** The identity hash covers the prompt, the resolved model, `mode` when set, non-empty sorted `configOptions`, `tier`, `phase`, `agentType`, the resolved agent definition, and the schema. The input fingerprint covers the resolved label, per-call `cwd` and isolation, `keepSession`, images, MCP servers, session/prompt metadata, and the approved script-backend digest.\n- **Operational bounds are not replay inputs.** Host `concurrency`, `agentRetries`, and `agentTimeoutMs`, plus per-call `timeoutMs` and `retries`, enter neither fingerprint. A resume does not inherit them from its source run; pass the values you want on every run. `agentTimeoutMs` caps the wall-clock time of each attempt; it is not an idle timer. A per-call `timeoutMs` can tighten that ceiling but cannot escape it. Each retry gets a fresh clock, so the envelope is `(resolved retries + 1) \xD7 resolved timeout`, with retries clamped to 3.\n- **Old journals stay usable.** Input formats below 2 replay positionally with `fallbackReason: "inputs-format-legacy"`. A current-format crash snapshot uses identity matching even without terminal-environment capture. Ancestor-scoped rows carried from \u22640.23 resume chains replay only while that ancestor run is still persisted. Journals resume across filesystem, environment, engine, Node, and V8 changes; `replayEligibility` reports those differences as diagnostics, never as gates.\n- **A background start returns immediately.** It sends no progress after it returns; collect progress with later bounded awaits. Background runs have no live checkpoint channel, so authored `headless` checkpoint modes apply. When a run\'s owner process dies, cold preflights reconcile stale `pending`/`running` state to `paused` with `pauseReason: "interrupted"`; a live owner is left alone.\n- A run paused with `reason: "auth_required"` resumes as a new run after you log in the backend\'s own CLI out of band.\n\n### Execution logs \u2014 the events resource\n\nEvery journaling run publishes an MCP resource at `workflow://runs/{runId}/events`. Subscribe to the canonical URI for advisory `resources/updated` hints, then read and paginate with `after`, `limit`, and `streamId`. Progress is coarse and redacted: `agentTranscript` rows are assistant/tool upserts partitioned by `(scope, callIndex, executionStartSeq)` and reduced by greatest revision per entry index. The durable cursor is authoritative when hints coalesce or a subscriber falls behind.\n\nEmbedding hosts can drive the same contract with `runDynamicWorkflow` / `WorkflowManager` from `@automatalabs/workflows`; the script contract is identical either way.\n\n## Choosing the agent for each call\n\nThe backend is selected **per `agent()` call** from its effective `model` string. One script can plan on one vendor\'s agent, implement on another\'s, and review on a third\'s, handing structured results between them.\n\nThe built-in names (`claude`, `codex`, `opencode`, `pi`) come from the runtime backend registry. Registered custom names extend that set.\n\n- **Omit `model` entirely** for maximum portability \u2014 the call runs on whatever default backend the host configured (`AGENTPRISM_DEFAULT_BACKEND`, or the host\'s session model). A script with no model specs anywhere runs unchanged on any backend.\n- **Route by one registered first segment.** Split on the first `/`; ASCII-case-insensitive `claude`, `codex`, `opencode`, `pi`, or a registered custom backend name selects that harness and is stripped exactly once. A custom registration wins on a built-in-name collision.\n- **Use a backend name alone** (`claude`, `codex`, `opencode`, `pi`, or a custom name) to preserve the harness\'s configured default model. No model config call is made.\n- **Everything else goes intact to the default backend.** `anthropic/\u2026`, `openai/\u2026`, bare `opus`, and bare `gpt-\u2026` are not routing aliases. When an id remains after routing, it is sent byte-for-byte: no catalog matching, case folding, bracket parsing, effort/Fast option driving, retry, or fallback. Harness rejection is an agent error.\n- **`tier`** (`"small" | "medium" | "big"`) is a coarse alternative resolved from the host\'s tier config \u2014 use it for "a cheap model" without naming a vendor.\n\nThe published examples use ids verified against live harness catalogs: `claude/opus[1m]`, `codex/gpt-5.6-sol`, and `opencode/zai/glm-5.2`. For Pi, `pi/openrouter/vendor/model-id` strips only `pi/`; Pi then splits provider `openrouter` from model id `vendor/model-id`. Prefer backend-only forms when the desired model is configured inside the harness.\n\nNever guess model ids, effort values, or option names from memory \u2014 read the live catalog first:\n\n```bash\nnpx @automatalabs/workflows config # every routable harness (claude, codex, opencode, pi + registered customs)\nnpx @automatalabs/workflows config codex --json # one harness, machine-readable\n```\n\nOne no-prompt session per harness, zero tokens: the table lists every negotiable session option \u2014 model ids (including bracket variants like `opus[1m]`), effort levels, modes \u2014 exactly as the installed harness advertises them. One caveat: the bare `config` probe reads each harness with its **default model** selected, and option domains are **model-specific**. An option can appear only after a particular model is selected. Ceilings differ per model. Provider-served variants of the same model can advertise different domains. The authoritative per-model probe is the validator run on your real script: it selects each authored `{ backend, model }` pair first and echoes that pair\'s advertised table. Confirm every pinned model against its own echoed table; do not read package internals to discover options.\n\n```js\nconst plan = await agent(PLAN_PROMPT, { label: "plan", model: "opencode/zai/glm-5.2", schema: PLAN });\nconst impl = await agent(implPrompt(plan), { label: "implement", model: "codex/gpt-5.6-sol" });\nconst review = await agent(reviewPrompt(impl), { label: "review", model: "claude/opus[1m]", schema: REVIEW });\n```\n\nUse `configOptions` only for exact ACP session options advertised by that routed harness. Read the per-harness advertised-options table first \u2014 `npx @automatalabs/workflows config <harness>`, or the same table in every validator report \u2014 before choosing ids or select values; catalogs vary by harness version, login, and machine.\n\n```js\nconst impl = await agent(implPrompt(plan), {\n label: "implement",\n model: "codex",\n configOptions: { "fast-mode": true, reasoning_effort: "high" },\n});\n```\n\nIds and string/boolean values pass through verbatim in ascending id order, after model selection and before the prompt. There are no aliases, coercion, client-side vocabulary, defaults, or cached catalogs. Copy option ids character-for-character from the catalog, punctuation included \u2014 `"fast-mode"`, not `fast_mode` \u2014 and quote ids that are not valid identifiers. Never put `"model"` in `configOptions`; use the dedicated `model` field. A harness rejection follows the ordinary agent-error path.\n\nPi\'s thought-level option is named `thinkingLevel`, and its choices depend on the exact model in the same call:\n\n```js\nconst review = await agent(REVIEW_PROMPT, {\n label: "pi-review",\n model: "pi/openrouter/vendor/model-id",\n configOptions: { thinkingLevel: "high" },\n});\n```\n\nValidation selects `openrouter/vendor/model-id` before reading Pi\'s choices. A listed value passes unchanged. A recognized value above an ordered model\'s ceiling, or in a model-specific gap, passes with a warning that names the effective clamp target. Pi advertises its SDK-derived domain directly. Claude and Codex are also ordered: when their options omit domain metadata, validation enumerates the advertised models and merges their per-model effort orders. A Claude model without an `effort` option does not support effort, and `default` never becomes a ceiling target. OpenCode and custom backends have no declared value order, so validation is exact-set. An unrecognized or unadvertised value fails with exit code `2`. Enumeration stops at 32 advertised models; a larger or inconsistently ordered catalog warns and falls back to exact advertised-value validation.\n\n**The harness is authoritative.** The client never substitutes a nearby model or silently falls back. A rejected id follows the existing agent-error path; a harness that accepts or ignores it determines the outcome. The public `fallbacks`/`onModelFallback` fields remain for compatibility but model resolution does not emit them.\n\n## Structured output\n\nPass `schema` \u2014 a **plain JSON Schema object literal** (no schema builders exist inside the realm) \u2014 and the call resolves to a **validated object** instead of text:\n\n```js\nconst FINDINGS = {\n type: "object",\n additionalProperties: false,\n required: ["findings"],\n properties: {\n findings: {\n type: "array",\n items: {\n type: "object",\n additionalProperties: false,\n required: ["file", "line", "summary"],\n properties: {\n file: { type: "string", description: "Repo-relative path \u2014 copy it exactly, never invent one" },\n line: { type: "number", description: "1-indexed line the finding anchors to" },\n summary: { type: "string", description: "One sentence stating the defect, grounded in code you actually read" },\n },\n },\n },\n },\n};\n\nconst report = await agent("Review the diff on this branch for correctness bugs.", {\n label: "review", schema: FINDINGS,\n});\nreport.findings.forEach((f) => log(`${f.file}:${f.line} ${f.summary}`));\n```\n\nThe same schema works on **every** backend; only the fulfillment channel differs, and the runner picks it for you: Claude uses its `outputFormat`, Codex its strict `outputSchema`, while Pi, OpenCode, and eligible custom ACP agents receive a client-hosted `StructuredOutput` MCP tool when they advertise HTTP MCP support. Pi accepts stdio, Streamable HTTP, and SSE MCP servers. If no valid tool capture exists, Pi retains the runner\'s common prompt-embedded schema and validated final-text JSON fallback. In every channel the runner validates the value client-side (with type coercion) and re-prompts a bounded number of times before failing the call with non-recoverable `SCHEMA_NONCOMPLIANCE`.\n\nSchema authoring rules that keep all channels healthy:\n\n- Root must be an object; set `additionalProperties: false` and list every property in `required`.\n- Put a `description` on every field \u2014 descriptions are the per-field prompt.\n- Keep schemas structurally simple. Exotic keywords (`oneOf`, `patternProperties`, unusual `format`s, backreference regexes) are normalized or stripped on the wire for some backends \u2014 validation still enforces them client-side, which shows up as re-prompt churn. Prefer `anyOf`, `enum`, and plain types.\n- Keep free-text fields small (tens of lines). An oversized structured output can exhaust schema repair and fail the call.\n- Validation checks structure, not truth. Check load-bearing values in script code (for example, reject findings whose `file` is not in a known file list) before spending more agents on them.\n\n## The `meta` header\n\nEvery script must **begin** with `export const meta = {...}` as a plain object literal (no computed values \u2014 it is parsed from the source text before anything runs):\n\n```js\nexport const meta = {\n name: "fix-flaky-tests", // required\n description: "Find flaky tests and fix them", // required\n phases: [ // optional; one { title, detail?, model? } entry\n { title: "Find", model: "opencode/zai/glm-5.2" }, // per phase() call, matched by exact title;\n { title: "Fix" }, // a phase model is that phase\'s default\n ],\n model: "claude/sonnet", // optional run-wide default model\n backends: { /* optional custom ACP agents \u2014 see "Custom ACP backends" */ },\n};\n```\n\nPer-agent model resolution order: explicit `agent({ model })` > `agent({ tier })` > the current phase\'s `model` > `meta.model` > the host session\'s default. So `meta.phases[].model` gives a whole phase a backend without repeating it on every call.\n\n## Fan-out: `parallel` and `pipeline`\n\n```js\n// parallel: an array of THUNKS (not promises!) run concurrently \u2014 a barrier that\n// resolves in input order. A failed slot resolves to null; filter before use.\nconst sweeps = (await parallel([\n () => agent("Audit error handling in src/server", { label: "sweep:errors", schema: FINDINGS }),\n () => agent("Audit input validation in src/api", { label: "sweep:input", schema: FINDINGS }),\n])).filter(Boolean);\n\n// pipeline: each item flows through the stages independently \u2014 NO barrier between\n// stages, so item A can be in stage 2 while item B is still in stage 1.\n// Stages receive (previousResult, originalItem, index).\nconst verified = (await pipeline(\n sweeps.flatMap((s) => s.findings),\n (f) => agent(`Adversarially verify this finding \u2014 try to refute it:\\n${JSON.stringify(f)}`,\n { label: `verify:${f.file}`, schema: VERDICT }),\n (verdict, f) => ({ ...f, real: verdict.real }),\n)).filter(Boolean).filter((f) => f.real);\n```\n\n**Default to `pipeline`** for multi-stage work. Add a `parallel` barrier only when the next stage needs *all* prior results at once: dedup across the full set, early-exit on a zero count, or prompts that compare "the other findings". The test is the **information dependency** \u2014 a barrier\'s cost is real, because the fastest worker idles for the slowest. All coordination lives in script code: agents cannot see each other, so never ask an agent to "check with the other reviewers" or "spawn helpers". Passing a promise instead of a thunk to `parallel` is a `TypeError` \u2014 wrap every call: `() => agent(...)`.\n\nFan-out also contends for the **working tree**, not just the concurrency limiter. Two agents running builds or test suites in the same checkout collide on build outputs, caches, and lockfiles, and concurrent `git fetch`es contend on the same `.git`. Give run-things agents `isolation: "worktree"` when the commits they must inspect are reachable from the run cwd\'s repository, or serialize them; fan out freely only the agents that just read.\n\nThe host caps concurrent agents per run (default 8); hand `parallel`/`pipeline` as many items as the task needs and let the limiter schedule them. The cap counts active agent attempts, not authored branches: queued branches begin as other attempts finish, and a branch that exhausts its timeout settles to `null` and frees its slot. `workflow(nameOrScript, args)` nests another workflow inline (one level deep, sharing this run\'s budget and limiter) \u2014 inline script strings always work; saved names resolve when the host serves a workflows folder (see the reference section below).\n\n## Failure semantics \u2014 design for `null`\n\n- A **recoverable** failure (timeout, empty output, transient execution error) is retried per the call\'s `retries` (default 0), then the call **resolves to `null`** \u2014 inside `parallel`/`pipeline` *and* as a bare `await agent(...)`. Null-check anything load-bearing, and set `retries: 1\u20132` on steps you can\'t afford to lose.\n- A host can settle one runaway in-flight call with MCP `{ action: "stop", runId, callIndex }` or SDK `manager.cancelAgentCall(runId, callIndex)`. The call resolves to `null` with `AGENT_CANCELLED`, skips every configured retry, and does not abort the run or its siblings. Its failed call record is not cached as a journal result, so a later resume runs that occurrence live.\n- A **non-recoverable** failure (schema never validated, script bug) throws and fails the run. You *may* `try/catch` around an `agent()` call to degrade gracefully \u2014 rethrow anything you can\'t meaningfully handle. In particular, **always rethrow pause-class errors** (`err.code === "PROVIDER_USAGE_LIMIT"` or `"AUTH_REQUIRED"`): they must propagate out of the script so the engine can pause the run resumably \u2014 swallowing one converts that pause into a fake, lossy completion.\n- A **provider quota wall, missing backend authentication, or opted-in durable checkpoint pauses a managed run instead of failing it** \u2014 the journal checkpoints and the host can resume after the budget refills, authentication completes, or a checkpoint decision is supplied. Direct `runner.run()` calls still receive the `AUTH_REQUIRED` error because they have no manager lifecycle.\n- Per-call knobs: `timeoutMs` and `retries`. A finite `timeoutMs` may shorten the host\'s run-level `agentTimeoutMs` ceiling; `null` or omission is uncapped only when the host supplied no ceiling. The timeout is total wall-clock time per attempt, and every retry gets a fresh clock.\n\n## Budgets and phases\n\n```js\nphase("Explore", { budget: 100_000 }); // soft per-phase token sub-budget\n// budget.total (null = unbounded) \xB7 budget.spent() \xB7 budget.remaining() (Infinity when unbounded)\n\nconst found = [];\nwhile (budget.total && budget.remaining() > 50_000 && found.length < 20) {\n const r = await agent("Find one more edge case not in: " + JSON.stringify(found.map((f) => f.name)),\n { label: `edge:${found.length}`, schema: EDGE });\n if (!r) break;\n found.push(r);\n}\n```\n\nGuard budget-driven loops on `budget.total` being set \u2014 with no budget, `remaining()` is `Infinity` and only your own counters stop the loop. The run-level token budget and agent-count cap are hard: once exhausted, further `agent()` calls throw. `phase()` also groups agents in progress UIs and run logs; `log(msg)` (and `console.log`) append to the run log \u2014 narrate what matters, especially anything you drop or cap.\n\n## Built-in quality loops\n\nThese helpers spawn their own subagents (on the default model \u2014 hand-roll with `parallel` + `agent` when you want panel members on specific backends). Full signatures in the reference section below.\n\n| helper | shape | use for |\n|---|---|---|\n| `gate(produce, validate, { attempts })` | produce \u2192 validate \u2192 feed `feedback` back; return `{ ok, value, verdict, attempts }` | produce-until-a-reviewer-approves loops that need the final review evidence |\n| `retry(thunk, { attempts, until })` | bounded retry until `until(result)` holds | flaky single steps |\n| `verify(item, { reviewers, threshold, lens })` | N adversarial reviewers vote `real`/not | killing plausible-but-wrong findings |\n| `judgePanel(attempts, { judges, rubric })` | score candidates 0\u20131 against a rubric, return the best | picking among independent solutions |\n| `loopUntilDry({ round, key, consecutiveEmpty, maxRounds })` | repeat a round, dedup by `key`, stop when dry | unknown-size discovery (bugs, edge cases) |\n| `completenessCheck(args, results)` | one critic lists what\'s still missing | a final "what did we not cover?" pass |\n\nThe `gate` pattern, spelled out \u2014 note how the producer thunk threads the validator\'s feedback into a *fresh* agent\'s prompt (sessions have no memory):\n\n```js\nconst outcome = await gate(\n (feedback, attempt) => agent(\n `Implement the fix described here:\\n${JSON.stringify(plan)}\\n` +\n (feedback ? `\\nA reviewer rejected attempt ${attempt}: ${feedback}\\nAddress every point.` : ""),\n { label: `fix:${attempt + 1}`, model: "codex/gpt-5.6-sol" },\n ),\n (result) => agent(\n `Run the test suite and review this change summary:\\n${result}\\n` +\n `Return ok=true only if tests pass and the fix is correct; include the reviewed commit SHA.`,\n { label: "gate-review", model: "claude/opus[1m]", schema: { type: "object", additionalProperties: false,\n required: ["ok"], properties: { ok: { type: "boolean" }, feedback: { type: "string" },\n commitSha: { type: "string" } } } },\n ),\n { attempts: 3 },\n);\nif (!outcome.ok) log(`reviewer never approved after ${outcome.attempts} attempts`);\nelse log(`reviewer approved commit ${outcome.verdict?.commitSha ?? "(unspecified)"}`);\n```\n\nFeedback is the producer\'s only context for the next attempt. Interpolate everything it needs, and name only files that provably exist.\n\n## Human gates: `checkpoint()`\n\n`checkpoint(promptText, options?)` is a zero-token, journaled human gate. With MCP elicitation (or a live SDK `confirm` callback) it waits for that reply; without a live channel, its default mode takes `default ?? true` immediately, so detached runs never hang.\n\n```js\nconst proceed = await checkpoint(`Apply this plan?\\n${JSON.stringify(plan, null, 2)}`, {\n kind: "confirm", // "confirm" | "input" | "select"\n default: false, // default headless mode takes this (or true)\n // headless: "abort", // abort when no live human is attached\n // headless: "pause", // or persist a resumable human-decision pause\n});\nif (!proceed) return { applied: false, plan };\n```\n\n`kind: "input"` resolves to free text, `kind: "select"` to one of `choices`. How the question reaches a human is the host\'s job (elicitation in the MCP server; `ExecOptions.confirm` in the SDK). With no live channel, `headless: "default"` (the default) takes `default ?? true`, `"abort"` aborts, and `"pause"` returns a managed run with `reason: "checkpoint_required"` plus non-secret `checkpointContext`. Resume the last mode with `checkpointReplies: { [context.callIndex]: decision }` or a live confirm. For `resumeFromRunId`, that key is the source context index; an unambiguous identity match may journal the injected answer at a shifted current index. Put a checkpoint before anything hard to reverse \u2014 applying diffs, pushing, publishing, or the first commit into a working copy the workflow did not create (`default: true` keeps detached runs moving).\n\n## Working directory, isolation, confinement\n\n- Every agent session runs in the run\'s base `cwd` unless the call narrows it: `agent({ cwd: "packages/api" })` (relative resolves against the base).\n- `isolation: "worktree"` runs the agent in a **throwaway git worktree** (`<repoRoot>/.agentprism/worktrees/\u2026`) so parallel agents can edit without colliding. The worktree and its branch are **always deleted when the call ends \u2014 an isolated agent\'s file edits are discarded**. Have isolated agents *return their work as data* (a unified diff, a file map, a report) and apply it in a later non-isolated step; use worktrees for experiments, builds, and verification, not for persistent edits. Outside a git repo, isolation degrades to the shared tree with a logged notice.\n- `resume: { filesystem: "read-only" }` is a deprecated compatibility annotation. It is not a runner mode and has no effect on replay; completed calls replay by journal correspondence whether they read or write. Use `mode`, tool policy, prompts, and worktrees when you actually need confinement.\n- `mode` requests an agent-advertised ACP session mode and is **strict** \u2014 an unsupported mode fails the call rather than running unconfined. Mode ids are backend-specific and drift with harness versions: read the advertised `mode` select from `npx @automatalabs/workflows config <harness>` or a validator report (Codex-family examples: `read-only`, `agent`; Claude-family advertises permission modes such as `plan` and `acceptEdits`; OpenCode via its mode option; Pi advertises thinking-level config rather than modes). Only set `mode` on calls whose `model` you also pin. Use read-only/plan modes for reviewers and auditors that must not write.\n- `agentType: "<name>"` binds a reusable subagent definition \u2014 a Markdown file at `<cwd>/.agentprism/agents/<name>.md` (project) or `~/.agentprism/agents/<name>.md` (user; project wins) whose frontmatter sets tool allow/deny lists, a model, and isolation, and whose body is the role prompt. An unknown name logs a warning and degrades to defaults.\n\n## Where a mutating workflow runs\n\nThe run\'s base `cwd` is the USER\'S checkout \u2014 the working copy they launched the host from. Treat it as borrowed: committing onto whatever branch is checked out, switching branches, or resetting it are defects unless the user asked for exactly that. A script that commits should verify its target workspace in a preflight step, or create its own workspace idempotently, and refuse on a mismatch rather than adapt. `isolation: "worktree"` is NOT such a workspace \u2014 it is per-call and throwaway. Note also that a throwaway worktree branches from the run cwd\'s repository: an isolated agent sees another agent\'s commits only when they are reachable there.\n\n## Wiring tools and inputs into a call\n\n- `mcpServers: [{ name, command, args: [], env: [] }]` attaches MCP servers to that agent\'s session \u2014 the portable way to hand any backend a capability (image generation, a browser, a ticket system). The agent sees the server\'s tools natively. Note `env` is a list of `{ name, value }` pairs (ACP shape), not an object map; HTTP/SSE servers use `{ type: "http", name, url, headers: [] }`.\n- `images: [...]` appends base64 image blocks to the prompt (backends without image support receive a bracketed text note instead).\n- `meta` / `promptMeta` pass generic ACP `_meta` through to `session/new` / `session/prompt` \u2014 the escape hatch for driving a custom agent\'s extension surface.\n- `keepSession: true` keeps a successful agent\'s ACP session re-openable after the run: the re-attach record (sessionId, backend, effective pool identity, cwd, reopen capabilities) lands in `WorkflowRunResult.agentSessions`, and the HOST can continue that conversation later via `runner.loadSession()`. Usage/auth pause failures are kept open automatically so managed resume can continue the interrupted occurrence. Scripts themselves never request reattach.\n\n### Custom ACP backends\n\nAny process that speaks ACP over stdio can serve `agent()` calls \u2014 an in-house browser-QA agent, an image generator, a domain-specific executor. Two ways in:\n\n1. **Host-registered** (preferred): the embedder passes `createAcpRunner({ backends: { browser: { command: "/abs/browser-acp" } } })`; the script just routes with `model: "browser"`.\n2. **Script-declared**: the script itself declares the backend in `meta.backends` \u2014 but declarations are **inert until the host approves them** (an elicitation in the MCP server; `allowScriptBackends` in the SDK), because they spawn commands on the host machine. Don\'t rely on them silently working.\n\n```js\nexport const meta = {\n name: "checkout-qa",\n description: "Implement, then QA the checkout flow in a real browser",\n backends: {\n browser: { command: "browser-acp", args: ["--headless"] }, // requires host approval\n },\n};\n\nconst change = await agent("Implement the coupon-code field per the spec in docs/coupon.md.",\n { label: "implement" }); // default backend\nconst verdict = await agent(\n `Open the app, walk through checkout with coupon SAVE20, and verify the discount line. Change summary:\\n${change}`,\n { label: "qa", model: "browser", // the custom agent\n schema: { type: "object", additionalProperties: false, required: ["passed"],\n properties: { passed: { type: "boolean" }, notes: { type: "string" } } } },\n);\nreturn { change, qa: verdict };\n```\n\nStructured output works on custom backends through the same injected-tool/fallback ladder as OpenCode \u2014 no special-casing in the script.\n\n## Determinism and resume\n\nRuns are journaled: every `agent()` and `checkpoint()` result is recorded under a deterministic call index. A new run may reuse eligible results from a terminal source run. Uncertainty always means live execution.\n\n> **Resume rule:** replay is content-addressed and fail-to-live on correspondence: a completed call replays when its identity and input fingerprint match uniquely. Filesystem or world state never gates replay.\n\n- Direct `Date.now()`, `Math.random()`, and no-arg `new Date()` / `Date()` calls fail static validation. The realm also blocks aliased or computed forms at runtime; `new Date(isoString)` is fine. Pass timestamps and random seeds through `args`.\n- The replay identity of an `agent()` call hashes: the prompt, the resolved `model`, `mode` when set, `configOptions` when non-empty (sorted keys), `tier`, `phase`, `agentType`, the resolved agent definition, and `schema`. The resolved agent definition includes its tool allowlist and denylist, model, isolation, and body prompt \u2014 editing a definition invalidates the calls that use it.\n- A separate input fingerprint hashes: the resolved label, per-call `cwd`, resolved isolation, `keepSession`, `images`, `mcpServers`, `meta`, `promptMeta`, and the approved script-backend digest.\n- Host `agentTimeoutMs`, `agentRetries`, and `concurrency`, plus per-call `timeoutMs` and `retries`, are operational bounds. They enter neither hash and may change freely on resume. A new run resolves them from its own request; it does not inherit the source values.\n- `args` is not hashed directly. New args that only raise a loop cap leave earlier identities unchanged, so those calls can replay. New args that change a prompt, model selection, phase, schema, call order, or runner-visible input make the affected calls run live. Unchanged independent calls may still replay.\n- Matching tries a unique exact `(kind, call path, identity hash)` row first (`"path-hash"`), then a unique `(kind, identity hash, input fingerprint)` row, so an unchanged call can replay as `"unique-hash"` after insertions or deletions. Source and current input fingerprints must be equal. Duplicate identities, duplicate content, consumed candidates, missing facts, and empty schema-less results run live. The engine never guesses by source order or occurrence.\n- Source admission requires: exact `cwd`, compatible call-path/input/checkpoint fingerprint formats, complete call/journal/allocation metadata, and a valid manifest and seed. Git HEAD and dirty digest, `environmentKey`, captured environment values, Node/V8, and producing engine version are diagnostics only. Environment differences may appear in `replayEligibility.provenanceChanges`; they never gate admission or matching.\n- A completed writer replays exactly like a reader. A live call, nested workflow, host checkpoint callback, or degraded worktree does not clear unrelated candidates. Nested child calls run live \u2014 they are outside the parent\'s journal \u2014 while matching root calls around them still replay. The engine does not reproduce file writes; a later live agent navigates the world it finds.\n- Replay preserves budget-driven control flow: a cached call adds its source logical debit to `budget.spent()`/`remaining()`, and zero current provider usage. Replayed session records keep their backend and session identity, rebound to the current call index, label, and phase.\n- A root call interrupted by `PROVIDER_USAGE_LIMIT` or `AUTH_REQUIRED` can continue its recorded session on either resume API. Continuation requires: the exact call index, identity hash, complete input fingerprint, non-worktree isolation, identical existing cwd, a coherent recorded session, and the runner\'s current backend/`poolKey`/reopen gates. A successful continuation finishes the unfinished turn and charges only its usage delta. Every failed gate runs fresh, and `fallbacks` records the reopen method or the exact skip reason. No script option controls this.\n- Completed checkpoint results replay when the identity and the `default`/`headless`/`timeoutMs` fingerprint match \u2014 headless results included. `checkpointReplies` keys always name the checkpoint index in the source run. A moved reply can follow intact prior correspondence; after a live divergence it must reach the exact recorded call site, so a different same-text branch cannot consume it.\n- `resumePolicy: "positional"` is a migration escape hatch for index/prefix matching. It cannot bypass format, metadata, manifest, cwd, or input checks. Marker-less, manual, and same-ID legacy journals keep historical hash-only positional behavior. Input formats below 2 use the `inputs-format-legacy` positional bridge and are rewritten under the current format on the next hop. A current-format crash snapshot with a valid identity manifest uses identity matching even without terminal-environment capture.\n- `label`, `cwd`, `mcpServers`, `images`, `meta`, `promptMeta`, and `keepSession` are not identity-hashed: changing one does not invalidate an ordinary replay. They are in the input fingerprint: changing one rejects continuation of an interrupted turn, and that occurrence runs fresh. To force a completed call to run again, change a hashed field \u2014 normally the prompt.\n- Keep call order deterministic. Derive iteration from `args` and prior agent results, never from ambient state.\n\nEvery `resumeFromRunId` result has a bounded `replayEligibility` summary. Background admission, foreground completion, both await shapes, and inspect expose the same fields: strategy, predicted replayable-prefix length, observed replayed prefix and counts, and the first non-replay when known. Active correspondence reasons include `strategy-live`, `positional-miss`, `positional-suffix`, `not-recorded`, `path-missing`, `inputs-missing`, `inputs-changed`, `ambiguous-identity`, `ambiguous-content`, `candidate-consumed`, `empty-output`, `worktree-degraded`, `seed-persistence-error`, and `resume-fatal-latch`. Older reason literals stay exported only so historical journals parse. Engine and input-format versions and environment provenance ride along as diagnostics.\n\nAn all-live outcome means correspondence could not be established \u2014 not that the world changed. Missing resume metadata, incompatible format literals, or an invalid manifest or seed disable new-format replay. If any source row lacks a captured path or input fact (possible past the raw-frame cap, or with a non-strict-JSON `meta` value), the whole source is `"manifest-invalid"`: dropping the row could make an ambiguous sibling look unique.\n\n### Worked resume \u2014 raise a loop cap\n\nThe following workflow (shipped as `examples/resume-loop-cap.workflow.js`) requires eight reviews but lets the caller cap how many are attempted in one run:\n\n```js\nexport const meta = {\n name: "resume-loop-cap",\n description: "Run expensive review rounds up to an args-controlled cap",\n phases: [{ title: "Review" }],\n};\n\nconst input = args && typeof args === "object" && !Array.isArray(args) ? args : {};\nconst numericCap = Number(input.maxRounds);\nconst maxRounds = Number.isInteger(numericCap) && numericCap > 0 ? numericCap : 8;\n\nphase("Review");\nconst rounds = [];\nfor (let i = 0; i < maxRounds; i += 1) {\n rounds.push(\n await agent(\n `Review round ${i + 1}: inspect the repository and report unresolved release blockers.`,\n { label: `review:${i + 1}`, phase: "Review" },\n ),\n );\n}\n\nif (maxRounds < 8) throw new Error(`review cap ${maxRounds} reached before 8 rounds`);\nreturn { rounds };\n```\n\nRun it with `args: { "maxRounds": 6 }`. Then send the same content (via `script`, or the absolute `scriptPath` you edit) with `args: { "maxRounds": 8 }` and the first result\'s `runId` as `resumeFromRunId`. Rounds 1\u20136 replay for zero current provider tokens; only rounds 7\u20138 run live, because the cap controls call count but is not interpolated into the round prompt. If every round prompt included `maxRounds`, all eight identities would change and all would run live. Resume always states its content; a bare `resumeFromRunId` never silently reuses the old script.\n\nGive repeated calls stable, descriptive labels and narrate decisions with `log()` \u2014 inspection by `labelGlob` then turns a pause or failure into a diagnosis instead of a guess.\n\n### Kill, patch, resume\n\nStop the live run with `{ action: "stop", runId }`. The returned `aborted` snapshot is the durable acknowledgement: resume is safe immediately, and a further await adds nothing. Edit the file. Start a new run with its absolute `scriptPath` and `resumeFromRunId`. Every completed call whose recorded identity and input fingerprint correspond replays, regardless of filesystem or environment drift. Read `replayEligibility` and the full `resumeReport` for the per-call decisions. A repeated stop of a terminal run is a successful no-op.\n\nRegistration, the per-action contracts, background collection, and the events resource are covered in the **Running workflows** section above. Resume a durable checkpoint pause by re-sending the script with `resumeFromRunId` and `checkpointReplies` keyed by the source run\'s `checkpointContext.callIndex`.\n\n## Worked example \u2014 cross-vendor build with every major primitive\n\n```js\nexport const meta = {\n name: "feature-build",\n description: "Plan, gate on approval, implement, cross-vendor review, fix until green",\n phases: [{ title: "Plan" }, { title: "Implement" }, { title: "Review" }],\n};\n\nconst PLAN = { type: "object", additionalProperties: false, required: ["steps", "risks"],\n properties: {\n steps: { type: "array", items: { type: "string", description: "One concrete implementation step" } },\n risks: { type: "array", items: { type: "string" } } } };\nconst VERDICT = { type: "object", additionalProperties: false, required: ["ok"],\n properties: { ok: { type: "boolean" },\n feedback: { type: "string", description: "Required when ok=false: concretely what to change" } } };\n\nphase("Plan");\nconst plan = await agent(\n `Study this repo, then write an implementation plan for: ${args.feature}. Keep steps concrete.`,\n { label: "plan", model: "opencode/zai/glm-5.2", schema: PLAN },\n);\n\nconst approved = await checkpoint(\n `Implement "${args.feature}" with this plan?\\n- ${plan.steps.join("\\n- ")}\\nRisks: ${plan.risks.join("; ")}`,\n { kind: "confirm", default: true },\n);\nif (!approved) return { implemented: false, plan };\n\nphase("Implement");\nconst outcome = await gate(\n (feedback, attempt) => agent(\n `Implement: ${args.feature}\\nPlan:\\n- ${plan.steps.join("\\n- ")}\\n` +\n `Run the project\'s tests before finishing and report results.` +\n (feedback ? `\\n\\nReviewer feedback on attempt ${attempt}:\\n${feedback}\\nAddress every point.` : ""),\n { label: `implement:${attempt + 1}`, model: "codex/gpt-5.6-sol", retries: 1 },\n ),\n async (report) => {\n if (!report) return { ok: false, feedback: "implementation agent produced no result" };\n phase("Review");\n const reviews = (await parallel([ // two reviewers on different vendors\n () => agent(`Review the working-tree diff for correctness. Implementer\'s report:\\n${report}`,\n { label: "review:correctness", model: "claude/opus[1m]", schema: VERDICT }),\n () => agent(`Review the working-tree diff for regressions and missing tests. Report:\\n${report}`,\n { label: "review:coverage", model: "opencode/zai/glm-5.2", schema: VERDICT }),\n ])).filter(Boolean);\n const rejections = reviews.filter((r) => !r.ok);\n return rejections.length\n ? { ok: false, feedback: rejections.map((r) => r.feedback).join("\\n"), reviews }\n : { ok: true, reviews };\n },\n { attempts: 3 },\n);\n\nreturn { implemented: outcome.ok, attempts: outcome.attempts, reviewVerdict: outcome.verdict, plan };\n```\n\n(The planner would ideally run read-only, but mode ids are backend-specific \u2014 this call routes to OpenCode, so it leaves `mode` unset rather than guessing; a Claude-routed planner could safely say `mode: "plan"`.)\n\n## Worked example \u2014 fully backend-agnostic audit\n\nNo `model` anywhere: this script runs unchanged on whatever backend the host defaults to.\n\n```js\nexport const meta = {\n name: "edge-case-audit",\n description: "Exhaustively hunt edge-case bugs in a target dir, verify each, report gaps",\n phases: [{ title: "Hunt" }, { title: "Verify" }],\n};\n\nconst BUGS = { type: "object", additionalProperties: false, required: ["bugs"],\n properties: { bugs: { type: "array", items: { type: "object", additionalProperties: false,\n required: ["file", "scenario"], properties: {\n file: { type: "string", description: "Repo-relative path you actually opened" },\n scenario: { type: "string", description: "Concrete input/state \u2192 wrong behavior" } } } } } };\n\nphase("Hunt");\nconst seen = []; // what earlier rounds reported, threaded into each new prompt\nconst candidates = await loopUntilDry({\n round: async (i) => {\n const r = await agent(\n `Round ${i + 1}: find edge-case bugs in ${args.target} not already in this list:\\n` +\n JSON.stringify(seen) + `\\nOnly report what you can ground in code you read.`,\n { label: `hunt:${i + 1}`, schema: BUGS },\n );\n const bugs = r ? r.bugs : [];\n seen.push(...bugs);\n return bugs; // loopUntilDry dedups these by `key` across rounds\n },\n key: (b) => `${b.file}:${b.scenario}`,\n consecutiveEmpty: 2,\n maxRounds: 8,\n});\n\nphase("Verify");\nconst confirmed = (await pipeline(\n candidates,\n (bug) => verify(bug, { reviewers: 3, threshold: 0.66, lens: ["correctness", "reproducibility"] }),\n (v, bug) => (v.real ? bug : null),\n)).filter(Boolean);\n\nconst gaps = await completenessCheck(args, confirmed);\nlog(`${confirmed.length}/${candidates.length} confirmed; complete=${gaps.complete}`);\nreturn { confirmed, missing: gaps.missing ?? [] };\n```\n\n## Full-scale example scripts\n\nWhen the inline examples above aren\'t enough, study the complete, validated scripts that ship with the published authoring skill:\n\n- [`repo-triage.workflow.js`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/repo-triage.workflow.js) \u2014 an autonomous cross-vendor repo triage and the broadest support-API tour: `pipeline` with no inter-stage barrier, a cross-vendor verification panel, `gate()` where writer and reviewer are different vendors, nesting a saved workflow by name, `completenessCheck()`, budget headroom reservation, string-form `args` hardening, path guards on schema outputs, and pause-class error rethrow.\n- `quick-wins.workflow.js` (included in full at the end of this document) \u2014 a small hunter that runs standalone *or* nested: `loopUntilDry()` with per-round vendor rotation, dedup threading via a `seen` list, and an in-round budget floor (nested runs share the parent\'s budget).\n- [`resume-loop-cap.workflow.js`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/resume-loop-cap.workflow.js) \u2014 content-addressed replay: run with a low `maxRounds`, resume with a higher one; unchanged rounds replay for zero tokens (worked through in Determinism and resume).\n\n[`examples/README.md`](https://github.com/agentprism/agentprism-workflows/blob/main/skills/agentprism-workflow-authoring/examples/README.md) maps each script to what it teaches.\n\n## Validate before you run\n\nThe SDK ships a validator that costs **zero tokens** \u2014 always run it on a script you just wrote or edited:\n\n```bash\nnpx @automatalabs/workflows validate my-workflow.js --args \'{"target":"src/"}\'\n```\n\nIt does three passes. First, a **static parse**: the `meta` literal, syntax, and direct\nnondeterministic call expressions. Second, a **dry run**: the engine runs the script\'s control flow\nin its realm, with every `agent()` call served by a mock backend that fabricates\nschema-conforming results \u2014 no real agent runs, and validation is not an execution of the\nworkflow. Third, one no-prompt session for each distinct routed `{ backend, model\n}` pair. The third pass spends no tokens, selects each authored call model, and echoes that pair\'s\nmodel-specific config-options table in the report. Read that table before picking `configOptions`\nvalues; unknown ids, bad select values, wrong value types, and the reserved `"model"` key fail\nvalidation with the call label, authored value, and alternatives. If a routed pair cannot spawn,\nauthenticate, select its model, or open a session, validation emits one warning, marks it\n`probed:false`, skips only that pair\'s checks, and stays valid \u2014 the offline degradation behavior. A\nmock live confirm answers checkpoints with `default ?? true`, so `headless: "pause"` dry-runs\ncleanly; `headless: "abort"` warns because a truly unattended run would abort. Script-declared\n`meta.backends` are treated as approved. The report lists every call with its backend attribution,\nplus warnings for undeclared phases, `headless: "abort"` checkpoints, and zero agent calls.\n(Option-domain clamping rules are in Backends and structured output; the full flag table and\nmock-answer grammar are in `reference.md`.)\n\nThe default fabricator returns `true` for every boolean. Do not accept that all-true path as proof that a convergence loop works: script its control labels with `--mock-answers` or a reusable `--mock-answers-file`. Use a finite `$sequence` such as reject-then-approve so validation executes the revision branch and proves the loop stops; the report identifies every consumed and unused fixture without printing answer bodies.\n\nSave reusable mock answers beside the workflow file (`<name>.mock.json`). When a default-fabrication dry run leaves declared phases unexecuted, your guard branches fired \u2014 script the mocks that reach past them instead of shrugging at the warnings.\n\nExit codes: `0` valid \xB7 `1` parse failure \xB7 `2` dry-run or config-option failure. The full flag table, mock-answers grammar, and limits are in `reference.md`.\n\nThe third pass\'s table is also available standalone \u2014 before any script exists \u2014 as validate\'s sibling command: `npx @automatalabs/workflows config [harness ...]` (default: every routable harness; `--json`; exit `1` when a probe fails). Use `config` while authoring to pick values; validate\'s copy then confirms the script you wrote against the same live catalog.\n\nIf the script nests saved workflows by name (`workflow("review-pr")`), pass the folder so names resolve \u2014 and the positional itself may then be a name: `npx @automatalabs/workflows validate review-pr --workflows-dir ./workflows`. A green dry run proves structure, not judgment \u2014 prompts and schemas still deserve review.\n\n---\n\n# Workflow script reference\n\nExhaustive tables for the AgentPrism workflow script DSL. The guide above covers authoring; this section is the lookup companion. Everything here is verified against `@automatalabs/workflow-engine` / `@automatalabs/acp-agents` as shipped with `@automatalabs/workflows`.\n\n## `agent(prompt, options?)` \u2014 full option table\n\nReturns the agent\'s final assistant text, or the schema-validated object when `schema` is set. Resolves to `null` when a *recoverable* failure survives all retries.\n\n| option | type | meaning |\n|---|---|---|\n| `label` | `string` | Display/telemetry name; also stamped on every live ACP event for this call. Always set it. Not part of the resume hash. |\n| `phase` | `string` | Assign this call to a phase explicitly (needed inside concurrent stages where the global `phase()` state would race). |\n| `schema` | JSON Schema object | Structured output. Plain object literal only \u2014 no schema builders exist in the realm. Part of the resume hash. |\n| `model` | `string` | Model spec: optional registered harness prefix plus a verbatim id, or a backend-only name. See [Model specs & routing](#model-specs--routing). Part of the resume hash. |\n| `tier` | `"small" \\| "medium" \\| "big"` | Coarse tier resolved from host config; beats phase/meta model, loses to explicit `model`. Part of the resume hash. |\n| `mode` | `string` | ACP session mode id advertised by the selected backend. **Strict**: unsupported/unadvertised ids fail the call (never silently unconfined). Ids are backend-specific and drift with harness versions \u2014 read the advertised `mode` select from the config probe or a validator report (Codex-family examples: `read-only`, `agent`, `agent-full-access`; Claude-family advertises permission modes such as `plan`, `acceptEdits`, and `dontAsk`). Part of the resume hash when set. |\n| `configOptions` | `Record<string, string \\| boolean>` | Exact ACP session option ids and authored values. Applied in ascending id order after model and before the prompt, with no aliases or coercion. `"model"` is reserved for the dedicated `model` field. Part of the resume hash only when non-empty, with sorted keys. Read the advertised-options table first (`agentprism-workflows config <harness>`, or any validate report) before choosing values. |\n| `agentType` | `string` | Bind a named subagent definition (tools allow/deny, model, isolation, role prompt). See [agentType definitions](#agenttype-definitions). Part of the resume hash. |\n| `isolation` | `"worktree"` | Run in a throwaway git worktree branched from the run cwd. **Always removed (worktree + branch) when the call ends** \u2014 edits are discarded; return work as data. Degrades to the shared tree outside a git repo (logged). |\n| `resume` | `{ filesystem: "read-only" }` | Deprecated compatibility annotation. It is recorded as legacy diagnostic provenance, is not sent to the runner or hashed, and has no effect on replay. New scripts should omit it. |\n| `cwd` | `string` | Per-session working directory; relative resolves against the run\'s base cwd. Overridden by worktree isolation. Not hashed. |\n| `timeoutMs` | `number \\| null` | Total wall-clock cap for each attempt. A finite value may tighten a finite host `agentTimeoutMs` ceiling but cannot raise or disable it. With no host ceiling, a finite value applies and `null`/omitted is uncapped. |\n| `retries` | `number` | Retries after *recoverable* failures (default 0, host-overridable). Exhausted retries \u21D2 the call resolves `null`. |\n| `mcpServers` | `McpServerConfig[]` | MCP servers attached to this session. Stdio shape: `{ name, command, args: [], env: [{ name, value }] }` (`args`/`env` required, `env` is name/value pairs, not a map); `{ type: "http" \\| "sse", name, url, headers: [] }` also accepted. Not hashed. |\n| `images` | `PromptImage[]` | Base64 image blocks appended to the prompt; backends without image support get a bracketed text note. Not hashed. |\n| `meta` | `object` | ACP `_meta` merged into `session/new` \u2014 session-scoped extension passthrough (pairs with custom backends). Not hashed. |\n| `promptMeta` | `object` | ACP `_meta` merged into `session/prompt` \u2014 turn-scoped passthrough. Backend-computed keys win on conflict. Not hashed. |\n| `keepSession` | `boolean` | Skip release-time best-effort `session/close`; the non-secret re-attach record lands in `WorkflowRunResult.agentSessions` for host-side `loadSession()` / `resumeSession()`. Usage/auth pause failures are kept open automatically for managed continuation. Not identity-hashed; included in the input fingerprint. |\n\nThe timeout clock measures the whole attempt, including backend startup, model/config setup, tool\nwork, and streamed output; it is not an idle timer. Each retry starts a fresh clock, so the maximum\ntimeout envelope is `(retries + 1) \xD7 resolved timeoutMs` (retries are clamped to 3). An exhausted\ntimeout is recoverable `AGENT_TIMEOUT`: the call resolves to `null`, releases its concurrency slot,\nand asks the ACP session to cancel. A session that keeps running after the cancellation grace is\nclosed where supported and its pooled child is recycled.\n\nEvery new run, including one admitted with `resumeFromRunId`, resolves host limits from that run\'s\nrequest. It does not inherit `agentTimeoutMs`, retries, concurrency, agent-count, or token-budget\nvalues from its source, so pass every operational bound the resumed execution should use.\n\n## Model specs & routing\n\nA `model` string is resolved solely from its first segment, then delegated to the harness:\n\n| spec shape | routes to | notes |\n|---|---|---|\n| *(omitted)* | host default backend | `AGENTPRISM_DEFAULT_BACKEND` (`claude` \\| `codex` \\| `opencode` \\| `pi` \\| custom name; default `claude`), session default model. Most portable. |\n| `claude`, `codex`, `opencode`, `pi`, or `<custom-name>` | that registered harness | Backend-only: no model config call; the harness default remains active. |\n| `claude/<id>`, `codex/<id>`, `opencode/<id>`, `pi/<id>`, or `<custom-name>/<id>` | that registered harness | Match the first segment ASCII-case-insensitively and strip exactly one segment. Custom names take priority on collision. The remaining `<id>` is sent verbatim, including further `/` characters. For Pi, that remainder is its `<provider>/<model-id>` and Pi preserves any further slashes in the model id. |\n| any other string, including `anthropic/\u2026`, `openai/\u2026`, bare `opus`, or bare `gpt-\u2026` | host default backend | The **entire** authored string is sent verbatim; these are not routing aliases. |\n\nSelection is a single `session/set_config_option` with `configId: "model"` and the exact remaining string. There is no catalog matching, case folding, normalization, bracket parsing, nearest-neighbor selection, sibling effort/Fast option driving, retry, or echo verification. Brackets, dots, and provider-style prefixes are ordinary model-id characters.\n\nWhatever the harness returns is the outcome. A rejection follows the existing agent-error path with no resolution-specific code or model fallback event. `onModelFallback` and `WorkflowRunResult.fallbacks` remain public compatibility surfaces; model resolution does not emit entries, while pause recovery emits `kind: "continuation"` reattach/skip notices.\n\n## Structured output channels\n\nOne author API (`schema`), four fulfillment paths \u2014 chosen automatically per backend:\n\n| backend | channel |\n|---|---|\n| Claude | native `outputFormat`, schema normalized to Anthropic\'s structured-outputs subset (e.g. `oneOf` \u2192 `anyOf`; unsupported keywords/formats stripped on the wire) |\n| Codex | native strict `outputSchema` (OpenAI strict subset normalization) |\n| Pi | a client-hosted `StructuredOutput` MCP tool injected when the agent advertises HTTP MCP support; common prompt-embedded schema and validated final-text JSON fallback |\n| OpenCode / custom ACP | a client-hosted **`StructuredOutput` MCP tool** injected into the session when the agent advertises HTTP MCP support (an agent may show it as `structured_output_StructuredOutput`); otherwise prompt-embedded schema + JSON parse of the final message. Custom backends can opt out of tool injection with `structuredOutputTool: false`. |\n\nPi accepts stdio, Streamable HTTP, and SSE MCP servers; ACP-transport MCP hosting remains client-side.\n\nIn every channel the runner coerces + validates client-side and re-prompts a bounded number of times; the final miss fails the call with non-recoverable `SCHEMA_NONCOMPLIANCE`. Constraints stripped from the wire are still enforced client-side \u2014 an exotic schema keyword shows up as re-prompt churn, so keep schemas simple.\n\n## DSL globals \u2014 complete signatures\n\n```\nagent(prompt, options?) \u2192 Promise<string | object | null>\nparallel(thunks) \u2192 Promise<results[]> // barrier; input order; failed slot = null\npipeline(items, ...stages) \u2192 Promise<results[]> // no inter-stage barrier; stage(prev, original, index); failed item = null\nworkflow(nameOrScript, args?) \u2192 Promise<unknown> // one nesting level; names resolve from the host\'s workflows folder, inline scripts always work\ngate(thunk, validator, { attempts = 3 }) \u2192 { ok, value, verdict, attempts }\n // thunk(feedback, attempt); validator(result) \u2192 { ok, feedback?, ... } | boolean | null (may be async / an agent call)\nretry(thunk, { attempts = 3, until? }) \u2192 last result // thunk(attempt); stops early when until(result)\nverify(item, { reviewers = 2, threshold = 0.5, lens? })\n \u2192 { real, realCount, total, votes: [{ real?, reason? }] }\n // N adversarial reviewers prompted to REFUTE; lens (string | string[]) rotates focus per reviewer\njudgePanel(attempts, { judges = 3, rubric = "overall quality and correctness" })\n \u2192 { index, attempt, score, judgments } // mean 0\u20131 score per candidate; stable tie-break by index\nloopUntilDry({ round, key = JSON.stringify, consecutiveEmpty = 2, maxRounds = 50 })\n \u2192 unique items[] // round(i) returns items; stops after N dry rounds; budget exhaustion returns the partial result\ncompletenessCheck(taskArgs, results) \u2192 { complete, missing?: string[] }\ncheckpoint(promptText, options?) \u2192 Promise<reply> // journaled human gate; zero tokens\nphase(title, { budget? }) \u2192 void // soft per-phase token sub-budget\nlog(message) \u2192 void // console.log/info/warn/error route here too\nargs // the host-provided input value, verbatim\ncwd // the run\'s base working directory (string); process.cwd() returns it too\nbudget.total | budget.spent() | budget.remaining()\n```\n\nFor `gate()`, `value` is the final producer result and `verdict` is the exact last completed\nvalidator return, including any extra structured fields. `{ ok: true }` and bare `true` pass;\n`{ ok: false, feedback? }`, bare `false`, and `null` reject. Only object feedback is threaded into\nthe next producer attempt. A producer result of `null` is still passed to the validator. Producer\nor validator exceptions propagate immediately, so no partial gate result is returned and no later\nattempt runs. An explicit unsupported `undefined` validator return is a rejection represented as\n`verdict: null`. If the script returns the gate result, its complete verdict is persisted and may\nreach the host; keep evidence concise and never put credentials or other secrets in verdict data.\n\n`verify`, `judgePanel`, and `completenessCheck` spawn their subagents on the run\'s default model \u2014 hand-roll with `parallel` + `agent` to pin panel members to specific backends.\n\n## `checkpoint()` options\n\n| option | type | meaning |\n|---|---|---|\n| `kind` | `"confirm" \\| "input" \\| "select"` | Reply shape: boolean-ish / free text / one of `choices`. Affects the journal hash and the host UI widget. |\n| `choices` | `string[]` | For `kind: "select"`. |\n| `default` | `unknown` | Reply taken in the default headless mode \u2014 journaled like a real reply. Defaults to `true`. |\n| `headless` | `"default" \\| "abort" \\| "pause"` | No live channel: `"default"` takes `default ?? true`, `"abort"` aborts, and `"pause"` creates a persisted `checkpoint_required` pause. Default `"default"`. |\n| `timeoutMs` | `number` | Deadline for the interactive prompt. |\n\nThe host supplies the live human channel (elicitation in the MCP server; `ExecOptions.confirm` in the SDK), and that channel wins even when `headless: "pause"` is declared. A durable pause carries non-secret `checkpointContext`; resume with `ExecOptions.checkpointReplies: { [context.callIndex]: decision }` or attach a live channel. On a new `resumeFromRunId` execution, reply keys always name indexes in the **source** recording; identity matching may inject that decision at a shifted current index. Completed host and headless checkpoint results both replay when identity and the checkpoint-options fingerprint over `default`, `headless`, and `timeoutMs` match. A changed option or ambiguous match runs fresh. Detached runs never pause for a checkpoint unless the author opts into `"pause"`.\n\n## Error codes (`WorkflowError.code`)\n\n| code | recoverable | engine behavior |\n|---|---|---|\n| `AGENT_TIMEOUT` | yes | Total wall-clock attempt cap exhausted. Every retry gets a fresh clock; after the final attempt the call resolves `null`, and ACP cancel escalates to close/recycle when the turn does not stop. |\n| `AGENT_CANCELLED` | yes | The host selected this in-flight call for cancellation. It resolves `null` immediately through an engine race, skips retries, leaves the run live, and is recorded as a failed call rather than a replayable journal result. |\n| `AGENT_EMPTY_OUTPUT` | yes | No assistant text on a schema-less call; same retry-then-`null`. |\n| `AGENT_EXECUTION_ERROR` | yes* | Generic agent failure (*refusal/truncation variants are non-recoverable). |\n| `SCHEMA_NONCOMPLIANCE` | no | Structured output never validated after the re-prompt ladder. Halts the run (catchable in-script). |\n| `PROVIDER_USAGE_LIMIT` | no | Quota/rate wall \u2014 the run **pauses** (journaled, resumable), with the provider\'s reset hint. |\n| `TOKEN_BUDGET_EXHAUSTED` | no | Run (or phase) token cap hit; further `agent()` calls throw. |\n| `AGENT_LIMIT_EXCEEDED` | no | `maxAgents` cap hit. |\n| `AUTH_REQUIRED` | no | Backend needs authentication. `WorkflowManager` returns a resumable pause with `reason: "auth_required"` and redacted `authContext`; a direct runner throws. The host completes auth before resuming/retrying. |\n| `CHECKPOINT_REQUIRED` | no | `headless: "pause"` reached without a live channel. `WorkflowManager` returns `reason: "checkpoint_required"` plus non-secret `checkpointContext`; resume with `checkpointReplies` or live confirm. |\n| `SCRIPT_VALIDATION_ERROR` | no | Script failed parse/validation (bad meta, nondeterministic API, bad `meta.backends` shape). |\n| `SCRIPT_ERROR` | no | The script itself crashed (uncaught throw, floated rejection). |\n| `WORKFLOW_ABORTED` | \u2014 | Real cancellation (pause/stop/host signal) \u2014 never used for crashes. |\n\n`loopUntilDry` absorbs `TOKEN_BUDGET_EXHAUSTED` / `AGENT_LIMIT_EXCEEDED` from its rounds and returns the partial result; everywhere else those propagate.\n\n## Determinism & the resume journal\n\n> **Resume rule:** replay is content-addressed and fail-to-live on correspondence: a completed call replays when its identity and input fingerprint match uniquely. Filesystem or world state never gates replay.\n\nThe guide section **Determinism and resume** carries the full semantics: what each hash contains, matching, admission, continuation of interrupted calls, and checkpoint replay. Wire-level specifics for lookup:\n\n- Each `agent()` result is journaled under a monotonic call index and a SHA-256 identity hash. The canonical identity fields, in order, are `prompt`, resolved `model`, `mode` only when set, `configOptions` only when non-empty, `tier`, `phase`, `agentType`, resolved `agentDef`, and `schema`. Config-option keys are sorted before serialization. Missing fields other than `mode` and `configOptions` serialize as `null`; an unset `mode` and an unset/empty `configOptions` key are omitted for compatibility with older journals.\n- `agentDef` is the resolved definition\'s tools, disallowed tools, model, isolation, and body prompt. Changing a named definition therefore invalidates its call even when the `agentType` name is unchanged.\n- The legacy `resume: { filesystem: "read-only" }` annotation has no effect on admission or matching. Writers, readers, worktree calls, and unannotated calls follow the same journal rule.\n- `resumePolicy: "positional"` requests index/prefix correspondence but cannot bypass new-format format, metadata, manifest, cwd, or input checks. Marker-less journals and permanently marked manual/same-run legacy resumes retain historical hash-only positional behavior. Sources below input format 2 use `inputs-format-legacy`. Ancestor-scoped rows carried by a \u22640.23 resume hop replay only while that ancestor is still persisted; engine-minted nested scopes and deleted ancestor scopes stay live.\n- There is no `require`, `import`, Node API, or network API in the realm. `Date.now()`, `Math.random()`, and no-arg `new Date()` / `Date()` fail static validation; aliased or computed forms are blocked at runtime; `new Date(value)` works.\n\nEvery new-run resume exposes `replayEligibility` on admission, polling, inspection, and the terminal result. It reports strategy, predicted/observed replayable prefix and counts, first non-replay/reason/detail, engine/input-format diagnostics, non-gating runtime/environment `provenanceChanges`, and non-gating operational changes; `resumeReport` retains the complete terminal per-call correspondence.\n\nAn all-live outcome is expected when correspondence cannot be established, not when the world changed. Missing resume metadata, incompatible format literals, or an invalid manifest/seed can disable reuse. A new-format source containing any result row without a captured call path/input fact\u2014possible with a call stack deeper than the raw-frame cap or a non-strict-JSON `meta` value\u2014is source-wide `"manifest-invalid"`; excluding the row could make an ambiguous sibling look unique. Format-1 bytes are never reinterpreted; they enter the positional bridge and replayed rows are recorded under format 2.\n\nAn args-controlled cap is the useful case: a cap that changes how many calls are reachable, but\ndoes not appear in an earlier call\'s prompt, lets those calls replay on resume. The worked example\nlives in the determinism-and-resume guide document and ships as\n`examples/resume-loop-cap.workflow.js`. This changed-args pattern is specific to new-run entry\npoints that accept current args with `resumeFromRunId`. The MCP `workflow` tool does, as does\n`WorkflowManager.runSync(script, newArgs, { resumeFromRunId })`. MCP resume always requires\nexplicit content; a bare `resumeFromRunId` is invalid. `WorkflowManager.resume(runId)` is a\ndifferent same-ID recovery API: it reloads the persisted original script/args and permanently uses\nlegacy positional replay semantics, while the independent default-on channel may still continue an\neligible usage/auth-interrupted live call.\n\n## <a name="custom-backends-metabackends"></a>Custom backends \u2014 `meta.backends`\n\n```js\nexport const meta = {\n name: "\u2026", description: "\u2026",\n backends: {\n browser: {\n command: "browser-acp", // required: executable (absolute or on PATH)\n args: ["--headless"], // default []\n env: { BROWSER_PROFILE: "qa" }, // merged OVER the child\'s inherited env \u2014 per-backend secrets go here\n sessionMeta: { viewport: "desktop" }, // static ACP _meta on every session/new (per-call `meta` merges over it)\n structuredOutputTool: true, // default true; false = keep this backend on the prompt/_meta schema fallback\n },\n },\n};\n```\n\nScript-declared backends are **trust-gated**: they spawn commands on the host machine, so they stay inert until the composition root approves them \u2014 elicitation approval in the MCP server, `allowScriptBackends: true` (or a per-backend callback) on `runDynamicWorkflow`, `ExecOptions.scriptBackends` on a manager, or `AGENTPRISM_ALLOW_SCRIPT_BACKENDS=1`. A *declined* backend aborts the run rather than silently rerouting its calls to the default backend. Host-registered names always win over script declarations. Prefer host registration (`createAcpRunner({ backends })` / `AGENTPRISM_BACKENDS` env JSON) when you control the host.\n\n## <a name="agenttype-definitions"></a>`agentType` definitions\n\nMarkdown files at `<runCwd>/.agentprism/agents/<name>.md` (project) and `~/.agentprism/agents/<name>.md` (user); project wins on name collision. Frontmatter + body:\n\n```markdown\n---\ndescription: Read-only security auditor\ntools: [read, grep, glob] # allowlist of tool names (omit = all)\ndisallowedTools: [bash] # denylist, applied after the allowlist\nmodel: claude/opus[1m] # verified id; agent({ model }) overrides it\nisolation: worktree # optional\n---\nYou are a security auditor. Report findings; never modify files.\n```\n\nThe body is prepended to the agent\'s task as role guidance. An unknown `agentType` logs a warning and runs with default tools/model (the name degrades to a prose hint).\n\n## How hosts run scripts (what authors can assume)\n\nThe MCP route (`npx @automatalabs/mcp-server`, tool name `workflow`) is the canonical way an agent\nruns an authored script; registration and the per-action contracts are in the Running workflows\nguide section. The `workflow` tool is the server\'s whole *workflow* surface: run/resume/inspect/await/stop\nare action branches, not separate tools, and this input does not resolve a saved workflow name.\n(The server also registers a second, separate model-facing tool, `repl`, for interactive REPL\norchestration \u2014 outside this authoring guide\'s scope.) A\nrun that pauses with `reason: "auth_required"` resumes via a new run after the backend\'s own CLI is\nlogged in out-of-band (see below). Prompt-capable MCP hosts (e.g. Claude Code, where it surfaces as\na slash command) also get this entire guide from the server itself as the **`author-workflow`**\nprompt, with an optional `task` argument.\n\nEnvironment knobs shared by the MCP server and the SDK: `AGENTPRISM_DEFAULT_BACKEND`,\n`AGENTPRISM_ACP_POOL_SIZE` (schema-run parallelism on OpenCode/custom backends scales with the\npool; one injected-tool registry per process), `AGENTPRISM_BACKENDS`,\n`AGENTPRISM_ALLOW_SCRIPT_BACKENDS`, `AGENTPRISM_PERSISTENCE_ROOT`, plus per-backend spawn\noverrides. Pi uses `AGENTPRISM_PI_ACP_CMD` with optional `AGENTPRISM_PI_ACP_ARGS`; otherwise the\ninstalled exact-pinned package bin is used before the `npx -y @automatalabs/pi-acp` fallback.\n\nEmbedding hosts drive the same contract directly through the SDK \u2014 `runDynamicWorkflow` /\n`WorkflowManager` from `@automatalabs/workflows`, with `exec` limits (`tokenBudget`, `maxAgents`,\n`concurrency`, `agentTimeoutMs`, `agentRetries`), a live `confirm` checkpoint channel, and\n`exec.resumeFromRunId` for edited-script resume. See `docs/api.md` in the repository. The shapes\nbelow are the `workflow` tool\'s MCP surface, which is what script authors interact with.\n\nExact MCP tool input/output types:\n\n```ts\ninterface WorkflowExecuteToolInputBase {\n action?: "run";\n args?: unknown;\n maxAgents?: number;\n concurrency?: number;\n agentRetries?: number;\n agentTimeoutMs?: number | null;\n tokenBudget?: number | null;\n resumeFromRunId?: string;\n resumePolicy?: "auto" | "positional";\n checkpointReplies?: Record<number, unknown>;\n background?: boolean; // default false\n}\n\ntype WorkflowExecuteToolInput = WorkflowExecuteToolInputBase & (\n | { script: string; scriptPath?: never }\n | { script?: never; scriptPath: string } // absolute path on the server\n);\n// WorkflowExecuteToolInputBase also carries projectDir?: string \u2014 the absolute project\n// directory selecting the project-scoped run store and default execution cwd. REQUIRED for\n// run on the shared workflow daemon (one registration serves every project); optional on a\n// single-project (--in-process) server. inspect/await/stop never take it: a runId locates\n// its project store automatically.\n\ninterface WorkflowAwaitToolInput {\n action: "await";\n runId: string;\n waitMs?: number; // default 20_000; integer 0..25_000\n lastN?: number; // default 20; integer 1..50\n labelGlob?: string; // same whole-label glob as inspect\n logLines?: number; // default 20; integer 0..50\n}\n\ninterface WorkflowBackgroundAccepted {\n runId: string;\n status: "running";\n scriptSource: "inline" | "path";\n scriptUri: string;\n limits: WorkflowRunLimits;\n replayEligibility?: WorkflowReplayEligibility;\n}\n\ninterface WorkflowAwaitMetadata {\n requestedMs: number;\n elapsedMs: number;\n returnedBecause: "terminal" | "timeout" | "immediate";\n}\n\ninterface WorkflowRunAwaitResult<T = unknown> extends WorkflowRunStatus {\n wait: WorkflowAwaitMetadata;\n tokenUsage?: TokenUsage;\n outcome?: Omit<WorkflowExecutionToolResult<T>, "scriptSource">; // exactly when terminal\n scriptUri: string;\n lineage: Array<{ runId: string; uri: string; available: boolean }>;\n}\n\ninterface WorkflowStopToolInput {\n action: "stop";\n runId: string;\n callIndex?: number; // omitted = whole-run abort; present = cancel one in-flight agent\n lastN?: number;\n labelGlob?: string;\n logLines?: number;\n script?: never;\n scriptPath?: never;\n waitMs?: never;\n}\n```\n\nThe selected stop form requires a live, uniquely addressable agent attempt. Settled/unallocated\nindexes, checkpoints, duplicate scoped indexes, and terminal runs are errors that enumerate the\ncurrently in-flight call-index/label pairs. A successful selected cancellation returns the ordinary\nlive `WorkflowRunStatus`; whole-run stop returns the terminal `WorkflowStopResult`.\n\n`WorkflowRunResult.fallbacks?: WorkflowRunFallback[]` retains the compatibility shape\n`{ callIndex, label, phase?, requestedSpec, resolvedModel?, backendId?, kind, message, continuation? }`.\n`kind` is `model | modifier | continuation`; continuation details report either a reattached\n`resume | load` method or an exact skip reason. The model-resolution pipeline itself produces no entries.\n`WorkflowRunResult.checkpointsTaken?: WorkflowCheckpointTaken[]` records resolved checkpoints as\n`{ callIndex, kind, decision, source }`, where source is `live`, `headless-default`,\n`journal-replay`, or `injected`. A paused checkpoint is not resolved. Both fields are persisted and\nappear in foreground results plus terminal await `outcome`; neither appears on `WorkflowRunStatus`.\n\nAt most four background runs may be active or starting per server instance. Foreground, inspect,\nawait, and stop consume no slot; a durably stopped background run frees its slot immediately even\nwhile backend session wind-down remains. A timeout returns the freshest status and partial cumulative usage; replay\nhits cost/add zero. Terminal results have no MCP TTL and are reconstructed after restart while the\nproject run record remains readable. The inherited status fields stay redacted/bounded at 24,576\nstructured bytes and 8,192 text bytes. The full script lineage is never truncated; when lineage\nalone exceeds the status budget, `truncation.maxStructuredBytes` reports the larger actual envelope\nlimit. Terminal `outcome` preserves the raw authored result/full logs and has no new total cap, but\nit is never copied into text. It includes `scriptUri` but not the unpersisted admission-only\n`scriptSource`.\n\nThe background start has no enduring request signal, progress channel, or live checkpoint channel.\nIt returns immediately and emits no progress after returning, even if the initiating request\nsupplied a progress token. A later bounded `action:"await"` is a separate request; when that await\ncarries a progress token, it can stream coarse phase and distinct started/ended-call progress while\npending. The legacy/inconsistent-log polling fallback emits no progress notifications. A headless\ncheckpoint default continues; abort fails with `WORKFLOW_ABORTED`; pause returns\n`checkpoint_required` plus `outcome.checkpointContext`. Auth pauses return non-secret\n`outcome.authContext`; log the backend CLI in before resume. Background execution lives in the\nserving process (the daemon, or the single process under `--in-process`): that process\'s death can\ninterrupt an in-flight call, and stale durable `pending`/`running` state reconciles under its lease\nto `paused` / `interrupted`.\n\nEvery resumed background run durably seeds its inherited prefix (including a manager-owned\ncheckpoint injection) beneath its new run ID before acknowledgement, so later resume hops remain\nself-contained. The MCP layer never rewrites that seed. Await and inspect never execute or resume\nthe script; their cold preflight may only reconcile a dead owner\'s stale `pending`/`running` state\nto `paused` / `interrupted`.\n\nEvery admitted script is an immutable persistence-backed MCP resource at\n`workflow://runs/{runId}/script`. Run results link the new script; inspect/await link the full\nresume lineage oldest-to-newest as structured `{ runId, uri, available }` entries. Listing and\ncompletion include only the 50 newest runs, but a direct URI read works for any retained project\nrun. A path is never persisted or implicitly re-read, and the MCP layer retains no scripts, args,\nor synthetic lineage metadata in process memory.\n\n`action:"stop"` durably aborts a `running` or `paused` run live in the serving process: it cancels\nany pending agent/checkpoint request, appends `stopped`, releases the lease, and returns the final\ninspection projection with `stopped:true`. Only backend session wind-down can remain, observable\nthrough inspect\'s agent states. A repeated stop on a terminal run succeeds with `stopped:false,\nalreadyTerminal:true`. An in-flight stop may lack a quiescent terminal-environment proof, so the\nmanager can conservatively run the following resume live; inspect `replayEligibility` and\n`resumeReport` rather than assuming a prefix replay.\n\nRetain the run ID and inspect halted runs before guessing. The exact inspection input is:\n\n```ts\ninterface WorkflowInspectToolInput {\n action: "inspect";\n runId: string; // /^[a-z0-9]+-[a-z0-9]+$/, at most 128 characters\n lastN?: number; // default 20; integer 1..50\n labelGlob?: string; // non-empty; at most 128 Unicode code points\n logLines?: number; // default 20; integer 0..50\n script?: never;\n scriptPath?: never;\n}\n```\n\n`labelGlob` matches the whole raw agent label case-sensitively: `*` is zero or more Unicode code\npoints, `?` is exactly one, and backslash escapes the next character (a trailing backslash is\nliteral). Checkpoints and unknown legacy calls are excluded when a glob is present. Filtering\nhappens before `lastN`; selected calls return in ascending call-index order.\n\n```ts\ninterface WorkflowLogTail {\n lines: string[];\n totalLines: number;\n omittedLines: number;\n truncatedLines: number;\n redactedLines: number;\n}\n\ninterface WorkflowRunCallStatus {\n index: number;\n kind: "agent" | "checkpoint" | "unknown";\n label?: string;\n phase?: string;\n model?: string;\n backendId?: string;\n timeoutMs?: number | null;\n errorCode?: string;\n resultPreview: string;\n resultRedacted: boolean;\n resultTruncated: boolean;\n}\n\ninterface WorkflowRunStatus {\n runId: string;\n status: "pending" | "running" | "paused" | "completed" | "failed" | "aborted";\n workflowName: string;\n phases: string[];\n currentPhase?: string;\n reason?: string;\n errorCode?: string;\n limits?: WorkflowRunLimits; // absent only on legacy persisted records\n replayEligibility?: WorkflowReplayEligibility;\n logTail: WorkflowLogTail;\n calls: WorkflowRunCallStatus[];\n filter: { lastN: number; logLines: number; labelGlob?: string };\n truncation: {\n maxStructuredBytes: number;\n byteCapApplied: boolean;\n phases: { total: number; returned: number; shortened: number };\n logs: { total: number; returned: number; shortened: number; redacted: number };\n calls: {\n total: number;\n matched: number;\n returned: number;\n shortenedResults: number;\n redactedResults: number;\n };\n };\n}\n\ninterface WorkflowRunLimits {\n maxAgents: number;\n tokenBudget: number | null;\n concurrency: number;\n agentRetries: number;\n agentTimeoutMs: number | null;\n}\n```\n\nInspection returns only this allowlisted projection: never raw script, args, prompts, histories,\nhashes, session IDs, cwd, checkpoint/auth details, or raw results. Credential-shaped data is\nredacted, results are structurally compacted, every outward text scalar/preview is capped at 512\nUTF-8 bytes, inherited status JSON at 24,576 bytes, and inspection text at 8,192 bytes. Full lineage\ncan raise the structured envelope limit as reported by `truncation.maxStructuredBytes`. An unknown ID is\na tool error with no structured content; reading an existing failed run succeeds and reports\n`status:"failed"`. Every paused, failed, or aborted execution result also carries a redacted\nfinal-20 `logTail` (present when empty) and renders it in the immediate terminal text. Completed\nexecution results omit that extra field while retaining their full `logs` array.\n\nBackend auth comes from the machine the host runs on: Claude via a logged-in Claude Code install or `ANTHROPIC_API_KEY`; Codex via `~/.codex/auth.json`; OpenCode via `opencode auth login` (its CLI must be installed \u2014 it is not bundled); Pi via one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`, or ambient credentials in `~/.pi/agent/auth.json`. A script only needs auth for the backends it actually routes to.\n\n## The validator \u2014 `agentprism-workflows validate`\n\n```bash\nnpx @automatalabs/workflows validate <workflow-file> [options]\n```\n\nZero tokens; three passes \u2014 static parse, mocked dry run, then one no-prompt config probe per\nrouted `{ backend, model }` pair \u2014 described in the guide\'s Validate before you run section. The\ntables and grammar below are the exhaustive contract.\n\n| flag | meaning |\n|---|---|\n| `--args <json>` / `--args-file <path>` | the script\'s `args` global for the dry run |\n| `--mock-answers <json>` | label-glob answers for dry-run calls; mutually exclusive with the file form |\n| `--mock-answers-file <path>` | read the same JSON object from a UTF-8 file resolved against the process cwd |\n| `--workflows-dir <dir>` | repeatable; a folder of workflow scripts (name = filename stem). Lets the positional be a NAME and resolves nested `workflow("<name>")` calls |\n| `--parse-only` | static parse only |\n| `--cwd <dir>` | dry-run base cwd (default: throwaway temp dir, so `isolation: "worktree"` no-ops; a real repo cwd creates and cleans up real worktrees) |\n| `--token-budget <n>` | sets `budget.total`; the mock reports 1000 tokens per agent call |\n| `--max-agents <n>` | cap on dry-run agent calls |\n| `--timeout-ms <n>` | dry-run wall-clock limit (default 30000) |\n| `--json` | machine-readable `ValidateWorkflowReport` on stdout |\n\nInline false-branch fixture (exact shell form):\n\n```bash\nagentprism-workflows validate flow.workflow.js \\\n --mock-answers \'{"refute:*":{"real":false}}\'\n```\n\nEquivalent reusable file with a reject-then-approve sequence:\n\n```json\n{\n "refute:*": { "real": false },\n "quality:review": {\n "$sequence": [\n { "ok": false, "feedback": "exercise the revision path" },\n { "ok": true }\n ]\n }\n}\n```\n\n```bash\nagentprism-workflows validate flow.workflow.js --mock-answers-file mock-answers.json\n```\n\nRules match the final resolved label case-sensitively across the whole string. `*` matches zero or more characters (including `:` and `/`), `?` one character, and `\\` escapes the next character; empty globs and trailing escapes are invalid. Object order is captured once and the **last matching rule wins**, so put `"*"` before narrower exceptions. Raw canonical array-index keys (`"0"` or a non-zero, no-leading-zero decimal through `"4294967294"`) are reserved because ECMAScript reorders them. To match numeric label `10`, use JSON key `"\\\\10"`; `"01"` and `"4294967295"` are ordinary keys.\n\nA single answer is reusable. `{ "$sequence": [...] }` is finite and only the winning rule consumes it; a raw array is one array result, and a sequence element is ordinary answer data even when it contains `$sequence`. Exhaustion fails instead of repeating the last item or falling back. The machine report uses zero-based `sequenceIndex`; human lines render one-based `[position/length]`. Earlier matching rules count the match even when shadowed, and `dryRun.mockAnswers.unused` distinguishes `no-match`, `shadowed`, and partially consumed `not-reached` items. Unused fixtures warn but do not fail validation.\n\nFor schema calls, each answer deep-merges over a **fresh** fabricated base: JSON objects merge recursively; arrays, `null`, falsy primitives, and other scalars replace. The merged value is TypeBox-checked without coercion. Any answer-caused violation fails non-recoverably with `SCHEMA_NONCOMPLIANCE`; a failure already present at the identical untouched path/message in the simple fabricated base may be accepted with a grouped inherited-fabrication warning. A valid override can repair such a base limitation. Schema-less answers must be nonblank strings. Fixture failure messages, attribution, and warnings contain only labels, globs, positions, paths, and counts\u2014not answer values.\n\nLimits: 256 KiB raw UTF-8 for either CLI source and canonical JSON for programmatic input; 256 rules; 1\u2013256 UTF-16 code units per glob; 256 entries per sequence; answer depth 32. Inputs must be plain JSON data. Mock-enabled validation serves agent calls serially for deterministic FIFO sequence allocation; it is not a concurrency/load simulation, and the soft token gate may admit work differently than an unscripted concurrent dry run. Fixture values still flow into the script like real agent results, so author code can expose them via `log()` or its returned result\u2014never store credentials or production data in fixtures.\n\nExit codes: `0` valid \xB7 `1` parse/static failure \xB7 `2` dry-run failure \xB7 `3` usage error. The report also lists every checkpoint with the mock reply (`default ?? true`) and warnings for backend approval, phase mismatch, `headless: "abort"`, and agent-less scripts. `headless: "pause"` dry-runs cleanly. A saved nested workflow still needs `--workflows-dir`.\n\nProgrammatic: `validateWorkflowScript(script, { args, workflows, dryRun, cwd, tokenBudget, maxAgents, timeoutMs, mockAnswers })` from `@automatalabs/workflows` returns the same report. Invalid workflow scripts resolve to reports; invalid `mockAnswers` supplied from untyped JavaScript throws `TypeError` before parsing.\n\n## Harness config discovery \u2014 `agentprism-workflows config`\n\nValidate\'s sibling: the same no-prompt config probe, standalone \u2014 no script required. Run it BEFORE authoring to read each harness\'s advertised, negotiable session surface (model ids including bracket variants, effort levels, modes, boolean knobs) instead of guessing values or writing a throwaway probe workflow.\n\n```bash\nnpx @automatalabs/workflows config # every routable harness\nnpx @automatalabs/workflows config codex opencode # only the named harnesses\nnpx @automatalabs/workflows config claude --json # machine-readable report\n```\n\nHarness names are the routing names: built-in `claude` / `codex` / `opencode` / `pi` plus any custom backend registered via the `AGENTPRISM_BACKENDS` env var (registered customs also join the no-argument default set). Each harness opens one session without a prompt \u2014 zero tokens \u2014 and its catalog is read fresh; a harness that cannot spawn or authenticate reports `probed: false` with the reason and never blocks the others.\n\nThe no-argument built-in sequence comes from `BUILTIN_BACKEND_IDS`; authoring prose describes the\ncurrent registry rows and does not define a separate supported-backend list.\n\n| flag | meaning |\n|---|---|\n| `--cwd <dir>` | session cwd for the probes (default: the current directory \u2014 harnesses may resolve project-level config, and hence their catalog, from it) |\n| `--timeout-ms <n>` | per-harness probe bound (default 60000); a timed-out harness reports `probed:false` |\n| `--json` | machine-readable `HarnessConfigReport` on stdout (`harnessOptions` uses the same per-harness shape as validate\'s report) |\n\nExit codes: `0` all probed \xB7 `1` at least one probe failed \xB7 `3` usage error.\n\nProgrammatic: `probeHarnessConfig({ harnesses, backends, cwd, timeoutMs })` from `@automatalabs/workflows` returns the same report (`backends` merges over `AGENTPRISM_BACKENDS` exactly like `createAcpRunner`); `formatHarnessConfigReport(report)` renders the human table.\n\n## Workflow folders\n\nHosts that keep versioned folders of workflow scripts serve them by name (the SDK\'s\n`openWorkflowDir` \u2014 see `docs/api.md`). The filename stem is the name (`review-pr.workflow.js` \u21D2\n`review-pr`; `.workflow.js` beats `.js`). For script AUTHORS the takeaway is simply:\n`workflow("<name>")` works when the host serves a folder; keep names equal to filename stems.\n\n---\n\n# Complete example \u2014 quick-wins.workflow.js\n\nA complete, validated script (`loopUntilDry()` with per-round vendor rotation, dedup threading via a `seen` list, and an in-round budget floor; runs standalone or nested):\n\n```js\n// quick-wins \u2014 a small, self-contained hunter that repo-triage nests by name\n// (`workflow("quick-wins", {...})`) and that also runs standalone:\n//\n// npm start -- --workflow quick-wins\n// npx agentprism-workflows validate quick-wins --workflows-dir workflows\n//\n// Demonstrates loopUntilDry(): keep spawning hunt rounds \u2014 each on the next vendor\n// in the pool \u2014 until two consecutive rounds add nothing new (or the round cap /\n// token budget stops it first). Workflow scripts are self-contained strings with no\n// imports, so the vendor pool is repeated here rather than shared with repo-triage.\nexport const meta = {\n name: "quick-wins",\n description: "Hunt small, high-confidence quick wins across the repo until two consecutive rounds come up dry",\n phases: [{ title: "Hunt" }],\n};\n\n// args \u2014 every knob optional; hosts may hand args through as a JSON string.\nconst raw = typeof args === "string" ? (() => { try { return JSON.parse(args); } catch { return {}; } })() : args;\nconst opt = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};\nconst rounds = Number.isFinite(Number(opt.rounds)) && Number(opt.rounds) >= 1 ? Math.floor(Number(opt.rounds)) : 4;\nconst focus =\n typeof opt.focus === "string" && opt.focus.trim().length > 0\n ? opt.focus.trim()\n : "small, safe, high-confidence improvements";\nconst avoid = Array.isArray(opt.avoid) ? opt.avoid.filter((x) => typeof x === "string") : [];\n\n// These registered-prefix specs use ids verified against each live harness catalog.\nconst POOL = [\n { name: "claude", model: "claude/opus[1m]", mode: "plan" },\n { name: "codex", model: "codex/gpt-5.6-sol", mode: "read-only" },\n { name: "opencode", model: "opencode/zai/glm-5.2" },\n];\n\nconst WINS = {\n type: "object",\n additionalProperties: false,\n required: ["wins"],\n properties: {\n wins: {\n type: "array",\n items: {\n type: "object",\n additionalProperties: false,\n required: ["file", "summary", "action"],\n properties: {\n file: {\n type: "string",\n description: "Repo-relative path of a file you actually opened \u2014 copy it exactly, never invent one",\n },\n summary: { type: "string", description: "One sentence: the small problem or missed improvement" },\n action: { type: "string", description: "The concrete, low-risk change that fixes it, in one clause" },\n },\n },\n },\n },\n};\n\nphase("Hunt");\nconst seen = [];\nconst wins = await loopUntilDry({\n round: async (i) => {\n // Budget floor: leave headroom for whatever runs after this hunt. When nested\n // inside repo-triage, budget.* reads the PARENT run\'s shared budget.\n if (budget.total && budget.remaining() < 30_000) {\n log(`Hunt round ${i + 1}: stopping \u2014 only ${budget.remaining()} tokens left`);\n return [];\n }\n const v = POOL[i % POOL.length];\n const r = await agent(\n `Hunt round ${i + 1}: find up to 3 quick wins in this repository \u2014 ${focus}. ` +\n "A quick win is a small, safe, self-contained improvement (a missing guard, a stale doc line, an obvious dead branch), " +\n "not a refactor. Open files and ground every entry in code you actually read; never emit a placeholder.\\n" +\n `Already known \u2014 do NOT repeat anything on this list: ${JSON.stringify([...avoid, ...seen])}`,\n { label: `hunt:${i + 1}:${v.name}`, phase: "Hunt", schema: WINS, model: v.model, mode: v.mode },\n );\n const found = (r?.wins ?? []).filter((w) => typeof w.file === "string" && w.file.length > 0 && !w.file.startsWith("/"));\n seen.push(...found.map((w) => `${w.file}: ${w.summary}`));\n return found.map((w) => ({ ...w, foundBy: v.name }));\n },\n key: (w) => `${w.file}::${w.summary}`,\n consecutiveEmpty: 2,\n maxRounds: rounds,\n});\n\nlog(`quick-wins: ${wins.length} unique wins across the hunt`);\nreturn { wins };\n```\n';
|
|
32759
33065
|
|
|
32760
33066
|
// ../mcp-server/src/authoring-prompt.ts
|
|
32761
33067
|
var AUTHORING_PROMPT_NAME = "author-workflow";
|
|
@@ -32797,6 +33103,929 @@ function registerAuthoringPrompt(mcp) {
|
|
|
32797
33103
|
);
|
|
32798
33104
|
}
|
|
32799
33105
|
|
|
33106
|
+
// ../mcp-server/src/repl-tool.ts
|
|
33107
|
+
import { capFinalText, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES } from "@automatalabs/repl-engine";
|
|
33108
|
+
import { isAbsolute as isAbsolute3 } from "node:path";
|
|
33109
|
+
var replToolInputShape = {
|
|
33110
|
+
action: external_exports.enum(["eval", "wait", "status", "interrupt", "reset"]).describe(
|
|
33111
|
+
"Operation. eval runs a script in the workspace's VM (persistent between calls); wait pumps server-side until the target calls settle or the timeout elapses; status reports workspaces, the workspace manifest, live agents, and pending ops; interrupt cancels one subagent call or breaks the running eval (refused when nothing is running); reset drops the VM and its stored state."
|
|
33112
|
+
),
|
|
33113
|
+
projectDir: external_exports.string().min(1).refine((value) => isAbsolute3(value), "projectDir must be an absolute path").optional().describe(
|
|
33114
|
+
"Absolute project directory the workspace lives in: one VM per projectDir, addressed exactly like the workflow tool's projectDir (the same validated, realpathed per-project context; the workspace state survives MCP-session churn and daemon restarts through the per-project repl store). Required on the shared workflow daemon; optional (defaults to this server's own project) in single-project mode."
|
|
33115
|
+
),
|
|
33116
|
+
code: external_exports.string().optional().describe("The JavaScript to eval (top-level await accepted; `return` is a syntax error; console output is captured). An empty string is valid JavaScript and resolves with `undefined` (the normal resolved eval shape)."),
|
|
33117
|
+
ids: external_exports.array(external_exports.string()).optional().describe("Call ids to wait for (wait action). Omitted: wait for every pending call."),
|
|
33118
|
+
timeoutMs: external_exports.number().int().nonnegative().max(12e4).optional().describe("Bounded server-side wait (wait action; default 30 000 ms, max 120 000 ms)."),
|
|
33119
|
+
id: external_exports.string().optional().describe("The call id to cancel (interrupt action). Omitted: break the running eval (honestly refused when no eval is in flight)."),
|
|
33120
|
+
refs: external_exports.array(external_exports.string()).optional().describe(
|
|
33121
|
+
"Continuation refs to read back (eval/wait/status): the truncated record's ref ids from an earlier result \u2014 the snapshot of the entries the structured-output cap elided (pending ids, checkpoint questions, completion ids, status metadata). The result carries them under `referenced` \u2014 the cap costs reads, never data."
|
|
33122
|
+
)
|
|
33123
|
+
};
|
|
33124
|
+
var replInputFields = ["action", "projectDir", "code", "ids", "timeoutMs", "id", "refs"];
|
|
33125
|
+
var REPL_ACTION_FIELDS = {
|
|
33126
|
+
eval: /* @__PURE__ */ new Set(["action", "projectDir", "code", "refs"]),
|
|
33127
|
+
wait: /* @__PURE__ */ new Set(["action", "projectDir", "ids", "timeoutMs", "refs"]),
|
|
33128
|
+
status: /* @__PURE__ */ new Set(["action", "projectDir", "refs"]),
|
|
33129
|
+
interrupt: /* @__PURE__ */ new Set(["action", "projectDir", "id"]),
|
|
33130
|
+
reset: /* @__PURE__ */ new Set(["action", "projectDir"])
|
|
33131
|
+
};
|
|
33132
|
+
function invalidReplInput(message) {
|
|
33133
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid repl tool input: ${message}`);
|
|
33134
|
+
}
|
|
33135
|
+
function parseRefs(raw) {
|
|
33136
|
+
if (raw.refs === void 0) return void 0;
|
|
33137
|
+
const refs = replToolInputShape.refs.parse(raw.refs) ?? [];
|
|
33138
|
+
return refs.length > 0 ? [...new Set(refs)] : void 0;
|
|
33139
|
+
}
|
|
33140
|
+
function resolveRefs(refs, contexts) {
|
|
33141
|
+
if (refs === void 0) return void 0;
|
|
33142
|
+
const referenced = {};
|
|
33143
|
+
for (const ref of refs) {
|
|
33144
|
+
for (const context of contexts) {
|
|
33145
|
+
const values = context.repl?.truncationRefs.get(ref);
|
|
33146
|
+
if (values !== void 0) {
|
|
33147
|
+
referenced[ref] = values;
|
|
33148
|
+
break;
|
|
33149
|
+
}
|
|
33150
|
+
}
|
|
33151
|
+
}
|
|
33152
|
+
return Object.keys(referenced).length > 0 ? referenced : void 0;
|
|
33153
|
+
}
|
|
33154
|
+
function stateRefStoreOf(contexts) {
|
|
33155
|
+
for (const context of contexts) {
|
|
33156
|
+
if (context.repl !== void 0) return context.repl.truncationRefs;
|
|
33157
|
+
}
|
|
33158
|
+
return void 0;
|
|
33159
|
+
}
|
|
33160
|
+
function parseReplToolInput(raw, options) {
|
|
33161
|
+
const action = replToolInputShape.action.parse(raw.action);
|
|
33162
|
+
const allowed = REPL_ACTION_FIELDS[action];
|
|
33163
|
+
const present = replInputFields.filter((field) => field !== "action" && raw[field] !== void 0);
|
|
33164
|
+
for (const field of present) {
|
|
33165
|
+
if (!allowed.has(field)) {
|
|
33166
|
+
invalidReplInput(`action "${action}" cannot include ${field}`);
|
|
33167
|
+
}
|
|
33168
|
+
}
|
|
33169
|
+
const projectDir = raw.projectDir === void 0 ? void 0 : replToolInputShape.projectDir.parse(raw.projectDir);
|
|
33170
|
+
if (projectDir === void 0 && options.requireProjectDir && action !== "status") {
|
|
33171
|
+
invalidReplInput("projectDir is required on the shared workflow daemon");
|
|
33172
|
+
}
|
|
33173
|
+
switch (action) {
|
|
33174
|
+
case "eval": {
|
|
33175
|
+
const code = replToolInputShape.code.parse(raw.code);
|
|
33176
|
+
if (code === void 0) {
|
|
33177
|
+
invalidReplInput("eval requires a code string");
|
|
33178
|
+
}
|
|
33179
|
+
return { action, projectDir, code, refs: parseRefs(raw) };
|
|
33180
|
+
}
|
|
33181
|
+
case "wait": {
|
|
33182
|
+
const ids = raw.ids === void 0 ? void 0 : replToolInputShape.ids.parse(raw.ids);
|
|
33183
|
+
const timeoutMs = replToolInputShape.timeoutMs.parse(raw.timeoutMs ?? 3e4) ?? 3e4;
|
|
33184
|
+
return { action, projectDir, ids, timeoutMs, refs: parseRefs(raw) };
|
|
33185
|
+
}
|
|
33186
|
+
case "status":
|
|
33187
|
+
return { action, projectDir, refs: parseRefs(raw) };
|
|
33188
|
+
case "interrupt": {
|
|
33189
|
+
const id = raw.id === void 0 ? void 0 : replToolInputShape.id.parse(raw.id);
|
|
33190
|
+
return { action, projectDir, id };
|
|
33191
|
+
}
|
|
33192
|
+
case "reset":
|
|
33193
|
+
return { action, projectDir };
|
|
33194
|
+
}
|
|
33195
|
+
}
|
|
33196
|
+
function resolveContext(options, projectDir) {
|
|
33197
|
+
if (projectDir === void 0) {
|
|
33198
|
+
return options.projects.stores()[0];
|
|
33199
|
+
}
|
|
33200
|
+
const resolution = resolveProjectDir(projectDir);
|
|
33201
|
+
if (!resolution.ok) {
|
|
33202
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid repl tool input: ${resolution.message}`);
|
|
33203
|
+
}
|
|
33204
|
+
return options.projects.getOrCreate(resolution.projectDir);
|
|
33205
|
+
}
|
|
33206
|
+
function refusedResult(state, action) {
|
|
33207
|
+
const error51 = state.restoreError;
|
|
33208
|
+
return {
|
|
33209
|
+
structuredContent: {
|
|
33210
|
+
action,
|
|
33211
|
+
projectDir: state.projectDir,
|
|
33212
|
+
error: `${error51.message} (the stored snapshot is not restorable with the running engine \u2014 run the repl tool with action "reset" to drop it and start a fresh workspace)`
|
|
33213
|
+
},
|
|
33214
|
+
content: [
|
|
33215
|
+
{
|
|
33216
|
+
type: "text",
|
|
33217
|
+
text: capToolResultText(
|
|
33218
|
+
`REPL workspace refused: ${error51.message}
|
|
33219
|
+
The stored snapshot is not restorable with the running engine. Run the repl tool with action "reset" to drop it and start a fresh workspace.`
|
|
33220
|
+
)
|
|
33221
|
+
}
|
|
33222
|
+
],
|
|
33223
|
+
isError: true
|
|
33224
|
+
};
|
|
33225
|
+
}
|
|
33226
|
+
function drainErrorLine(state) {
|
|
33227
|
+
const error51 = state.drainError;
|
|
33228
|
+
if (error51 === null) return null;
|
|
33229
|
+
return `warn: ${error51.name}: ${error51.message} (the last client-presence drain failed \u2014 the workspace state was not persisted; the next disconnect retries the drain)`;
|
|
33230
|
+
}
|
|
33231
|
+
var replOutputFields = [
|
|
33232
|
+
"action",
|
|
33233
|
+
"projectDir",
|
|
33234
|
+
"output",
|
|
33235
|
+
"outputTruncated",
|
|
33236
|
+
"result",
|
|
33237
|
+
"pending",
|
|
33238
|
+
"checkpoints",
|
|
33239
|
+
"completed",
|
|
33240
|
+
"drained",
|
|
33241
|
+
"timedOut",
|
|
33242
|
+
"workspaces",
|
|
33243
|
+
"interrupt",
|
|
33244
|
+
"dropped",
|
|
33245
|
+
"truncated",
|
|
33246
|
+
"error"
|
|
33247
|
+
];
|
|
33248
|
+
function forbidsOutside2(allowed) {
|
|
33249
|
+
const allowedFields = new Set(allowed);
|
|
33250
|
+
return {
|
|
33251
|
+
not: {
|
|
33252
|
+
anyOf: replOutputFields.filter((field) => !allowedFields.has(field)).map((field) => ({ required: [field] }))
|
|
33253
|
+
}
|
|
33254
|
+
};
|
|
33255
|
+
}
|
|
33256
|
+
var truncatedShape = external_exports.record(
|
|
33257
|
+
external_exports.string(),
|
|
33258
|
+
external_exports.union([
|
|
33259
|
+
// The string backstop's elision count (`truncated.strings`).
|
|
33260
|
+
external_exports.number().int().positive(),
|
|
33261
|
+
// An elided array's continuation reference (phase-F review round
|
|
33262
|
+
// 2): the dropped tail's entry count plus the ref id that a later
|
|
33263
|
+
// eval/wait/status call's `refs` parameter reads back — the cap
|
|
33264
|
+
// costs reads, never data.
|
|
33265
|
+
external_exports.object({ elided: external_exports.number().int().positive(), ref: external_exports.string() })
|
|
33266
|
+
])
|
|
33267
|
+
);
|
|
33268
|
+
var checkpointSummaryShape = external_exports.object({
|
|
33269
|
+
id: external_exports.string(),
|
|
33270
|
+
question: external_exports.string()
|
|
33271
|
+
});
|
|
33272
|
+
var reconcileReportShape = external_exports.object({
|
|
33273
|
+
settledFromStore: external_exports.array(external_exports.string()),
|
|
33274
|
+
reattached: external_exports.array(external_exports.string()),
|
|
33275
|
+
reissued: external_exports.array(external_exports.string()),
|
|
33276
|
+
failedLost: external_exports.array(external_exports.string()),
|
|
33277
|
+
requeuedCheckpoints: external_exports.array(external_exports.string()),
|
|
33278
|
+
leftPending: external_exports.array(external_exports.string()),
|
|
33279
|
+
reQueuedUndelivered: external_exports.array(external_exports.string())
|
|
33280
|
+
});
|
|
33281
|
+
var manifestBindingShape = external_exports.object({
|
|
33282
|
+
name: external_exports.string(),
|
|
33283
|
+
/** Structure-only token (type/shape/size, and the live-handle status
|
|
33284
|
+
* for agent handles) — never value content. */
|
|
33285
|
+
token: external_exports.string(),
|
|
33286
|
+
/** The machine-readable structure-only type label (`string`,
|
|
33287
|
+
* `number`, `object`, `array`, `agent handle`, … — see the engine's
|
|
33288
|
+
* `manifestTypeLabel` vocabulary). */
|
|
33289
|
+
type: external_exports.string(),
|
|
33290
|
+
sizeBytes: external_exports.number().int().nonnegative(),
|
|
33291
|
+
/** The stable call id of an agent-handle binding; null otherwise. */
|
|
33292
|
+
handleCallId: external_exports.string().nullable(),
|
|
33293
|
+
/** The live-handle status of an agent-handle binding (`pending`
|
|
33294
|
+
* while its founding call is unsettled, `settled` once it
|
|
33295
|
+
* completed); null for non-handle bindings. */
|
|
33296
|
+
handleStatus: external_exports.enum(["pending", "settled"]).nullable(),
|
|
33297
|
+
provenance: external_exports.string().nullable(),
|
|
33298
|
+
provenanceAtMs: external_exports.number().int().nonnegative().nullable(),
|
|
33299
|
+
task: external_exports.string().nullable()
|
|
33300
|
+
});
|
|
33301
|
+
var logRefsShape = external_exports.object({
|
|
33302
|
+
first: external_exports.number().int().nonnegative().nullable(),
|
|
33303
|
+
last: external_exports.number().int().nonnegative().nullable(),
|
|
33304
|
+
count: external_exports.number().int().nonnegative()
|
|
33305
|
+
});
|
|
33306
|
+
var liveAgentShape = external_exports.object({
|
|
33307
|
+
callId: external_exports.string(),
|
|
33308
|
+
modelSpec: external_exports.string(),
|
|
33309
|
+
task: external_exports.string().max(200),
|
|
33310
|
+
state: external_exports.enum(["opening", "running", "delivering", "idle"]),
|
|
33311
|
+
supportsSteering: external_exports.boolean(),
|
|
33312
|
+
queuedSteers: external_exports.number().int().nonnegative()
|
|
33313
|
+
});
|
|
33314
|
+
var workspaceStatusShape = external_exports.object({
|
|
33315
|
+
projectDir: external_exports.string(),
|
|
33316
|
+
state: external_exports.enum(["not-opened", "fresh", "restored", "refused"]),
|
|
33317
|
+
restoreError: external_exports.string().optional(),
|
|
33318
|
+
reconcile: reconcileReportShape.optional(),
|
|
33319
|
+
bindings: external_exports.array(manifestBindingShape),
|
|
33320
|
+
logs: logRefsShape,
|
|
33321
|
+
evalSeq: external_exports.number().int().nonnegative(),
|
|
33322
|
+
inFlight: external_exports.array(external_exports.string()),
|
|
33323
|
+
checkpoints: external_exports.array(checkpointSummaryShape),
|
|
33324
|
+
liveAgents: external_exports.array(liveAgentShape),
|
|
33325
|
+
pending: external_exports.array(external_exports.string()),
|
|
33326
|
+
/** True when the client-presence drain closed every child (the
|
|
33327
|
+
* workspace stays live; re-attach on demand). */
|
|
33328
|
+
childrenClosed: external_exports.boolean(),
|
|
33329
|
+
drainError: external_exports.string().optional()
|
|
33330
|
+
});
|
|
33331
|
+
var interruptOutcomeShape = external_exports.object({
|
|
33332
|
+
outcome: external_exports.enum(["targeted", "refused-idle", "cancelled", "idle", "failed", "none"]),
|
|
33333
|
+
callId: external_exports.string().optional()
|
|
33334
|
+
});
|
|
33335
|
+
var replToolOutputShape = external_exports.object({
|
|
33336
|
+
action: external_exports.enum(["eval", "wait", "status", "interrupt", "reset"]),
|
|
33337
|
+
projectDir: external_exports.string().optional(),
|
|
33338
|
+
// eval/wait (the doc's `{ output, result?, pending, checkpoints,
|
|
33339
|
+
// completed }`).
|
|
33340
|
+
output: external_exports.array(external_exports.string()).optional(),
|
|
33341
|
+
outputTruncated: external_exports.boolean().optional(),
|
|
33342
|
+
result: external_exports.string().optional(),
|
|
33343
|
+
pending: external_exports.array(external_exports.string()).optional(),
|
|
33344
|
+
checkpoints: external_exports.array(checkpointSummaryShape).optional(),
|
|
33345
|
+
completed: external_exports.array(external_exports.string()).optional(),
|
|
33346
|
+
// The aggregate structured-result cap's elision record (present
|
|
33347
|
+
// only when the serialized result crossed the doc's 10 KB bound):
|
|
33348
|
+
// a path-keyed record that serves every variant (`pending`,
|
|
33349
|
+
// `checkpoints`, `workspaces[0].reconcile.requeuedCheckpoints`, …)
|
|
33350
|
+
// with the elided entry counts — the kept head prefix plus the
|
|
33351
|
+
// record always reconciles to the true totals — and each elided
|
|
33352
|
+
// array's CONTINUATION REF (phase-F review round 2): the dropped
|
|
33353
|
+
// tail's snapshot id, readable back through the `refs` parameter of
|
|
33354
|
+
// a later eval/wait/status call (`referenced` in the result). The
|
|
33355
|
+
// cap costs reads, never data.
|
|
33356
|
+
truncated: truncatedShape.optional(),
|
|
33357
|
+
// The referenced continuation values (phase-F review round 2): the
|
|
33358
|
+
// `refs` parameter's read-back — `{ [refId]: values }` for every
|
|
33359
|
+
// requested ref the workspace's truncation-reference store holds
|
|
33360
|
+
// (the dropped entries of an earlier elision, verbatim).
|
|
33361
|
+
referenced: external_exports.record(external_exports.string(), external_exports.array(external_exports.unknown())).optional(),
|
|
33362
|
+
// wait-only: whether the targets settled within the bound (false =
|
|
33363
|
+
// the doc's "still running" timeout outcome).
|
|
33364
|
+
drained: external_exports.boolean().optional(),
|
|
33365
|
+
timedOut: external_exports.boolean().optional(),
|
|
33366
|
+
// status: one entry per workspace context.
|
|
33367
|
+
workspaces: external_exports.array(workspaceStatusShape).optional(),
|
|
33368
|
+
// interrupt: the honest outcome.
|
|
33369
|
+
interrupt: interruptOutcomeShape.optional(),
|
|
33370
|
+
// reset: the teardown acknowledgement.
|
|
33371
|
+
dropped: external_exports.boolean().optional(),
|
|
33372
|
+
// The error variant (a refused snapshot, a missing project context).
|
|
33373
|
+
error: external_exports.string().optional()
|
|
33374
|
+
}).superRefine((value, context) => {
|
|
33375
|
+
const keys = new Set(Object.keys(value));
|
|
33376
|
+
const has = (field) => keys.has(field);
|
|
33377
|
+
const only = (...fields) => [...keys].every((key) => key === "action" || fields.includes(key));
|
|
33378
|
+
const hasAll = (...fields) => fields.every(has);
|
|
33379
|
+
let valid;
|
|
33380
|
+
if (has("error")) {
|
|
33381
|
+
valid = only("projectDir", "error");
|
|
33382
|
+
} else if (value.action === "eval") {
|
|
33383
|
+
valid = only("projectDir", "output", "outputTruncated", "result", "pending", "checkpoints", "completed", "truncated", "referenced") && hasAll("projectDir", "output", "outputTruncated", "pending", "checkpoints", "completed");
|
|
33384
|
+
} else if (value.action === "wait") {
|
|
33385
|
+
valid = only("projectDir", "output", "outputTruncated", "result", "pending", "checkpoints", "completed", "drained", "timedOut", "truncated", "referenced") && hasAll("projectDir", "output", "outputTruncated", "pending", "checkpoints", "completed", "drained", "timedOut");
|
|
33386
|
+
} else if (value.action === "status") {
|
|
33387
|
+
valid = only("projectDir", "workspaces", "truncated", "referenced") && has("workspaces");
|
|
33388
|
+
} else if (value.action === "interrupt") {
|
|
33389
|
+
valid = only("projectDir", "interrupt") && hasAll("projectDir", "interrupt");
|
|
33390
|
+
} else if (value.action === "reset") {
|
|
33391
|
+
valid = only("projectDir", "dropped") && hasAll("projectDir", "dropped");
|
|
33392
|
+
} else {
|
|
33393
|
+
valid = false;
|
|
33394
|
+
}
|
|
33395
|
+
if (!valid) {
|
|
33396
|
+
context.addIssue({ code: "custom", message: "output does not match a repl result variant" });
|
|
33397
|
+
}
|
|
33398
|
+
}).meta({
|
|
33399
|
+
oneOf: [
|
|
33400
|
+
{
|
|
33401
|
+
title: "eval",
|
|
33402
|
+
required: ["action", "projectDir", "output", "outputTruncated", "pending", "checkpoints", "completed"],
|
|
33403
|
+
properties: { action: { const: "eval" } },
|
|
33404
|
+
...forbidsOutside2(["action", "projectDir", "output", "outputTruncated", "result", "pending", "checkpoints", "completed", "truncated", "referenced"])
|
|
33405
|
+
},
|
|
33406
|
+
{
|
|
33407
|
+
title: "wait",
|
|
33408
|
+
required: ["action", "projectDir", "output", "outputTruncated", "pending", "checkpoints", "completed", "drained", "timedOut"],
|
|
33409
|
+
properties: { action: { const: "wait" } },
|
|
33410
|
+
...forbidsOutside2(["action", "projectDir", "output", "outputTruncated", "result", "pending", "checkpoints", "completed", "drained", "timedOut", "truncated", "referenced"])
|
|
33411
|
+
},
|
|
33412
|
+
{
|
|
33413
|
+
title: "status",
|
|
33414
|
+
required: ["action", "workspaces"],
|
|
33415
|
+
properties: { action: { const: "status" } },
|
|
33416
|
+
...forbidsOutside2(["action", "projectDir", "workspaces", "truncated", "referenced"])
|
|
33417
|
+
},
|
|
33418
|
+
{
|
|
33419
|
+
title: "interrupt",
|
|
33420
|
+
required: ["action", "projectDir", "interrupt"],
|
|
33421
|
+
properties: { action: { const: "interrupt" } },
|
|
33422
|
+
...forbidsOutside2(["action", "projectDir", "interrupt"])
|
|
33423
|
+
},
|
|
33424
|
+
{
|
|
33425
|
+
title: "reset",
|
|
33426
|
+
required: ["action", "projectDir", "dropped"],
|
|
33427
|
+
properties: { action: { const: "reset" } },
|
|
33428
|
+
...forbidsOutside2(["action", "projectDir", "dropped"])
|
|
33429
|
+
},
|
|
33430
|
+
{
|
|
33431
|
+
title: "error",
|
|
33432
|
+
required: ["action", "error"],
|
|
33433
|
+
...forbidsOutside2(["action", "projectDir", "error"])
|
|
33434
|
+
}
|
|
33435
|
+
]
|
|
33436
|
+
});
|
|
33437
|
+
var TOOL_RESULT_TRUNCATION_MARKER = `(tool result truncated \u2014 cap: ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes; the omitted console values remain reachable through their $N refs, and every elided structured field through its truncated record's continuation ref \u2014 read it back with the refs parameter)`;
|
|
33438
|
+
var STRUCTURED_MAX_BYTES = OUTPUT_MAX_BYTES;
|
|
33439
|
+
var STRUCTURED_STRING_MAX = 200;
|
|
33440
|
+
function structuredBytes(value) {
|
|
33441
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
33442
|
+
}
|
|
33443
|
+
function largestStructuredArray(node, path, best, eligible) {
|
|
33444
|
+
if (Array.isArray(node)) {
|
|
33445
|
+
if (eligible(path, node.length)) {
|
|
33446
|
+
const bytes = structuredBytes(node);
|
|
33447
|
+
if (best === null || bytes > best.bytes) best = { path, value: node, bytes };
|
|
33448
|
+
}
|
|
33449
|
+
for (let i = 0; i < node.length; i++) {
|
|
33450
|
+
best = largestStructuredArray(node[i], [...path, i], best, eligible);
|
|
33451
|
+
}
|
|
33452
|
+
return best;
|
|
33453
|
+
}
|
|
33454
|
+
if (typeof node === "object" && node !== null) {
|
|
33455
|
+
for (const [key, value] of Object.entries(node)) {
|
|
33456
|
+
best = largestStructuredArray(value, [...path, key], best, eligible);
|
|
33457
|
+
}
|
|
33458
|
+
}
|
|
33459
|
+
return best;
|
|
33460
|
+
}
|
|
33461
|
+
function setStructuredPath(node, path, value) {
|
|
33462
|
+
let cursor = node;
|
|
33463
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
33464
|
+
cursor = cursor[path[i]];
|
|
33465
|
+
}
|
|
33466
|
+
cursor[path[path.length - 1]] = value;
|
|
33467
|
+
}
|
|
33468
|
+
function structuredPathKey(path) {
|
|
33469
|
+
let key = "";
|
|
33470
|
+
for (const part of path) {
|
|
33471
|
+
if (typeof part === "number") key += `[${part}]`;
|
|
33472
|
+
else key += key === "" ? part : `.${part}`;
|
|
33473
|
+
}
|
|
33474
|
+
return key;
|
|
33475
|
+
}
|
|
33476
|
+
function structuredHeadTail(value, max) {
|
|
33477
|
+
if (value.length <= max) return value;
|
|
33478
|
+
if (max <= 1) return "\u2026";
|
|
33479
|
+
const keep = max - 1;
|
|
33480
|
+
const head = Math.ceil(keep / 2);
|
|
33481
|
+
const tail = keep - head;
|
|
33482
|
+
return `${value.slice(0, head)}\u2026${value.slice(value.length - tail)}`;
|
|
33483
|
+
}
|
|
33484
|
+
function capStructuredStrings(node, max) {
|
|
33485
|
+
let elided = 0;
|
|
33486
|
+
if (Array.isArray(node)) {
|
|
33487
|
+
for (let i = 0; i < node.length; i++) {
|
|
33488
|
+
const item = node[i];
|
|
33489
|
+
if (typeof item === "string") {
|
|
33490
|
+
if (item.length > max) {
|
|
33491
|
+
node[i] = structuredHeadTail(item, max);
|
|
33492
|
+
elided++;
|
|
33493
|
+
}
|
|
33494
|
+
} else {
|
|
33495
|
+
elided += capStructuredStrings(item, max);
|
|
33496
|
+
}
|
|
33497
|
+
}
|
|
33498
|
+
return elided;
|
|
33499
|
+
}
|
|
33500
|
+
if (typeof node === "object" && node !== null) {
|
|
33501
|
+
const record2 = node;
|
|
33502
|
+
for (const key of Object.keys(record2)) {
|
|
33503
|
+
const value = record2[key];
|
|
33504
|
+
if (typeof value === "string") {
|
|
33505
|
+
if (value.length > max) {
|
|
33506
|
+
record2[key] = structuredHeadTail(value, max);
|
|
33507
|
+
elided++;
|
|
33508
|
+
}
|
|
33509
|
+
} else {
|
|
33510
|
+
elided += capStructuredStrings(value, max);
|
|
33511
|
+
}
|
|
33512
|
+
}
|
|
33513
|
+
}
|
|
33514
|
+
return elided;
|
|
33515
|
+
}
|
|
33516
|
+
function capStructuredResult(result, truncationRefs) {
|
|
33517
|
+
result = JSON.parse(JSON.stringify(result));
|
|
33518
|
+
const truncated = {};
|
|
33519
|
+
const fits = () => structuredBytes({ ...result, truncated }) <= STRUCTURED_MAX_BYTES;
|
|
33520
|
+
if (fits()) return result;
|
|
33521
|
+
const capture = (dropped) => truncationRefs === void 0 ? "" : truncationRefs.set(dropped);
|
|
33522
|
+
const recordElision = (key, dropped) => {
|
|
33523
|
+
const prior = truncated[key];
|
|
33524
|
+
const priorRef = typeof prior === "object" && prior !== null ? prior.ref : void 0;
|
|
33525
|
+
const priorValues = priorRef !== void 0 && truncationRefs !== void 0 ? truncationRefs.get(priorRef) : void 0;
|
|
33526
|
+
const accumulated = priorValues !== void 0 ? [...dropped, ...priorValues] : dropped;
|
|
33527
|
+
const ref = capture(accumulated);
|
|
33528
|
+
const priorElided = typeof prior === "object" && prior !== null ? prior.elided : typeof prior === "number" ? prior : 0;
|
|
33529
|
+
truncated[key] = ref === "" ? priorElided + dropped.length : { elided: priorElided + dropped.length, ref };
|
|
33530
|
+
};
|
|
33531
|
+
for (; ; ) {
|
|
33532
|
+
if (fits()) break;
|
|
33533
|
+
const largest = largestStructuredArray(result, [], null, (_path, length) => length >= 2);
|
|
33534
|
+
if (largest === null) break;
|
|
33535
|
+
const kept = Math.floor(largest.value.length / 2);
|
|
33536
|
+
const dropped = largest.value.slice(kept);
|
|
33537
|
+
setStructuredPath(result, largest.path, largest.value.slice(0, kept));
|
|
33538
|
+
recordElision(structuredPathKey(largest.path), dropped);
|
|
33539
|
+
}
|
|
33540
|
+
if (!fits()) {
|
|
33541
|
+
const stringElisions = capStructuredStrings(result, STRUCTURED_STRING_MAX);
|
|
33542
|
+
if (stringElisions > 0) truncated.strings = stringElisions;
|
|
33543
|
+
}
|
|
33544
|
+
if (!fits()) {
|
|
33545
|
+
for (; ; ) {
|
|
33546
|
+
const largest = largestStructuredArray(
|
|
33547
|
+
result,
|
|
33548
|
+
[],
|
|
33549
|
+
null,
|
|
33550
|
+
(path) => !(path.length === 1 && path[0] === "workspaces")
|
|
33551
|
+
);
|
|
33552
|
+
if (largest === null) break;
|
|
33553
|
+
const dropped = largest.value;
|
|
33554
|
+
setStructuredPath(result, largest.path, []);
|
|
33555
|
+
recordElision(structuredPathKey(largest.path), dropped);
|
|
33556
|
+
if (fits()) break;
|
|
33557
|
+
}
|
|
33558
|
+
}
|
|
33559
|
+
if (Object.keys(truncated).length > 0) result.truncated = truncated;
|
|
33560
|
+
return result;
|
|
33561
|
+
}
|
|
33562
|
+
function capToolResultText(text) {
|
|
33563
|
+
return capFinalText(text, TOOL_RESULT_TRUNCATION_MARKER);
|
|
33564
|
+
}
|
|
33565
|
+
function renderEvalResult(result) {
|
|
33566
|
+
const lines = [];
|
|
33567
|
+
if (result.output.length > 0) lines.push(...result.output);
|
|
33568
|
+
if (result.result !== void 0) lines.push(`result: ${result.result}`);
|
|
33569
|
+
if (result.pending.length > 0) lines.push(`pending: ${result.pending.join(", ")}`);
|
|
33570
|
+
for (const checkpoint of result.checkpoints) {
|
|
33571
|
+
lines.push(`checkpoint ${checkpoint.id}: ${checkpoint.question}`);
|
|
33572
|
+
}
|
|
33573
|
+
if (result.completed.length > 0) lines.push(`completed: ${result.completed.join(", ")}`);
|
|
33574
|
+
return lines.length > 0 ? lines.join("\n") : "(no output)";
|
|
33575
|
+
}
|
|
33576
|
+
function renderStatus(contexts) {
|
|
33577
|
+
const lines = [];
|
|
33578
|
+
for (const context of contexts) {
|
|
33579
|
+
const state = context.repl;
|
|
33580
|
+
if (state === void 0) {
|
|
33581
|
+
lines.push(`workspace ${context.projectDir}: not opened yet`);
|
|
33582
|
+
continue;
|
|
33583
|
+
}
|
|
33584
|
+
if (state.restoreError !== null) {
|
|
33585
|
+
lines.push(`workspace ${context.projectDir}: REFUSED \u2014 ${state.restoreError.message}`);
|
|
33586
|
+
continue;
|
|
33587
|
+
}
|
|
33588
|
+
if (state.source === null) {
|
|
33589
|
+
lines.push(`workspace ${context.projectDir}: not opened yet`);
|
|
33590
|
+
continue;
|
|
33591
|
+
}
|
|
33592
|
+
if (state.source === "restored") {
|
|
33593
|
+
const report = state.reconcileReport;
|
|
33594
|
+
lines.push(
|
|
33595
|
+
`workspace ${context.projectDir}: restored` + (report !== null ? ` (settled from store: ${report.settledFromStore.length}, re-attached: ${report.reattached.length}, re-issued: ${report.reissued.length}, failed/lost: ${report.failedLost.length}, checkpoints re-surfaced: ${report.requeuedCheckpoints.length})` : "")
|
|
33596
|
+
);
|
|
33597
|
+
} else {
|
|
33598
|
+
lines.push(`workspace ${context.projectDir}: fresh`);
|
|
33599
|
+
}
|
|
33600
|
+
if (state.drainError !== null) {
|
|
33601
|
+
lines.push(`workspace ${context.projectDir}: LAST DRAIN FAILED \u2014 ${state.drainError.name}: ${state.drainError.message}`);
|
|
33602
|
+
}
|
|
33603
|
+
const broker = state.broker;
|
|
33604
|
+
if (broker === null) continue;
|
|
33605
|
+
const manifest = broker.workspaceManifest();
|
|
33606
|
+
if (manifest.bindings.length === 0) {
|
|
33607
|
+
lines.push("bindings: (none)");
|
|
33608
|
+
} else {
|
|
33609
|
+
lines.push("bindings:");
|
|
33610
|
+
for (const binding of manifest.bindings) {
|
|
33611
|
+
lines.push(
|
|
33612
|
+
` ${binding.name} = ${binding.token}` + (binding.provenance !== null ? ` \xB7 via ${binding.provenance}` : "") + (binding.task !== null ? ` \xB7 task ${JSON.stringify(binding.task)}` : "") + (binding.provenanceAtMs !== null ? ` \xB7 at ${new Date(binding.provenanceAtMs).toISOString()}` : "")
|
|
33613
|
+
);
|
|
33614
|
+
}
|
|
33615
|
+
}
|
|
33616
|
+
const logs = manifest.logs;
|
|
33617
|
+
lines.push(
|
|
33618
|
+
logs.first === null ? "logs: (none)" : `logs: $${logs.first}\u2026$${logs.last} (${logs.count} values)`
|
|
33619
|
+
);
|
|
33620
|
+
if (manifest.inFlight.length > 0) {
|
|
33621
|
+
lines.push(`in-flight calls: ${manifest.inFlight.join(", ")}`);
|
|
33622
|
+
}
|
|
33623
|
+
if (broker.isDrained) lines.push("children: closed (client-presence drain; re-attach on demand)");
|
|
33624
|
+
for (const agent of broker.liveAgents()) {
|
|
33625
|
+
lines.push(
|
|
33626
|
+
`agent ${agent.callId}: ${agent.state} \u2014 task: ${JSON.stringify(agent.task)} (${agent.modelSpec}; steering: ${agent.supportsSteering ? "yes" : "no"}; queued: ${agent.queuedSteers})`
|
|
33627
|
+
);
|
|
33628
|
+
}
|
|
33629
|
+
const pending = broker.pendingCalls();
|
|
33630
|
+
if (pending.length > 0) lines.push(`pending: ${pending.map((entry) => entry.id).join(", ")}`);
|
|
33631
|
+
for (const checkpoint of broker.checkpointSummaries()) {
|
|
33632
|
+
lines.push(`checkpoint ${checkpoint.id}: ${checkpoint.question}`);
|
|
33633
|
+
}
|
|
33634
|
+
}
|
|
33635
|
+
return lines.join("\n");
|
|
33636
|
+
}
|
|
33637
|
+
function structuredEvalWait(action, projectDir, result, drained) {
|
|
33638
|
+
const structured = {
|
|
33639
|
+
action,
|
|
33640
|
+
projectDir,
|
|
33641
|
+
output: result.output,
|
|
33642
|
+
outputTruncated: result.outputTruncated,
|
|
33643
|
+
pending: result.pending,
|
|
33644
|
+
checkpoints: result.checkpoints,
|
|
33645
|
+
completed: result.completed
|
|
33646
|
+
};
|
|
33647
|
+
if (result.result !== void 0) structured.result = result.result;
|
|
33648
|
+
if (drained !== void 0) {
|
|
33649
|
+
structured.drained = drained;
|
|
33650
|
+
structured.timedOut = !drained;
|
|
33651
|
+
}
|
|
33652
|
+
return structured;
|
|
33653
|
+
}
|
|
33654
|
+
function structuredStatus(contexts, projectDir) {
|
|
33655
|
+
const structured = {
|
|
33656
|
+
action: "status",
|
|
33657
|
+
workspaces: contexts.map((context) => {
|
|
33658
|
+
const entry = {
|
|
33659
|
+
projectDir: context.projectDir,
|
|
33660
|
+
state: "not-opened",
|
|
33661
|
+
bindings: [],
|
|
33662
|
+
logs: { first: null, last: null, count: 0 },
|
|
33663
|
+
evalSeq: 0,
|
|
33664
|
+
inFlight: [],
|
|
33665
|
+
checkpoints: [],
|
|
33666
|
+
liveAgents: [],
|
|
33667
|
+
pending: [],
|
|
33668
|
+
childrenClosed: false
|
|
33669
|
+
};
|
|
33670
|
+
const state = context.repl;
|
|
33671
|
+
if (state === void 0) return entry;
|
|
33672
|
+
if (state.restoreError !== null) {
|
|
33673
|
+
entry.state = "refused";
|
|
33674
|
+
entry.restoreError = state.restoreError.message;
|
|
33675
|
+
return entry;
|
|
33676
|
+
}
|
|
33677
|
+
if (state.source === null) return entry;
|
|
33678
|
+
entry.state = state.source;
|
|
33679
|
+
if (state.source === "restored" && state.reconcileReport !== null) {
|
|
33680
|
+
entry.reconcile = state.reconcileReport;
|
|
33681
|
+
}
|
|
33682
|
+
if (state.drainError !== null) {
|
|
33683
|
+
entry.drainError = `${state.drainError.name}: ${state.drainError.message}`;
|
|
33684
|
+
}
|
|
33685
|
+
const broker = state.broker;
|
|
33686
|
+
if (broker === null) return entry;
|
|
33687
|
+
const manifest = broker.workspaceManifest();
|
|
33688
|
+
entry.bindings = manifest.bindings;
|
|
33689
|
+
entry.logs = manifest.logs;
|
|
33690
|
+
entry.evalSeq = manifest.evalSeq;
|
|
33691
|
+
entry.inFlight = manifest.inFlight;
|
|
33692
|
+
entry.checkpoints = broker.checkpointSummaries();
|
|
33693
|
+
entry.liveAgents = broker.liveAgents();
|
|
33694
|
+
entry.pending = broker.pendingCalls().map((call) => call.id);
|
|
33695
|
+
entry.childrenClosed = broker.isDrained;
|
|
33696
|
+
return entry;
|
|
33697
|
+
})
|
|
33698
|
+
};
|
|
33699
|
+
if (projectDir !== void 0) structured.projectDir = projectDir;
|
|
33700
|
+
return structured;
|
|
33701
|
+
}
|
|
33702
|
+
function registerReplTool(mcp, options) {
|
|
33703
|
+
const { projects, wasm, requireProjectDir } = options;
|
|
33704
|
+
mcp.registerTool(
|
|
33705
|
+
"repl",
|
|
33706
|
+
{
|
|
33707
|
+
description: "One persistent QuickJS-in-WASM VM per projectDir, addressed by the same project model as the workflow tool. State (bindings, pending subagent calls, checkpoints) lives in the VM between calls and survives MCP-session churn and daemon restarts: every eval and every settlement drain that changed state persists the workspace to the daemon's per-project repl store, and the first touch of a stored workspace restores it and reconciles every outstanding call (settle from the store / re-attach via ACP session/load / re-issue). A stored snapshot that refuses (corrupt, a format upgrade, or a wasm-binary mismatch) is surfaced loudly and never silently discarded \u2014 reset drops it and starts fresh. Subagents are ACP sessions via acp-agents (6 concurrent per workspace); console output is captured and previewed. On last-client disconnect the workspace drains in-flight subagent turns to completion (each settlement boundary snapshots) and closes idle children; followUp re-attaches the subagent session lazily on the next connect. Every result carries the machine- readable shape (see the output schema) as structuredContent alongside the bounded text.",
|
|
33708
|
+
inputSchema: replToolInputShape,
|
|
33709
|
+
outputSchema: replToolOutputShape
|
|
33710
|
+
},
|
|
33711
|
+
async (rawArgs) => {
|
|
33712
|
+
if (!options.acceptingWork()) {
|
|
33713
|
+
throw new McpError(
|
|
33714
|
+
ErrorCode.InternalError,
|
|
33715
|
+
"Workflow server is shutting down and is no longer accepting tool calls."
|
|
33716
|
+
);
|
|
33717
|
+
}
|
|
33718
|
+
const input = parseReplToolInput(rawArgs, { requireProjectDir });
|
|
33719
|
+
const { action, projectDir } = input;
|
|
33720
|
+
if (action === "status") {
|
|
33721
|
+
if (projectDir === void 0) {
|
|
33722
|
+
const contexts = projects.stores();
|
|
33723
|
+
const structured = structuredStatus(contexts);
|
|
33724
|
+
const referenced = resolveRefs(input.refs, contexts);
|
|
33725
|
+
if (referenced !== void 0) structured.referenced = referenced;
|
|
33726
|
+
return {
|
|
33727
|
+
structuredContent: capStructuredResult(structured, stateRefStoreOf(contexts)),
|
|
33728
|
+
content: [{ type: "text", text: capToolResultText(renderStatus(contexts)) }]
|
|
33729
|
+
};
|
|
33730
|
+
}
|
|
33731
|
+
const context2 = resolveContext(options, projectDir);
|
|
33732
|
+
if (context2 === void 0) {
|
|
33733
|
+
return {
|
|
33734
|
+
structuredContent: {
|
|
33735
|
+
action: "status",
|
|
33736
|
+
projectDir,
|
|
33737
|
+
error: `No project context is available for projectDir "${projectDir}".`
|
|
33738
|
+
},
|
|
33739
|
+
content: [
|
|
33740
|
+
{
|
|
33741
|
+
type: "text",
|
|
33742
|
+
text: `No project context is available for projectDir "${projectDir}".`
|
|
33743
|
+
}
|
|
33744
|
+
],
|
|
33745
|
+
isError: true
|
|
33746
|
+
};
|
|
33747
|
+
}
|
|
33748
|
+
context2.repl ??= createReplProjectState(context2.projectDir);
|
|
33749
|
+
const state2 = context2.repl;
|
|
33750
|
+
options.presence.touch(state2, options.clientId() ?? "unknown");
|
|
33751
|
+
if (state2.restoreError === null) {
|
|
33752
|
+
await ensureReplWorkspace(state2, await wasm, options.runner, options.evalTimeoutMs, options.evalBreakChannel);
|
|
33753
|
+
}
|
|
33754
|
+
return {
|
|
33755
|
+
structuredContent: capStructuredResult(
|
|
33756
|
+
(() => {
|
|
33757
|
+
const structured = structuredStatus([context2], projectDir);
|
|
33758
|
+
const referenced = resolveRefs(input.refs, [context2]);
|
|
33759
|
+
if (referenced !== void 0) structured.referenced = referenced;
|
|
33760
|
+
return structured;
|
|
33761
|
+
})(),
|
|
33762
|
+
state2.truncationRefs
|
|
33763
|
+
),
|
|
33764
|
+
content: [{ type: "text", text: capToolResultText(renderStatus([context2])) }]
|
|
33765
|
+
};
|
|
33766
|
+
}
|
|
33767
|
+
const context = resolveContext(options, projectDir);
|
|
33768
|
+
if (context === void 0) {
|
|
33769
|
+
return {
|
|
33770
|
+
structuredContent: {
|
|
33771
|
+
action,
|
|
33772
|
+
projectDir,
|
|
33773
|
+
error: `No project context is available for projectDir "${projectDir}".`
|
|
33774
|
+
},
|
|
33775
|
+
content: [{ type: "text", text: capToolResultText(`No project context is available for projectDir "${projectDir}".`) }],
|
|
33776
|
+
isError: true
|
|
33777
|
+
};
|
|
33778
|
+
}
|
|
33779
|
+
context.repl ??= createReplProjectState(context.projectDir);
|
|
33780
|
+
const state = context.repl;
|
|
33781
|
+
options.presence.touch(state, options.clientId() ?? "unknown");
|
|
33782
|
+
if (action === "reset") {
|
|
33783
|
+
options.evalBreakChannel?.clearBreak(context.projectDir);
|
|
33784
|
+
await resetReplProjectState(state);
|
|
33785
|
+
return {
|
|
33786
|
+
structuredContent: { action: "reset", projectDir: context.projectDir, dropped: true },
|
|
33787
|
+
content: [
|
|
33788
|
+
{
|
|
33789
|
+
type: "text",
|
|
33790
|
+
text: capToolResultText(
|
|
33791
|
+
`workspace ${context.projectDir}: dropped \u2014 the VM and its stored state were reset`
|
|
33792
|
+
)
|
|
33793
|
+
}
|
|
33794
|
+
]
|
|
33795
|
+
};
|
|
33796
|
+
}
|
|
33797
|
+
if (state.restoreError !== null) return refusedResult(state, action);
|
|
33798
|
+
await ensureReplWorkspace(state, await wasm, options.runner, options.evalTimeoutMs, options.evalBreakChannel);
|
|
33799
|
+
if (state.restoreError !== null) return refusedResult(state, action);
|
|
33800
|
+
const broker = state.broker;
|
|
33801
|
+
if (action === "eval") {
|
|
33802
|
+
const result = await broker.eval(input.code);
|
|
33803
|
+
const line = drainErrorLine(state);
|
|
33804
|
+
const rendered = renderEvalResult(result);
|
|
33805
|
+
const text2 = line !== null ? `${line}
|
|
33806
|
+
${rendered}` : rendered;
|
|
33807
|
+
const structured = structuredEvalWait("eval", context.projectDir, result);
|
|
33808
|
+
const referenced = resolveRefs(input.refs, [context]);
|
|
33809
|
+
if (referenced !== void 0) structured.referenced = referenced;
|
|
33810
|
+
return {
|
|
33811
|
+
structuredContent: capStructuredResult(structured, state.truncationRefs),
|
|
33812
|
+
content: [{ type: "text", text: capToolResultText(text2) }]
|
|
33813
|
+
};
|
|
33814
|
+
}
|
|
33815
|
+
if (action === "wait") {
|
|
33816
|
+
const { result, drained } = await broker.waitForCalls(input.ids, input.timeoutMs);
|
|
33817
|
+
const text2 = renderEvalResult(result);
|
|
33818
|
+
const line = drainErrorLine(state);
|
|
33819
|
+
const body = drained ? text2 : `${text2}
|
|
33820
|
+
(still running \u2014 wait timed out after ${input.timeoutMs} ms)`;
|
|
33821
|
+
const waitText = line !== null ? `${line}
|
|
33822
|
+
${body}` : body;
|
|
33823
|
+
const structured = structuredEvalWait("wait", context.projectDir, result, drained);
|
|
33824
|
+
const referenced = resolveRefs(input.refs, [context]);
|
|
33825
|
+
if (referenced !== void 0) structured.referenced = referenced;
|
|
33826
|
+
return {
|
|
33827
|
+
structuredContent: capStructuredResult(structured, state.truncationRefs),
|
|
33828
|
+
content: [{ type: "text", text: capToolResultText(waitText) }]
|
|
33829
|
+
};
|
|
33830
|
+
}
|
|
33831
|
+
if (input.id === void 0) {
|
|
33832
|
+
const targeted = await broker.armEvalBreak();
|
|
33833
|
+
options.evalBreakChannel?.clearBreak(context.projectDir);
|
|
33834
|
+
if (!targeted && broker.consumeOutOfBandBreakReport() !== null) {
|
|
33835
|
+
return {
|
|
33836
|
+
structuredContent: {
|
|
33837
|
+
action: "interrupt",
|
|
33838
|
+
projectDir: context.projectDir,
|
|
33839
|
+
interrupt: { outcome: "targeted" }
|
|
33840
|
+
},
|
|
33841
|
+
content: [
|
|
33842
|
+
{
|
|
33843
|
+
type: "text",
|
|
33844
|
+
text: capToolResultText(
|
|
33845
|
+
`workspace ${context.projectDir}: the running eval was broken OUT OF BAND \u2014 the relay delivered the break while the daemon's main thread was blocked in the eval, and the quickjs interrupt handler broke it mid-run`
|
|
33846
|
+
)
|
|
33847
|
+
}
|
|
33848
|
+
]
|
|
33849
|
+
};
|
|
33850
|
+
}
|
|
33851
|
+
if (!targeted) {
|
|
33852
|
+
return {
|
|
33853
|
+
structuredContent: {
|
|
33854
|
+
action: "interrupt",
|
|
33855
|
+
projectDir: context.projectDir,
|
|
33856
|
+
interrupt: { outcome: "refused-idle" }
|
|
33857
|
+
},
|
|
33858
|
+
content: [
|
|
33859
|
+
{
|
|
33860
|
+
type: "text",
|
|
33861
|
+
text: capToolResultText(
|
|
33862
|
+
`workspace ${context.projectDir}: no running eval to interrupt \u2014 no eval is in flight, the in-flight evals await nothing this host can key an execution to (a never-settling local promise \u2014 no pending host call's settlement can ever resume it), or the resident guest library predates the continuation-lease seam (a restored older snapshot); nothing was armed`
|
|
33863
|
+
)
|
|
33864
|
+
}
|
|
33865
|
+
]
|
|
33866
|
+
};
|
|
33867
|
+
}
|
|
33868
|
+
return {
|
|
33869
|
+
structuredContent: {
|
|
33870
|
+
action: "interrupt",
|
|
33871
|
+
projectDir: context.projectDir,
|
|
33872
|
+
interrupt: { outcome: "targeted" }
|
|
33873
|
+
},
|
|
33874
|
+
content: [
|
|
33875
|
+
{
|
|
33876
|
+
type: "text",
|
|
33877
|
+
text: capToolResultText(
|
|
33878
|
+
`workspace ${context.projectDir}: interrupting the running eval \u2014 the eval-break signal is set; the eval's next execution (a settlement drain resuming its continuation, or a direct eval's drain) is broken mid-run by the quickjs interrupt handler`
|
|
33879
|
+
)
|
|
33880
|
+
}
|
|
33881
|
+
]
|
|
33882
|
+
};
|
|
33883
|
+
}
|
|
33884
|
+
const outcome = await broker.cancelCall(input.id);
|
|
33885
|
+
const text = outcome === "cancelled" ? `interrupt ${input.id}: ACP session/cancel sent` : outcome === "idle" ? `interrupt ${input.id}: the session was idle \u2014 nothing to cancel` : outcome === "failed" ? `interrupt ${input.id}: could not reach the backend session (lazy re-attach failed)` : `interrupt ${input.id}: no live session to cancel`;
|
|
33886
|
+
return {
|
|
33887
|
+
structuredContent: {
|
|
33888
|
+
action: "interrupt",
|
|
33889
|
+
projectDir: context.projectDir,
|
|
33890
|
+
interrupt: { outcome, callId: input.id }
|
|
33891
|
+
},
|
|
33892
|
+
content: [{ type: "text", text: capToolResultText(text) }]
|
|
33893
|
+
};
|
|
33894
|
+
}
|
|
33895
|
+
);
|
|
33896
|
+
}
|
|
33897
|
+
|
|
33898
|
+
// ../mcp-server/src/repl-presence.ts
|
|
33899
|
+
var ReplPresenceLedger = class {
|
|
33900
|
+
constructor(boundMs) {
|
|
33901
|
+
this.boundMs = boundMs;
|
|
33902
|
+
}
|
|
33903
|
+
boundMs;
|
|
33904
|
+
/** sessionId → the repl states that session has touched (RETAINED
|
|
33905
|
+
* across disconnects — the session's project affinity; dropped only
|
|
33906
|
+
* by `forget` when the session is deleted, see the module docs). */
|
|
33907
|
+
bySession = /* @__PURE__ */ new Map();
|
|
33908
|
+
/** repl state → the sessions currently present on it. */
|
|
33909
|
+
byProject = /* @__PURE__ */ new Map();
|
|
33910
|
+
/** repl states with a drain scheduled or running (single-flight). */
|
|
33911
|
+
draining = /* @__PURE__ */ new Set();
|
|
33912
|
+
/** The concrete drain bound (the daemon's session-eviction TTL). */
|
|
33913
|
+
drainBoundMs() {
|
|
33914
|
+
return this.boundMs;
|
|
33915
|
+
}
|
|
33916
|
+
/**
|
|
33917
|
+
* Mark an MCP session as present on a project's repl workspace (every
|
|
33918
|
+
* `repl` tool call from that session touches). Idempotent per
|
|
33919
|
+
* (session, project).
|
|
33920
|
+
*/
|
|
33921
|
+
touch(state, clientId) {
|
|
33922
|
+
let sessions = this.byProject.get(state);
|
|
33923
|
+
if (sessions === void 0) {
|
|
33924
|
+
sessions = /* @__PURE__ */ new Set();
|
|
33925
|
+
this.byProject.set(state, sessions);
|
|
33926
|
+
}
|
|
33927
|
+
sessions.add(clientId);
|
|
33928
|
+
touchReplProject(state, clientId);
|
|
33929
|
+
let projects = this.bySession.get(clientId);
|
|
33930
|
+
if (projects === void 0) {
|
|
33931
|
+
projects = /* @__PURE__ */ new Set();
|
|
33932
|
+
this.bySession.set(clientId, projects);
|
|
33933
|
+
}
|
|
33934
|
+
projects.add(state);
|
|
33935
|
+
}
|
|
33936
|
+
/**
|
|
33937
|
+
* Run when an MCP session's last connection closed (or the session was
|
|
33938
|
+
* deleted): remove its presence from every project it touched; a
|
|
33939
|
+
* project whose client set became EMPTY is drained (single-flight).
|
|
33940
|
+
* The session's project AFFINITY is retained (see the module docs) so
|
|
33941
|
+
* a reconnect of the same live session can restore its presence; the
|
|
33942
|
+
* drain decision reads the ledger's own per-project set (the
|
|
33943
|
+
* authoritative presence — the same set `touch`/`reconnect` maintain),
|
|
33944
|
+
* never a snapshot of the projects' `clients` sets.
|
|
33945
|
+
*/
|
|
33946
|
+
disconnect(clientId) {
|
|
33947
|
+
const projects = this.bySession.get(clientId);
|
|
33948
|
+
if (projects === void 0) return;
|
|
33949
|
+
for (const state of projects) {
|
|
33950
|
+
const sessions = this.byProject.get(state);
|
|
33951
|
+
let last = false;
|
|
33952
|
+
if (sessions !== void 0) {
|
|
33953
|
+
sessions.delete(clientId);
|
|
33954
|
+
if (sessions.size === 0) {
|
|
33955
|
+
this.byProject.delete(state);
|
|
33956
|
+
last = true;
|
|
33957
|
+
}
|
|
33958
|
+
}
|
|
33959
|
+
disconnectReplProject(state, clientId);
|
|
33960
|
+
if (last) this.scheduleDrain(state);
|
|
33961
|
+
}
|
|
33962
|
+
}
|
|
33963
|
+
/**
|
|
33964
|
+
* Run when a connection OPENS on a live session (the daemon's session
|
|
33965
|
+
* registry signals it — a reconnect of the SAME session after a
|
|
33966
|
+
* transient drop): restore the session's presence on every project it
|
|
33967
|
+
* retains affinity with (see the module docs). A project whose drain
|
|
33968
|
+
* was already scheduled or is mid-flight sees the re-added client and
|
|
33969
|
+
* skips/aborts it — children stay warm while any client is connected.
|
|
33970
|
+
*/
|
|
33971
|
+
reconnect(clientId) {
|
|
33972
|
+
const projects = this.bySession.get(clientId);
|
|
33973
|
+
if (projects === void 0) return;
|
|
33974
|
+
for (const state of projects) {
|
|
33975
|
+
this.touch(state, clientId);
|
|
33976
|
+
}
|
|
33977
|
+
}
|
|
33978
|
+
/**
|
|
33979
|
+
* Run when a session record is deleted (DELETE, transport close,
|
|
33980
|
+
* eviction): drop the session's retained project affinity. The
|
|
33981
|
+
* session can never reconnect; a re-initialized client carries a new
|
|
33982
|
+
* session id and re-touches projects through its tool calls. (The
|
|
33983
|
+
* registry fires `disconnect` BEFORE this — the presence removal and
|
|
33984
|
+
* drain evaluation walk the affinity.)
|
|
33985
|
+
*/
|
|
33986
|
+
forget(clientId) {
|
|
33987
|
+
this.bySession.delete(clientId);
|
|
33988
|
+
}
|
|
33989
|
+
/** Every project currently drained or draining (the status seam). */
|
|
33990
|
+
drainedProjects() {
|
|
33991
|
+
return [...this.draining];
|
|
33992
|
+
}
|
|
33993
|
+
/** Test seam: how many projects are mid-drain right now. */
|
|
33994
|
+
drainingCount() {
|
|
33995
|
+
return this.draining.size;
|
|
33996
|
+
}
|
|
33997
|
+
/** Drop every session's presence (daemon shutdown): the scheduled
|
|
33998
|
+
* drains see the projects' client sets emptied and run to completion
|
|
33999
|
+
* on the already-disposed brokers — a no-op there, cleared here. */
|
|
34000
|
+
disconnectAll() {
|
|
34001
|
+
for (const clientId of [...this.bySession.keys()]) this.disconnect(clientId);
|
|
34002
|
+
}
|
|
34003
|
+
scheduleDrain(state) {
|
|
34004
|
+
if (this.draining.has(state)) return;
|
|
34005
|
+
this.draining.add(state);
|
|
34006
|
+
void drainReplProject(state, this.boundMs).catch(() => {
|
|
34007
|
+
}).finally(() => {
|
|
34008
|
+
this.draining.delete(state);
|
|
34009
|
+
});
|
|
34010
|
+
}
|
|
34011
|
+
};
|
|
34012
|
+
|
|
34013
|
+
// ../mcp-server/src/daemon/constants.ts
|
|
34014
|
+
var DAEMON_NAME = "agentprism-daemon";
|
|
34015
|
+
var MCP_ENDPOINT_PATH = "/mcp";
|
|
34016
|
+
var HEALTHZ_PATH = "/healthz";
|
|
34017
|
+
var DEFAULT_DAEMON_PORT = 29888;
|
|
34018
|
+
var DAEMON_PORT_ENV = "AGENTPRISM_DAEMON_PORT";
|
|
34019
|
+
var DAEMON_ALLOWED_ORIGINS_ENV = "AGENTPRISM_DAEMON_ALLOWED_ORIGINS";
|
|
34020
|
+
var DAEMON_IDLE_TTL_MS = 15 * 6e4;
|
|
34021
|
+
var DAEMON_IDLE_TTL_ENV = "AGENTPRISM_DAEMON_IDLE_TTL_MS";
|
|
34022
|
+
var SESSION_IDLE_TTL_MS = 2 * 60 * 6e4;
|
|
34023
|
+
var SESSION_IDLE_TTL_ENV = "AGENTPRISM_SESSION_TTL_MS";
|
|
34024
|
+
var REAPER_INTERVAL_MS = 6e4;
|
|
34025
|
+
var EVENT_STORE_MAX_EVENTS_PER_STREAM = 1e3;
|
|
34026
|
+
var EVENT_STORE_MAX_TOTAL_EVENTS = 1e4;
|
|
34027
|
+
var SPAWN_HEALTH_TIMEOUT_MS = 1e4;
|
|
34028
|
+
|
|
32800
34029
|
// ../mcp-server/src/workflow-resources.ts
|
|
32801
34030
|
import {
|
|
32802
34031
|
RUN_EVENT_READ_LIMIT_DEFAULT,
|
|
@@ -33680,7 +34909,7 @@ function addInspectionResourceFields(status, fields, retention) {
|
|
|
33680
34909
|
).length;
|
|
33681
34910
|
};
|
|
33682
34911
|
refreshCounters();
|
|
33683
|
-
const
|
|
34912
|
+
const structuredBytes2 = () => Buffer.byteLength(JSON.stringify(projected), "utf8");
|
|
33684
34913
|
const mandatoryEnvelope = {
|
|
33685
34914
|
...projected,
|
|
33686
34915
|
calls: [],
|
|
@@ -33693,13 +34922,13 @@ function addInspectionResourceFields(status, fields, retention) {
|
|
|
33693
34922
|
previousLimit = projected.truncation.maxStructuredBytes;
|
|
33694
34923
|
projected.truncation.maxStructuredBytes = Math.max(
|
|
33695
34924
|
MAX_INSPECTION_STRUCTURED_BYTES,
|
|
33696
|
-
|
|
34925
|
+
structuredBytes2()
|
|
33697
34926
|
);
|
|
33698
34927
|
}
|
|
33699
34928
|
return projected;
|
|
33700
34929
|
}
|
|
33701
34930
|
projected.truncation.maxStructuredBytes = MAX_INSPECTION_STRUCTURED_BYTES;
|
|
33702
|
-
const tooLarge = () =>
|
|
34931
|
+
const tooLarge = () => structuredBytes2() > MAX_INSPECTION_STRUCTURED_BYTES;
|
|
33703
34932
|
while (projected.calls.length > 0 && tooLarge()) {
|
|
33704
34933
|
projected.calls.shift();
|
|
33705
34934
|
refreshCounters();
|
|
@@ -34025,12 +35254,31 @@ function formatAwaitSummary(result) {
|
|
|
34025
35254
|
lines.push(...diagnostics);
|
|
34026
35255
|
return truncateUtf82(lines.join("\n"), 8192, "\u2026[text truncated]");
|
|
34027
35256
|
}
|
|
35257
|
+
function replEvalTimeoutMs() {
|
|
35258
|
+
const env = process.env.AGENTPRISM_REPL_EVAL_TIMEOUT_MS;
|
|
35259
|
+
if (env !== void 0) {
|
|
35260
|
+
const parsed = Number.parseInt(env, 10);
|
|
35261
|
+
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
|
|
35262
|
+
}
|
|
35263
|
+
return DEFAULT_REPL_EVAL_TIMEOUT_MS;
|
|
35264
|
+
}
|
|
34028
35265
|
function createWorkflowServer(runner, options = {}) {
|
|
34029
35266
|
const mcp = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
34030
35267
|
let acceptingWork = true;
|
|
35268
|
+
const ownsReplEvalBreakChannel = options.replEvalBreakChannel === void 0;
|
|
35269
|
+
const replEvalBreakChannel = options.replEvalBreakChannel ?? createEvalBreakChannel();
|
|
34031
35270
|
const server = Object.assign(mcp, {
|
|
34032
35271
|
stopAcceptingWork() {
|
|
34033
35272
|
acceptingWork = false;
|
|
35273
|
+
},
|
|
35274
|
+
replBreakUrl() {
|
|
35275
|
+
return replEvalBreakChannel.breakUrl();
|
|
35276
|
+
},
|
|
35277
|
+
replDefaultProjectDir() {
|
|
35278
|
+
return projects.stores()[0]?.projectDir;
|
|
35279
|
+
},
|
|
35280
|
+
async disposeReplEvalBreakChannel() {
|
|
35281
|
+
if (ownsReplEvalBreakChannel) await replEvalBreakChannel.dispose();
|
|
34034
35282
|
}
|
|
34035
35283
|
});
|
|
34036
35284
|
mcp.server.registerCapabilities({
|
|
@@ -34042,7 +35290,8 @@ function createWorkflowServer(runner, options = {}) {
|
|
|
34042
35290
|
const defaultContext = requireProjectDir ? void 0 : projects.adopt(options.manager ?? new WorkflowManager3({ agent: runner }), options.backgroundRuns);
|
|
34043
35291
|
const scriptResources = new WorkflowScriptResources(mcp, { router: projects });
|
|
34044
35292
|
const backendApprovals = /* @__PURE__ */ new Set();
|
|
34045
|
-
const
|
|
35293
|
+
const replPresence = options.replPresence ?? new ReplPresenceLedger(options.replDrainBoundMs ?? SESSION_IDLE_TTL_MS);
|
|
35294
|
+
const resolveContext2 = (input) => {
|
|
34046
35295
|
if (input.action === "inspect" || input.action === "await" || input.action === "stop") {
|
|
34047
35296
|
return projects.storeFor(input.runId) ?? defaultContext;
|
|
34048
35297
|
}
|
|
@@ -34056,6 +35305,17 @@ function createWorkflowServer(runner, options = {}) {
|
|
|
34056
35305
|
return defaultContext;
|
|
34057
35306
|
};
|
|
34058
35307
|
registerAuthoringPrompt(mcp);
|
|
35308
|
+
registerReplTool(mcp, {
|
|
35309
|
+
projects,
|
|
35310
|
+
wasm: loadShippedWasm(),
|
|
35311
|
+
requireProjectDir,
|
|
35312
|
+
runner: options.replRunner,
|
|
35313
|
+
evalTimeoutMs: replEvalTimeoutMs(),
|
|
35314
|
+
presence: replPresence,
|
|
35315
|
+
clientId: options.replClientId ?? (() => "single-project"),
|
|
35316
|
+
evalBreakChannel: replEvalBreakChannel,
|
|
35317
|
+
acceptingWork: () => acceptingWork
|
|
35318
|
+
});
|
|
34059
35319
|
registerWorkflowAppUi(mcp, {
|
|
34060
35320
|
readEventsPage: (request) => scriptResources.readEventsPage(request),
|
|
34061
35321
|
registerResourceReader: (uri, read) => scriptResources.registerExternalResourceReader(uri, read)
|
|
@@ -34078,7 +35338,7 @@ function createWorkflowServer(runner, options = {}) {
|
|
|
34078
35338
|
);
|
|
34079
35339
|
}
|
|
34080
35340
|
const parsedInput = parseWorkflowToolInput(args, { requireProjectDir });
|
|
34081
|
-
const context =
|
|
35341
|
+
const context = resolveContext2(parsedInput);
|
|
34082
35342
|
if (context === void 0) {
|
|
34083
35343
|
return {
|
|
34084
35344
|
content: [
|
|
@@ -34090,6 +35350,8 @@ function createWorkflowServer(runner, options = {}) {
|
|
|
34090
35350
|
isError: true
|
|
34091
35351
|
};
|
|
34092
35352
|
}
|
|
35353
|
+
if (context.repl === void 0) context.repl = createReplProjectState(context.projectDir);
|
|
35354
|
+
replPresence.touch(context.repl, options.replClientId?.() ?? "unknown");
|
|
34093
35355
|
const manager = context.manager;
|
|
34094
35356
|
const backgroundRuns = context.backgroundRuns;
|
|
34095
35357
|
if ((parsedInput.action === void 0 || parsedInput.action === "run") && parsedInput.resumeFromRunId !== void 0) {
|
|
@@ -34548,22 +35810,6 @@ import { homedir } from "node:os";
|
|
|
34548
35810
|
import { dirname as dirname2 } from "node:path";
|
|
34549
35811
|
import { spawn } from "node:child_process";
|
|
34550
35812
|
|
|
34551
|
-
// ../mcp-server/src/daemon/constants.ts
|
|
34552
|
-
var DAEMON_NAME = "agentprism-daemon";
|
|
34553
|
-
var MCP_ENDPOINT_PATH = "/mcp";
|
|
34554
|
-
var HEALTHZ_PATH = "/healthz";
|
|
34555
|
-
var DEFAULT_DAEMON_PORT = 29888;
|
|
34556
|
-
var DAEMON_PORT_ENV = "AGENTPRISM_DAEMON_PORT";
|
|
34557
|
-
var DAEMON_ALLOWED_ORIGINS_ENV = "AGENTPRISM_DAEMON_ALLOWED_ORIGINS";
|
|
34558
|
-
var DAEMON_IDLE_TTL_MS = 15 * 6e4;
|
|
34559
|
-
var DAEMON_IDLE_TTL_ENV = "AGENTPRISM_DAEMON_IDLE_TTL_MS";
|
|
34560
|
-
var SESSION_IDLE_TTL_MS = 2 * 60 * 6e4;
|
|
34561
|
-
var SESSION_IDLE_TTL_ENV = "AGENTPRISM_SESSION_TTL_MS";
|
|
34562
|
-
var REAPER_INTERVAL_MS = 6e4;
|
|
34563
|
-
var EVENT_STORE_MAX_EVENTS_PER_STREAM = 1e3;
|
|
34564
|
-
var EVENT_STORE_MAX_TOTAL_EVENTS = 1e4;
|
|
34565
|
-
var SPAWN_HEALTH_TIMEOUT_MS = 1e4;
|
|
34566
|
-
|
|
34567
35813
|
// ../mcp-server/src/daemon/daemon-info.ts
|
|
34568
35814
|
import { randomUUID } from "node:crypto";
|
|
34569
35815
|
import { createHash } from "node:crypto";
|
|
@@ -34770,90 +36016,6 @@ async function ensureDaemonRunning(options) {
|
|
|
34770
36016
|
// ../mcp-server/src/daemon/run-daemon.ts
|
|
34771
36017
|
import { createAcpRunner } from "@automatalabs/workflows";
|
|
34772
36018
|
|
|
34773
|
-
// ../mcp-server/src/lifecycle.ts
|
|
34774
|
-
var SHUTDOWN_DEADLINE_MS = 5e3;
|
|
34775
|
-
function isDisposableRunner(runner) {
|
|
34776
|
-
return "dispose" in runner && typeof runner.dispose === "function";
|
|
34777
|
-
}
|
|
34778
|
-
function isForceKillableRunner(runner) {
|
|
34779
|
-
return "forceKill" in runner && typeof runner.forceKill === "function";
|
|
34780
|
-
}
|
|
34781
|
-
function exitCodeFor(reason) {
|
|
34782
|
-
if (reason === "SIGINT") return 130;
|
|
34783
|
-
if (reason === "SIGTERM") return 143;
|
|
34784
|
-
return 0;
|
|
34785
|
-
}
|
|
34786
|
-
async function disposeRunnerWithDeadline(runner, deadlineMs = SHUTDOWN_DEADLINE_MS) {
|
|
34787
|
-
const dispose = isDisposableRunner(runner) ? Promise.resolve().then(() => runner.dispose()).catch(() => void 0) : Promise.resolve();
|
|
34788
|
-
let deadlineTimer;
|
|
34789
|
-
const deadline = new Promise((resolve) => {
|
|
34790
|
-
deadlineTimer = setTimeout(() => {
|
|
34791
|
-
if (isForceKillableRunner(runner)) {
|
|
34792
|
-
try {
|
|
34793
|
-
runner.forceKill();
|
|
34794
|
-
} catch {
|
|
34795
|
-
}
|
|
34796
|
-
}
|
|
34797
|
-
resolve();
|
|
34798
|
-
}, deadlineMs);
|
|
34799
|
-
});
|
|
34800
|
-
await Promise.race([dispose, deadline]);
|
|
34801
|
-
if (deadlineTimer !== void 0) clearTimeout(deadlineTimer);
|
|
34802
|
-
}
|
|
34803
|
-
function installMcpServerLifecycle(options) {
|
|
34804
|
-
const processHandle = options.process ?? process;
|
|
34805
|
-
const deadlineMs = options.deadlineMs ?? SHUTDOWN_DEADLINE_MS;
|
|
34806
|
-
let shutdownPromise;
|
|
34807
|
-
let shuttingDown = false;
|
|
34808
|
-
const onStdinClose = () => {
|
|
34809
|
-
void lifecycle.shutdown("stdin-close");
|
|
34810
|
-
};
|
|
34811
|
-
const onStdinEnd = () => {
|
|
34812
|
-
void lifecycle.shutdown("stdin-end");
|
|
34813
|
-
};
|
|
34814
|
-
const onSigint = () => {
|
|
34815
|
-
void lifecycle.shutdown("SIGINT");
|
|
34816
|
-
};
|
|
34817
|
-
const onSigterm = () => {
|
|
34818
|
-
void lifecycle.shutdown("SIGTERM");
|
|
34819
|
-
};
|
|
34820
|
-
const previousTransportOnClose = options.transport.onclose;
|
|
34821
|
-
const onTransportClose = () => {
|
|
34822
|
-
try {
|
|
34823
|
-
previousTransportOnClose?.();
|
|
34824
|
-
} catch {
|
|
34825
|
-
}
|
|
34826
|
-
void lifecycle.shutdown("transport-close");
|
|
34827
|
-
};
|
|
34828
|
-
const removeListeners = () => {
|
|
34829
|
-
processHandle.stdin.removeListener("close", onStdinClose);
|
|
34830
|
-
processHandle.stdin.removeListener("end", onStdinEnd);
|
|
34831
|
-
processHandle.removeListener("SIGINT", onSigint);
|
|
34832
|
-
processHandle.removeListener("SIGTERM", onSigterm);
|
|
34833
|
-
};
|
|
34834
|
-
const lifecycle = {
|
|
34835
|
-
shutdown(reason) {
|
|
34836
|
-
if (shutdownPromise) return shutdownPromise;
|
|
34837
|
-
shuttingDown = true;
|
|
34838
|
-
options.server.stopAcceptingWork();
|
|
34839
|
-
shutdownPromise = disposeRunnerWithDeadline(options.runner, deadlineMs).then(() => {
|
|
34840
|
-
removeListeners();
|
|
34841
|
-
processHandle.exit(exitCodeFor(reason));
|
|
34842
|
-
});
|
|
34843
|
-
return shutdownPromise;
|
|
34844
|
-
},
|
|
34845
|
-
isShuttingDown() {
|
|
34846
|
-
return shuttingDown;
|
|
34847
|
-
}
|
|
34848
|
-
};
|
|
34849
|
-
options.transport.onclose = onTransportClose;
|
|
34850
|
-
processHandle.stdin.once("close", onStdinClose);
|
|
34851
|
-
processHandle.stdin.once("end", onStdinEnd);
|
|
34852
|
-
processHandle.once("SIGINT", onSigint);
|
|
34853
|
-
processHandle.once("SIGTERM", onSigterm);
|
|
34854
|
-
return lifecycle;
|
|
34855
|
-
}
|
|
34856
|
-
|
|
34857
36019
|
// ../mcp-server/src/daemon/daemon-lifecycle.ts
|
|
34858
36020
|
function exitCodeFor2(reason) {
|
|
34859
36021
|
if (reason === "SIGINT") return 130;
|
|
@@ -34873,14 +36035,14 @@ function installDaemonLifecycle(options) {
|
|
|
34873
36035
|
log(`[agentprism-daemon] evicted ${evicted.length} idle session(s): ${evicted.join(", ")}`);
|
|
34874
36036
|
}
|
|
34875
36037
|
if (options.idleTtlMs <= 0) return;
|
|
34876
|
-
const busy = options.daemon.sessions.size > 0 || options.daemon.activeRunCount() > 0;
|
|
36038
|
+
const busy = options.daemon.sessions.size > 0 || options.daemon.activeRunCount() > 0 || options.daemon.activeReplDrainCount() > 0;
|
|
34877
36039
|
if (busy) {
|
|
34878
36040
|
idleSince = void 0;
|
|
34879
36041
|
return;
|
|
34880
36042
|
}
|
|
34881
36043
|
idleSince ??= Date.now();
|
|
34882
36044
|
if (Date.now() - idleSince >= options.idleTtlMs) {
|
|
34883
|
-
log(`[agentprism-daemon] idle for ${options.idleTtlMs}ms with no sessions or
|
|
36045
|
+
log(`[agentprism-daemon] idle for ${options.idleTtlMs}ms with no sessions, runs, or repl drains; shutting down`);
|
|
34884
36046
|
void lifecycle.shutdown("idle");
|
|
34885
36047
|
}
|
|
34886
36048
|
}, options.reaperIntervalMs ?? REAPER_INTERVAL_MS);
|
|
@@ -36360,6 +37522,40 @@ function validateRequest(headers, boundPort, env = process.env) {
|
|
|
36360
37522
|
// ../mcp-server/src/daemon/session-registry.ts
|
|
36361
37523
|
var SessionRegistry = class {
|
|
36362
37524
|
sessions = /* @__PURE__ */ new Map();
|
|
37525
|
+
/**
|
|
37526
|
+
* Fired when a connection OPENS on a live session — the daemon's
|
|
37527
|
+
* client-RECONNECT signal. The daemon wires it to the REPL presence
|
|
37528
|
+
* ledger, which re-adds the session's project presence from its
|
|
37529
|
+
* retained affinity (see `repl-presence.ts`): a transient GET drop
|
|
37530
|
+
* followed by a reconnect of the SAME session must not leave the
|
|
37531
|
+
* session's projects draining while the client is connected
|
|
37532
|
+
* (phase-E review rejection: only disconnects were wired, so a
|
|
37533
|
+
* reconnect did not restore presence until the client's next tool
|
|
37534
|
+
* call — the already-scheduled drain could close children while that
|
|
37535
|
+
* client was connected).
|
|
37536
|
+
*/
|
|
37537
|
+
onConnectionOpened;
|
|
37538
|
+
/**
|
|
37539
|
+
* Fired when a session's LAST open connection closed (or the session
|
|
37540
|
+
* was deleted outright) — the daemon's client-presence signal. The
|
|
37541
|
+
* daemon wires it to the REPL presence ledger, which drains projects
|
|
37542
|
+
* whose client set became empty (the roadmap doc's last-client-
|
|
37543
|
+
* disconnect drain; phase-D review round 2: the registry used to
|
|
37544
|
+
* maintain connection counts without ever signaling project REPL
|
|
37545
|
+
* lifecycle).
|
|
37546
|
+
*/
|
|
37547
|
+
onLastConnectionClosed;
|
|
37548
|
+
/**
|
|
37549
|
+
* Fired when the session record is deleted (DELETE, transport close,
|
|
37550
|
+
* eviction) — the daemon's session-GONE signal. The daemon wires it
|
|
37551
|
+
* to the REPL presence ledger, which drops the session's retained
|
|
37552
|
+
* project affinity (the session can never reconnect; a re-initialized
|
|
37553
|
+
* client carries a new session id). Fired AFTER
|
|
37554
|
+
* `onLastConnectionClosed` (the disconnect's drain evaluation needs
|
|
37555
|
+
* the affinity to remove the session's presence from its projects
|
|
37556
|
+
* first).
|
|
37557
|
+
*/
|
|
37558
|
+
onSessionDeleted;
|
|
36363
37559
|
add(record2) {
|
|
36364
37560
|
this.sessions.set(record2.sessionId, record2);
|
|
36365
37561
|
}
|
|
@@ -36368,6 +37564,8 @@ var SessionRegistry = class {
|
|
|
36368
37564
|
}
|
|
36369
37565
|
delete(sessionId) {
|
|
36370
37566
|
this.sessions.delete(sessionId);
|
|
37567
|
+
this.onLastConnectionClosed?.(sessionId);
|
|
37568
|
+
this.onSessionDeleted?.(sessionId);
|
|
36371
37569
|
}
|
|
36372
37570
|
touch(sessionId, now = Date.now()) {
|
|
36373
37571
|
const record2 = this.sessions.get(sessionId);
|
|
@@ -36378,12 +37576,16 @@ var SessionRegistry = class {
|
|
|
36378
37576
|
if (record2 === void 0) return;
|
|
36379
37577
|
record2.openConnections++;
|
|
36380
37578
|
record2.lastActivityAt = Date.now();
|
|
37579
|
+
this.onConnectionOpened?.(sessionId);
|
|
36381
37580
|
}
|
|
36382
37581
|
connectionClosed(sessionId) {
|
|
36383
37582
|
const record2 = this.sessions.get(sessionId);
|
|
36384
37583
|
if (record2 === void 0) return;
|
|
36385
37584
|
record2.openConnections = Math.max(0, record2.openConnections - 1);
|
|
36386
37585
|
record2.lastActivityAt = Date.now();
|
|
37586
|
+
if (record2.openConnections === 0) {
|
|
37587
|
+
this.onLastConnectionClosed?.(sessionId);
|
|
37588
|
+
}
|
|
36387
37589
|
}
|
|
36388
37590
|
get size() {
|
|
36389
37591
|
return this.sessions.size;
|
|
@@ -36429,6 +37631,10 @@ async function createDaemon(options) {
|
|
|
36429
37631
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
36430
37632
|
const sessions = new SessionRegistry();
|
|
36431
37633
|
const projects = new WorkflowProjectRegistry(options.runner);
|
|
37634
|
+
const replPresence = new ReplPresenceLedger(options.sessionTtlMs ?? SESSION_IDLE_TTL_MS);
|
|
37635
|
+
sessions.onConnectionOpened = (sessionId) => replPresence.reconnect(sessionId);
|
|
37636
|
+
sessions.onLastConnectionClosed = (sessionId) => replPresence.disconnect(sessionId);
|
|
37637
|
+
sessions.onSessionDeleted = (sessionId) => replPresence.forget(sessionId);
|
|
36432
37638
|
let boundPort = options.port;
|
|
36433
37639
|
const handleMcpRequest = async (req, res) => {
|
|
36434
37640
|
const sessionHeader = req.headers["mcp-session-id"];
|
|
@@ -36462,7 +37668,12 @@ async function createDaemon(options) {
|
|
|
36462
37668
|
});
|
|
36463
37669
|
const server = createWorkflowServer(options.runner, {
|
|
36464
37670
|
projects,
|
|
36465
|
-
requireProjectDir: true
|
|
37671
|
+
requireProjectDir: true,
|
|
37672
|
+
replRunner: options.replRunner,
|
|
37673
|
+
replPresence,
|
|
37674
|
+
replClientId: () => transport.sessionId,
|
|
37675
|
+
replDrainBoundMs: options.sessionTtlMs ?? SESSION_IDLE_TTL_MS,
|
|
37676
|
+
replEvalBreakChannel: options.evalBreakChannel
|
|
36466
37677
|
});
|
|
36467
37678
|
await server.connect(transport);
|
|
36468
37679
|
const protocolOnClose = transport.onclose;
|
|
@@ -36535,10 +37746,13 @@ async function createDaemon(options) {
|
|
|
36535
37746
|
sessions,
|
|
36536
37747
|
projects,
|
|
36537
37748
|
activeRunCount: () => projects.activeRunCount(),
|
|
37749
|
+
activeReplDrainCount: () => replPresence.drainingCount(),
|
|
36538
37750
|
async close() {
|
|
36539
37751
|
const closed = new Promise((resolvePromise) => {
|
|
36540
37752
|
httpServer.close(() => resolvePromise());
|
|
36541
37753
|
});
|
|
37754
|
+
await projects.disposeReplStates();
|
|
37755
|
+
replPresence.disconnectAll();
|
|
36542
37756
|
await sessions.closeAll();
|
|
36543
37757
|
httpServer.closeAllConnections();
|
|
36544
37758
|
await closed;
|
|
@@ -36547,6 +37761,7 @@ async function createDaemon(options) {
|
|
|
36547
37761
|
}
|
|
36548
37762
|
|
|
36549
37763
|
// ../mcp-server/src/daemon/run-daemon.ts
|
|
37764
|
+
import { createEvalBreakChannel as createEvalBreakChannel2 } from "@automatalabs/repl-engine";
|
|
36550
37765
|
function envInt(name, fallback) {
|
|
36551
37766
|
const raw = process.env[name];
|
|
36552
37767
|
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
@@ -36567,8 +37782,10 @@ async function runDaemon(options = {}) {
|
|
|
36567
37782
|
const port = resolveDaemonPort(options.port);
|
|
36568
37783
|
const runner = createAcpRunner();
|
|
36569
37784
|
let daemon;
|
|
37785
|
+
const sessionTtlMs = envInt(SESSION_IDLE_TTL_ENV, SESSION_IDLE_TTL_MS);
|
|
37786
|
+
const evalBreakChannel = createEvalBreakChannel2();
|
|
36570
37787
|
try {
|
|
36571
|
-
daemon = await createDaemon({ runner, port, log });
|
|
37788
|
+
daemon = await createDaemon({ runner, port, log, sessionTtlMs, evalBreakChannel });
|
|
36572
37789
|
} catch (error51) {
|
|
36573
37790
|
if (!(error51 instanceof DaemonPortInUseError)) throw error51;
|
|
36574
37791
|
if (await ownDaemonAlreadyRunning()) {
|
|
@@ -36576,7 +37793,7 @@ async function runDaemon(options = {}) {
|
|
|
36576
37793
|
return "already-running";
|
|
36577
37794
|
}
|
|
36578
37795
|
log(`[${DAEMON_NAME}] port ${port} is taken by another process; falling back to an ephemeral port`);
|
|
36579
|
-
daemon = await createDaemon({ runner, port: 0, log });
|
|
37796
|
+
daemon = await createDaemon({ runner, port: 0, log, sessionTtlMs, evalBreakChannel });
|
|
36580
37797
|
}
|
|
36581
37798
|
writeDaemonInfo({
|
|
36582
37799
|
name: DAEMON_NAME,
|
|
@@ -36585,7 +37802,8 @@ async function runDaemon(options = {}) {
|
|
|
36585
37802
|
port: daemon.port,
|
|
36586
37803
|
url: daemon.url,
|
|
36587
37804
|
startedAt: daemon.startedAt,
|
|
36588
|
-
envFingerprint: envFingerprint()
|
|
37805
|
+
envFingerprint: envFingerprint(),
|
|
37806
|
+
...await evalBreakChannel.breakUrl().then((url2) => ({ replBreakUrl: url2 })).catch(() => ({}))
|
|
36589
37807
|
});
|
|
36590
37808
|
installDaemonLifecycle({
|
|
36591
37809
|
daemon,
|
|
@@ -36710,6 +37928,10 @@ async function runDaemonCommand(args, options) {
|
|
|
36710
37928
|
}
|
|
36711
37929
|
}
|
|
36712
37930
|
|
|
37931
|
+
// ../mcp-server/src/shim/shim.ts
|
|
37932
|
+
import { realpathSync as realpathSync2 } from "node:fs";
|
|
37933
|
+
import { isAbsolute as isAbsolute4 } from "node:path";
|
|
37934
|
+
|
|
36713
37935
|
// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@4.4.3/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
|
|
36714
37936
|
import process3 from "node:process";
|
|
36715
37937
|
|
|
@@ -38279,6 +39501,24 @@ function initializeResultProtocolVersion(message) {
|
|
|
38279
39501
|
async function runShim(options) {
|
|
38280
39502
|
const log = (line) => console.error(line);
|
|
38281
39503
|
const info = await ensureDaemonRunning({ bundlePath: options.bundlePath, port: options.port, log });
|
|
39504
|
+
let replBreakUrl = info.replBreakUrl;
|
|
39505
|
+
function fireOutOfBandBreak(projectDir) {
|
|
39506
|
+
if (typeof projectDir !== "string" || replBreakUrl === void 0) return;
|
|
39507
|
+
let key;
|
|
39508
|
+
try {
|
|
39509
|
+
if (!isAbsolute4(projectDir)) return;
|
|
39510
|
+
key = realpathSync2(projectDir);
|
|
39511
|
+
} catch {
|
|
39512
|
+
return;
|
|
39513
|
+
}
|
|
39514
|
+
void fetch(replBreakUrl, {
|
|
39515
|
+
method: "POST",
|
|
39516
|
+
headers: { "content-type": "application/json" },
|
|
39517
|
+
body: JSON.stringify({ key }),
|
|
39518
|
+
signal: AbortSignal.timeout(1e3)
|
|
39519
|
+
}).catch(() => {
|
|
39520
|
+
});
|
|
39521
|
+
}
|
|
38282
39522
|
const stdio = new StdioServerTransport();
|
|
38283
39523
|
let cachedInitialize;
|
|
38284
39524
|
let clientInitializeId;
|
|
@@ -38343,6 +39583,7 @@ async function runShim(options) {
|
|
|
38343
39583
|
try {
|
|
38344
39584
|
await http2.close().catch(() => void 0);
|
|
38345
39585
|
const fresh = await ensureDaemonRunning({ bundlePath: options.bundlePath, port: options.port, log });
|
|
39586
|
+
replBreakUrl = fresh.replBreakUrl;
|
|
38346
39587
|
http2 = makeHttpTransport(fresh.url);
|
|
38347
39588
|
await http2.start();
|
|
38348
39589
|
pendingReinitId = `__shim_reinit_${++reinitCounter}__`;
|
|
@@ -38393,6 +39634,14 @@ async function runShim(options) {
|
|
|
38393
39634
|
if (message.method === "resources/subscribe") subscribedUris.add(uri);
|
|
38394
39635
|
else subscribedUris.delete(uri);
|
|
38395
39636
|
}
|
|
39637
|
+
} else if (message.method === "tools/call") {
|
|
39638
|
+
const params = message.params;
|
|
39639
|
+
if (params?.name === "repl") {
|
|
39640
|
+
const args = params.arguments ?? {};
|
|
39641
|
+
if (args.action === "interrupt" && args.id === void 0) {
|
|
39642
|
+
fireOutOfBandBreak(args.projectDir);
|
|
39643
|
+
}
|
|
39644
|
+
}
|
|
38396
39645
|
}
|
|
38397
39646
|
}
|
|
38398
39647
|
void pumpSend(message);
|
|
@@ -38414,13 +39663,90 @@ async function runShim(options) {
|
|
|
38414
39663
|
}
|
|
38415
39664
|
|
|
38416
39665
|
// ../mcp-server/src/index.ts
|
|
38417
|
-
import { realpathSync as
|
|
39666
|
+
import { realpathSync as realpathSync3 } from "node:fs";
|
|
38418
39667
|
import { pathToFileURL } from "node:url";
|
|
38419
39668
|
import { createAcpRunner as createAcpRunner2 } from "@automatalabs/workflows";
|
|
39669
|
+
|
|
39670
|
+
// ../mcp-server/src/repl-stdio-transport.ts
|
|
39671
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
39672
|
+
import { fileURLToPath } from "node:url";
|
|
39673
|
+
import { Worker } from "node:worker_threads";
|
|
39674
|
+
var EOF_MARKER = "\0__repl_stdio_eof__\0";
|
|
39675
|
+
function relayWorkerEntryUrl() {
|
|
39676
|
+
const tsEntry = new URL("./repl-stdio-relay-worker.ts", import.meta.url);
|
|
39677
|
+
if (existsSync2(fileURLToPath(tsEntry))) return tsEntry;
|
|
39678
|
+
return new URL("./repl-stdio-relay-worker.js", import.meta.url);
|
|
39679
|
+
}
|
|
39680
|
+
var ReplRelayStdioTransport = class {
|
|
39681
|
+
onclose;
|
|
39682
|
+
onerror;
|
|
39683
|
+
onmessage;
|
|
39684
|
+
breakUrlSource;
|
|
39685
|
+
defaultProjectKeySource;
|
|
39686
|
+
stdout;
|
|
39687
|
+
worker;
|
|
39688
|
+
started = false;
|
|
39689
|
+
closed = false;
|
|
39690
|
+
constructor(breakUrlSource, defaultProjectKeySource = () => void 0, stdout = process.stdout) {
|
|
39691
|
+
this.breakUrlSource = breakUrlSource;
|
|
39692
|
+
this.defaultProjectKeySource = defaultProjectKeySource;
|
|
39693
|
+
this.stdout = stdout;
|
|
39694
|
+
}
|
|
39695
|
+
async start() {
|
|
39696
|
+
if (this.started) return;
|
|
39697
|
+
this.started = true;
|
|
39698
|
+
const [breakUrl, defaultProjectKey] = await Promise.all([
|
|
39699
|
+
this.breakUrlSource().catch(() => void 0),
|
|
39700
|
+
Promise.resolve(this.defaultProjectKeySource())
|
|
39701
|
+
]);
|
|
39702
|
+
if (this.closed) return;
|
|
39703
|
+
this.worker = new Worker(relayWorkerEntryUrl(), {
|
|
39704
|
+
workerData: { breakUrl, defaultProjectKey }
|
|
39705
|
+
});
|
|
39706
|
+
this.worker.unref();
|
|
39707
|
+
this.worker.on("message", (payload) => {
|
|
39708
|
+
if (payload === EOF_MARKER) {
|
|
39709
|
+
void this.close();
|
|
39710
|
+
return;
|
|
39711
|
+
}
|
|
39712
|
+
try {
|
|
39713
|
+
this.onmessage?.(JSON.parse(payload));
|
|
39714
|
+
} catch (error51) {
|
|
39715
|
+
this.onerror?.(error51 instanceof Error ? error51 : new Error(String(error51)));
|
|
39716
|
+
}
|
|
39717
|
+
});
|
|
39718
|
+
this.worker.on("error", (error51) => this.onerror?.(error51));
|
|
39719
|
+
this.worker.on("exit", () => {
|
|
39720
|
+
if (!this.closed) void this.close();
|
|
39721
|
+
});
|
|
39722
|
+
}
|
|
39723
|
+
async send(message) {
|
|
39724
|
+
if (this.stdout.write(`${JSON.stringify(message)}
|
|
39725
|
+
`)) return;
|
|
39726
|
+
await new Promise((resolve) => {
|
|
39727
|
+
this.stdout.once("drain", () => resolve());
|
|
39728
|
+
});
|
|
39729
|
+
}
|
|
39730
|
+
async close() {
|
|
39731
|
+
if (this.closed) return;
|
|
39732
|
+
this.closed = true;
|
|
39733
|
+
const worker = this.worker;
|
|
39734
|
+
this.worker = void 0;
|
|
39735
|
+
if (worker !== void 0) {
|
|
39736
|
+
await worker.terminate().catch(() => void 0);
|
|
39737
|
+
}
|
|
39738
|
+
this.onclose?.();
|
|
39739
|
+
}
|
|
39740
|
+
};
|
|
39741
|
+
|
|
39742
|
+
// ../mcp-server/src/index.ts
|
|
38420
39743
|
async function main() {
|
|
38421
39744
|
const runner = createAcpRunner2();
|
|
38422
39745
|
const server = createWorkflowServer(runner);
|
|
38423
|
-
const transport = new
|
|
39746
|
+
const transport = new ReplRelayStdioTransport(
|
|
39747
|
+
() => server.replBreakUrl(),
|
|
39748
|
+
() => server.replDefaultProjectDir?.()
|
|
39749
|
+
);
|
|
38424
39750
|
await server.connect(transport);
|
|
38425
39751
|
installMcpServerLifecycle({ runner, server, transport });
|
|
38426
39752
|
}
|
|
@@ -38429,7 +39755,7 @@ function isProcessEntryPoint() {
|
|
|
38429
39755
|
const invokedPath = process.argv[1];
|
|
38430
39756
|
if (invokedPath === void 0) return false;
|
|
38431
39757
|
try {
|
|
38432
|
-
return import.meta.url === pathToFileURL(
|
|
39758
|
+
return import.meta.url === pathToFileURL(realpathSync3(invokedPath)).href;
|
|
38433
39759
|
} catch {
|
|
38434
39760
|
return false;
|
|
38435
39761
|
}
|
|
@@ -38458,7 +39784,7 @@ function portFlag(argv) {
|
|
|
38458
39784
|
function entryPath() {
|
|
38459
39785
|
const invoked = process.argv[1];
|
|
38460
39786
|
if (invoked === void 0) throw new Error("cannot determine the entry path (no argv[1])");
|
|
38461
|
-
return
|
|
39787
|
+
return realpathSync4(invoked);
|
|
38462
39788
|
}
|
|
38463
39789
|
async function dispatch(argv) {
|
|
38464
39790
|
if (argv[0] === "daemon") {
|
|
@@ -38485,7 +39811,7 @@ function isProcessEntryPoint2() {
|
|
|
38485
39811
|
const invokedPath = process.argv[1];
|
|
38486
39812
|
if (invokedPath === void 0) return false;
|
|
38487
39813
|
try {
|
|
38488
|
-
return import.meta.url === pathToFileURL2(
|
|
39814
|
+
return import.meta.url === pathToFileURL2(realpathSync4(invokedPath)).href;
|
|
38489
39815
|
} catch {
|
|
38490
39816
|
return false;
|
|
38491
39817
|
}
|
|
@@ -38507,6 +39833,7 @@ export {
|
|
|
38507
39833
|
MAX_BACKGROUND_RUNS,
|
|
38508
39834
|
MCP_ENDPOINT_PATH,
|
|
38509
39835
|
RUN_MONITOR_RESOURCE_URI,
|
|
39836
|
+
ReplPresenceLedger,
|
|
38510
39837
|
SCRIPT_RESOURCE_LIST_LIMIT,
|
|
38511
39838
|
SCRIPT_RESOURCE_MIME_TYPE,
|
|
38512
39839
|
SHUTDOWN_DEADLINE_MS,
|
|
@@ -38514,13 +39841,17 @@ export {
|
|
|
38514
39841
|
WORKFLOW_RUN_EVENTS_SCHEMA_VERSION,
|
|
38515
39842
|
WorkflowProjectRegistry,
|
|
38516
39843
|
buildAuthoringPromptText,
|
|
39844
|
+
capStructuredResult,
|
|
38517
39845
|
clampWorkflowInput,
|
|
38518
39846
|
createDaemon,
|
|
38519
39847
|
createProgressReporter,
|
|
39848
|
+
createReplProjectState,
|
|
38520
39849
|
createWorkflowServer,
|
|
38521
39850
|
dispatch,
|
|
39851
|
+
disposeReplProjectState,
|
|
38522
39852
|
disposeRunnerWithDeadline,
|
|
38523
39853
|
ensureDaemonRunning,
|
|
39854
|
+
ensureReplWorkspace,
|
|
38524
39855
|
envFingerprint,
|
|
38525
39856
|
installMcpServerLifecycle,
|
|
38526
39857
|
main,
|
|
@@ -38530,6 +39861,9 @@ export {
|
|
|
38530
39861
|
readDaemonInfo,
|
|
38531
39862
|
registerAuthoringPrompt,
|
|
38532
39863
|
registerWorkflowAppUi,
|
|
39864
|
+
replToolInputShape,
|
|
39865
|
+
replToolOutputShape,
|
|
39866
|
+
resetReplProjectState,
|
|
38533
39867
|
resolveProjectDir,
|
|
38534
39868
|
runDaemon,
|
|
38535
39869
|
runShim,
|