@krapht/ork 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
Files changed (43) hide show
  1. package/README.md +3 -4
  2. package/dist/{__build_info__-D9lSlMFy.mjs → __build_info__-CIchF6nr.mjs} +2 -2
  3. package/dist/bin.mjs +1479 -600
  4. package/dist/chips-BA7SrrH5.mjs +43 -0
  5. package/dist/{clean-CA_Oi2Wl.mjs → clean-Di-KT7Mf.mjs} +5 -2
  6. package/dist/{completion-DBGfL-gN.mjs → completion-C5D0sEs5.mjs} +23 -16
  7. package/dist/context-BaNshWr9.mjs +695 -0
  8. package/dist/{decide-CoIJh34e.mjs → decide-B6PnKeqo.mjs} +3 -3
  9. package/dist/{find-rBBk7Jui.mjs → find-BicBbGMg.mjs} +1 -1
  10. package/dist/format-lWhN-jYR.mjs +286 -0
  11. package/dist/{in-a-run-DdAakpRR.mjs → in-a-run-CgkVouyG.mjs} +1 -1
  12. package/dist/{limits-BPM9wUlV.mjs → limits-jZQp9ZJC.mjs} +1 -1
  13. package/dist/{list-Bv5Lxu7z.mjs → list-CEaJ07jd.mjs} +11 -4
  14. package/dist/{mcp-DM30MFBw.mjs → mcp-DYgVA9f5.mjs} +6 -6
  15. package/dist/output-CM95njYt.mjs +421 -0
  16. package/dist/{pause-C7lJkSma.mjs → pause-D6PuSlNl.mjs} +4 -4
  17. package/dist/{relay-BipJYszF.mjs → relay-DNx7be9k.mjs} +2 -2
  18. package/dist/{remove-BDs9isB6.mjs → remove-CpMb4NDl.mjs} +14 -7
  19. package/dist/{report-DK4LGqwF.mjs → report-CEN8VoUb.mjs} +31 -20
  20. package/dist/{send-nbkjf3sc.mjs → send-CLs_fyg7.mjs} +3 -3
  21. package/dist/{src-BxUJPGa5.mjs → src-9iGkjnsy.mjs} +487 -341
  22. package/dist/{src-B0TT7DBi.mjs → src-B6KvTm7B.mjs} +106 -90
  23. package/dist/src-BnZgZGrf.mjs +2697 -0
  24. package/dist/{src-hrj2h0IZ.mjs → src-D6_xjm-x.mjs} +6719 -3389
  25. package/dist/ui/THIRD_PARTY_LICENSES.txt +594 -0
  26. package/dist/ui/assets/app-CxURph7a.js +75 -0
  27. package/dist/ui/assets/app-QoHVi-mb.css +1 -0
  28. package/dist/ui/assets/{index-CPx74lFf.js → index-By_8KKG6.js} +3 -3
  29. package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
  30. package/dist/ui/index.html +2 -2
  31. package/dist/{ui-B98FSboa.mjs → ui-CIci4UBW.mjs} +4 -4
  32. package/dist/{usage-EW7DI6Z7.mjs → usage-GceBGMjU.mjs} +43 -32
  33. package/dist/{watch-BAkSGaOp.mjs → watch-BmoybMQg.mjs} +28 -16
  34. package/dist/{workflows-vsEohqa7.mjs → workflows-DZvW3i1e.mjs} +4 -3
  35. package/dist/{worktrees-Ahj9rdWK.mjs → worktrees-Be51E2rB.mjs} +84 -24
  36. package/package.json +2 -4
  37. package/dist/context-6x3kXREO.mjs +0 -274
  38. package/dist/format-zMmhqyLE.mjs +0 -427
  39. package/dist/output-TV_j7Ni0.mjs +0 -950
  40. package/dist/src-B9v0lDc6.mjs +0 -1204
  41. package/dist/ui/assets/app-BNT0SuIz.css +0 -1
  42. package/dist/ui/assets/app-CmkynZyW.js +0 -67
  43. package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
@@ -1,10 +1,11 @@
1
- import { $n as timeoutMs, $t as parseTemplate, At as runScope, F as declaredProblem, Jn as beforeOf, Mt as envReadsOf, Ot as heldTimeout, Pt as declaredOutputOf, Qt as evaluate, Vn as markValue, Xn as isToolNode, Zn as programsRunBy, en as renderTemplate, er as INTEGRATIONS, ft as sessionDriver, jt as calledWorkflows, mt as reportSchemaFor, nr as integrationFor, s as planReason, tr as TOOL_PREFIX, ut as planNodeSession } from "./src-hrj2h0IZ.mjs";
1
+ import { $t as endOf, Bt as lowerChild, Cr as TOOL_PREFIX, Ft as failedWith, Ht as envReadsOf, It as stoppedEnd, Lt as calledWorkflows, Mt as runScope, Nt as declaredProblem, Pt as endedWith, Rt as slotClock, Sr as INTEGRATIONS, Vt as startAt, Wt as declaredOutputOf, Xt as mcpNotText, Yt as MCP_FILE, Zt as serversIn, _n as parseTemplate, _r as isToolNode, ar as programsRunBy, at as planReason, bt as heldTimeout, dt as sessionDriver, en as outcomeEnd, gn as evaluate, gr as isClaudeNode, ir as beforeOf, lr as markValue, lt as planNodeSession, pt as reportSchemaFor, rn as endFor, tn as END_DETAIL_LIMIT, vn as renderTemplate, wr as integrationFor, xr as timeoutMs } from "./src-D6_xjm-x.mjs";
2
2
  import path from "node:path";
3
- import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { accessSync, constants, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
7
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { Readable } from "node:stream";
8
9
  // ../nodes/src/call.ts
9
10
  /**
10
11
  * @file The `workflow.call` node type (RFC-004): renders what the node asks for
@@ -18,24 +19,17 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
18
19
  */
19
20
  const limitedCall = (result) => {
20
21
  if (result.ok) return result;
21
- return (result.error ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
22
+ const why = result.end?.detail ?? result.error;
23
+ return result.end?.kind === "plan_limit" || (why ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
22
24
  ...result,
23
- limited: result.error ?? "the called run reached a limit"
25
+ limited: why ?? "the called run reached a limit"
24
26
  } : result;
25
27
  };
26
28
  const callExecutor = (context) => {
27
29
  const { node, calls } = context;
28
- if (node.type !== "workflow.call") return Promise.resolve({
29
- ok: false,
30
- output: {},
31
- error: `${context.id} is not a call`
32
- });
33
- if (calls === void 0) return Promise.resolve({
34
- ok: false,
35
- output: { status: "fail" },
36
- error: "This run cannot call workflows."
37
- });
38
- const scope = runScope(context.inputs, context.outputs, context.runId);
30
+ if (node.type !== "workflow.call") return Promise.resolve(failedWith(endFor("unknown", {}, `${context.id} is not a call`), {}));
31
+ if (calls === void 0) return Promise.resolve(failedWith(endFor("invalid_node", { reason: "this run cannot call workflows" }), { status: "fail" }));
32
+ const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
39
33
  const render = (text) => {
40
34
  const template = parseTemplate(text);
41
35
  return template.ok ? renderTemplate(template.parts, scope) : text;
@@ -61,33 +55,56 @@ const callExecutor = (context) => {
61
55
  }
62
56
  }, context.signal).then(limitedCall);
63
57
  };
58
+ // ../nodes/src/claude-plan.ts
59
+ /**
60
+ * @file What a Claude node's session is planned with, read from the node's
61
+ * context.
62
+ */
63
+ /** The plan options every session of a node starts from. */
64
+ const planOptionsOf = (context, inPlace) => {
65
+ const planOptions = {
66
+ cwd: context.cwd,
67
+ inputs: context.inputs,
68
+ outputs: context.outputs,
69
+ inPlace,
70
+ workflowDir: context.workflowDir,
71
+ projectRoot: context.projectRoot,
72
+ ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
73
+ pluginDir: path.join(context.runDir, "plugins", context.id)
74
+ };
75
+ if (context.checkout !== void 0) planOptions.checkout = context.checkout;
76
+ if (context.brief !== void 0) planOptions.brief = context.brief;
77
+ if (context.template !== void 0) planOptions.template = context.template;
78
+ if (context.item !== void 0) planOptions.item = context.item;
79
+ if (context.ownership !== void 0) planOptions.ownership = context.ownership;
80
+ if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
81
+ if (context.claims !== void 0) planOptions.claims = context.claims;
82
+ return planOptions;
83
+ };
64
84
  // ../nodes/src/claude-result.ts
65
85
  /**
66
86
  * @file A finished session as a node's result: its report is the output, held
67
- * to what the node declares (RFC-010).
87
+ * to what the node declares (RFC-010), and how it ended is its end kind
88
+ * (specs/runtime/session-ends.md).
68
89
  */
90
+ /** How a session with no report ended; a driver's `done` without one is not. */
91
+ const withoutReport = (outcome) => {
92
+ const end = outcomeEnd(outcome);
93
+ return end.kind === "done" || end.kind === "reported_failure" ? endFor("no_report", {}, end.detail) : end;
94
+ };
69
95
  /** A finished session as a node result: its report is the node's output. */
70
96
  const resultOf = (outcome, declared) => {
71
- if (outcome === null) return {
72
- ok: false,
73
- output: {
74
- status: "fail",
75
- summary: "the session ended without a result"
76
- },
77
- error: "the session ended without a result"
78
- };
97
+ if (outcome === null) return failedWith(endFor("driver_crashed", {}, "the session ended without a result"));
79
98
  const { report, usage } = outcome;
80
99
  if (report === null) {
81
- const reason = outcome.errors[0] ?? "no valid report";
100
+ const end = withoutReport(outcome);
82
101
  return {
83
- ok: false,
84
- output: {
102
+ ...failedWith(end, {
85
103
  status: "fail",
86
- summary: reason,
104
+ summary: end.message,
87
105
  usage
88
- },
106
+ }),
89
107
  usage,
90
- error: `ended without a report (${outcome.subtype}): ${reason}`,
91
108
  sessionId: outcome.sessionId
92
109
  };
93
110
  }
@@ -101,7 +118,7 @@ const resultOf = (outcome, declared) => {
101
118
  };
102
119
  if (report.branch !== void 0) output["branch"] = report.branch;
103
120
  const problem = declaredProblem(declared, output);
104
- if (problem !== null) return {
121
+ if (problem !== null) return endedWith({
105
122
  ok: false,
106
123
  output: {
107
124
  status: "fail",
@@ -111,18 +128,166 @@ const resultOf = (outcome, declared) => {
111
128
  ...report.branch === void 0 ? {} : { branch: report.branch }
112
129
  },
113
130
  usage,
114
- error: problem,
115
131
  ...ran
116
- };
117
- const result = {
118
- ok: report.status !== "fail",
132
+ }, endFor("invalid_report", { reason: problem }));
133
+ const ok = report.status !== "fail";
134
+ return endedWith({
135
+ ok,
119
136
  output,
120
137
  usage,
121
138
  ...ran
139
+ }, endFor(ok ? "done" : "reported_failure"));
140
+ };
141
+ // ../nodes/src/environ.ts
142
+ /** A variable of the user's. */
143
+ const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
144
+ // ../nodes/src/mcp-servers.ts
145
+ /**
146
+ * @file Reading an MCP server's definition from the project's `.mcp.json`, the
147
+ * file Claude Code reads too, so a server is defined in one place (RFC-006).
148
+ * `${VAR}` and `${VAR:-default}` read the user's environment, which is where
149
+ * secrets stay: Ork stores none, and a run masks each value read this way. A
150
+ * server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
151
+ * so it is refused.
152
+ */
153
+ const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
154
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
155
+ const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
156
+ const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
157
+ /** The server as written, or why Ork can't call it. */
158
+ const readMcpServer = (root, name) => {
159
+ const servers = serversIn(root);
160
+ if (!servers.ok) return servers;
161
+ const known = Object.keys(servers.value);
162
+ const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
163
+ if (!isRecord(server)) return {
164
+ ok: false,
165
+ error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
166
+ };
167
+ if ("oauth" in server) return {
168
+ ok: false,
169
+ error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
170
+ };
171
+ const notText = mcpNotText(server);
172
+ if (notText !== void 0) return {
173
+ ok: false,
174
+ error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
175
+ };
176
+ const { type, url, command } = server;
177
+ if (type === "http" && typeof url === "string") return {
178
+ ok: true,
179
+ value: {
180
+ kind: "http",
181
+ url,
182
+ headers: stringsOf(server["headers"])
183
+ }
184
+ };
185
+ if ((type === void 0 || type === "stdio") && typeof command === "string") return {
186
+ ok: true,
187
+ value: {
188
+ kind: "stdio",
189
+ command,
190
+ args: listOf(server["args"]),
191
+ env: stringsOf(server["env"])
192
+ }
193
+ };
194
+ return {
195
+ ok: false,
196
+ error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
197
+ };
198
+ };
199
+ /**
200
+ * The named servers' entries as written, for a session: `${VAR}` stays text for
201
+ * the `claude` that loads them to expand. The compiler has checked the names.
202
+ */
203
+ const namedMcpEntries = (root, names) => {
204
+ if (names.length === 0) return {
205
+ ok: true,
206
+ value: {}
207
+ };
208
+ const servers = serversIn(root);
209
+ if (!servers.ok) return servers;
210
+ const mcpServers = {};
211
+ for (const name of names) {
212
+ const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
213
+ if (!isRecord(server)) return {
214
+ ok: false,
215
+ error: `no MCP server \`${name}\` in ${MCP_FILE}`
216
+ };
217
+ mcpServers[name] = server;
218
+ }
219
+ return {
220
+ ok: true,
221
+ value: { mcpServers }
122
222
  };
123
- if (!result.ok) result.error = report.summary;
124
- return result;
125
223
  };
224
+ /** Each variable a server reads, and whether it has a default. */
225
+ const readsOf = (server) => {
226
+ return (server.kind === "stdio" ? [
227
+ server.command,
228
+ ...server.args,
229
+ ...Object.values(server.env)
230
+ ] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
231
+ name: String(match[1]),
232
+ fallback: match[2] !== void 0
233
+ })));
234
+ };
235
+ /**
236
+ * The server with its variables filled in, and the values to mask: all it read
237
+ * but those `plain` names, which aren't secret.
238
+ */
239
+ const expandServer = (server, environ, plain = []) => {
240
+ const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
241
+ if (unset !== void 0) return {
242
+ ok: false,
243
+ error: `${MCP_FILE} reads ${unset.name}, which is not set`
244
+ };
245
+ const values = [];
246
+ const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
247
+ const value = readEnv(environ, name);
248
+ if (value !== void 0 && !plain.includes(name)) values.push(value);
249
+ return value ?? fallback ?? "";
250
+ });
251
+ const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
252
+ return {
253
+ ok: true,
254
+ value: {
255
+ server: server.kind === "stdio" ? {
256
+ kind: "stdio",
257
+ command: fill(server.command),
258
+ args: server.args.map((arg) => fill(arg)),
259
+ env: fillAll(server.env)
260
+ } : {
261
+ kind: "http",
262
+ url: fill(server.url),
263
+ headers: fillAll(server.headers)
264
+ },
265
+ values
266
+ }
267
+ };
268
+ };
269
+ /** What a workflow's MCP servers read of the user's, unless called not secret. */
270
+ const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
271
+ if (node.type !== "mcp.call") return [];
272
+ const read = readMcpServer(root, node.server);
273
+ return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
274
+ });
275
+ /** A stdio server's program, when its name can be known before it is filled in. */
276
+ const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
277
+ /**
278
+ * What stops a workflow's `mcp.call` nodes, found before a run spends anything:
279
+ * a server that isn't there or can't be called, a program that isn't installed,
280
+ * or a variable that is not set.
281
+ */
282
+ const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
283
+ if (node.type !== "mcp.call" || finished.has(id)) return [];
284
+ const read = readMcpServer(root, node.server);
285
+ if (!read.ok) return [`${id}: ${read.error}`];
286
+ const given = new Set(Object.keys(node.env ?? {}));
287
+ const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
288
+ const program = programOf(read.value);
289
+ return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
290
+ });
126
291
  /** The only built-in tools a fork has: it reads, and reports. */
127
292
  const FORK_TOOLS = [
128
293
  "Read",
@@ -277,25 +442,39 @@ const askFork = async (context, owner, question, deps) => {
277
442
  /** What the person sees of a question its asker's owner passed on. */
278
443
  const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
279
444
  /**
445
+ * The node's channel to the person: what it asked is withdrawn when it stops,
446
+ * so a stopped node leaves no decision open.
447
+ */
448
+ const withdrawnOnStop = (context) => {
449
+ const { human } = context;
450
+ const signalOf = (asked) => asked === void 0 ? context.signal : AbortSignal.any([asked, context.signal]);
451
+ return {
452
+ permit: (request) => human.permit({
453
+ ...request,
454
+ signal: signalOf(request.signal)
455
+ }),
456
+ ask: (question) => human.ask({
457
+ ...question,
458
+ signal: signalOf(question.signal)
459
+ }),
460
+ ...human.settled === void 0 ? {} : { settled: human.settled }
461
+ };
462
+ };
463
+ /**
280
464
  * The child's channel when its owner takes its questions first: a question goes
281
465
  * to a fork of the owner's session, then to the person if the fork passes it on
282
466
  * or can't answer. Permission prompts go to the person as before.
283
467
  */
284
468
  const ownerFirst = (context, owner, deps) => {
285
- const { human } = context;
469
+ const human = withdrawnOnStop(context);
286
470
  return {
287
471
  permit: (request) => human.permit(request),
288
472
  ask: async (question) => {
289
473
  const answered = await askFork(context, owner, question, deps);
290
474
  if (context.signal.aborted) throw new Error(`${context.id} was stopped`);
291
- const signal = question.signal === void 0 ? context.signal : AbortSignal.any([question.signal, context.signal]);
292
- if (answered === null) return human.ask({
293
- ...question,
294
- signal
295
- });
475
+ if (answered === null) return human.ask(question);
296
476
  if (answered.choice === passOnId(question.options)) return human.ask({
297
477
  ...question,
298
- signal,
299
478
  owner: owner.id,
300
479
  context: passedOnContext(question.context, owner.id, answered.note)
301
480
  });
@@ -347,13 +526,16 @@ const withUsageOf = (first, later) => {
347
526
  */
348
527
  const afterLateTurn = (first, later) => {
349
528
  const merged = withUsageOf(first, later);
350
- return later.report === null ? {
351
- ...merged,
529
+ if (later.report !== null) return merged;
530
+ const { end: _later, ...rest } = merged;
531
+ return {
532
+ ...rest,
352
533
  report: first.report,
353
534
  subtype: first.subtype,
535
+ ...first.end === void 0 ? {} : { end: first.end },
354
536
  text: first.text,
355
537
  errors: first.errors
356
- } : merged;
538
+ };
357
539
  };
358
540
  /**
359
541
  * What another turn of a session may use. `spent` and `turns` are this
@@ -379,6 +561,20 @@ const budgetNow = (context) => {
379
561
  return known.length === 0 ? void 0 : Math.min(...known);
380
562
  };
381
563
  /**
564
+ * What a node's first session may spend, set aside from the run's budget in the
565
+ * same step that reads it (ADR-077), so a node starting beside it is never
566
+ * offered the same part: the least of what is left and the node's own
567
+ * `budgetUsd`. With nothing left, it waits for a worker running beside it to
568
+ * finish, as long as one may.
569
+ */
570
+ const budgetToStart = async (context) => {
571
+ const left = budgetNow(context);
572
+ if (left !== void 0 && left <= 0) return await context.budgetFreed?.(context.signal) === true ? budgetToStart(context) : left;
573
+ const own = isClaudeNode(context.node) ? context.node.budgetUsd ?? context.workflow.defaults?.budgetUsd : void 0;
574
+ context.reserveBudget?.(own === void 0 || left === void 0 ? left : Math.min(own, left));
575
+ return left;
576
+ };
577
+ /**
382
578
  * Whether a limit across runs, rather than the node's work, ended a node: a
383
579
  * session whose cap was a quota's and that ran into it, or one its plan refused
384
580
  * and that gave no report. A node that still succeeded is left alone.
@@ -398,24 +594,38 @@ const limitWatch = (context) => {
398
594
  },
399
595
  ended: (outcome) => {
400
596
  if (refused !== void 0 && (outcome?.report ?? null) === null) why = refused;
401
- if (left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left && outcome?.subtype === "error_max_budget_usd") why = context.quota?.().blocked ?? "a session reached what was left of a quota";
597
+ const capped = left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left;
598
+ const spent = outcome !== null && outcomeEnd(outcome).kind === "budget_limit";
599
+ if (capped && spent) why = context.quota?.().blocked ?? "a session reached what was left of a quota";
402
600
  }
403
601
  };
404
602
  },
405
- result: (result) => result.ok || why === void 0 ? result : {
406
- ...result,
407
- limited: why
603
+ result: (result) => {
604
+ if (result.ok) return result;
605
+ if (why !== void 0) return {
606
+ ...endedWith(result, endFor("plan_limit", {}, why)),
607
+ limited: why
608
+ };
609
+ const { end } = result;
610
+ return end?.kind === "plan_limit" ? {
611
+ ...result,
612
+ limited: end.message
613
+ } : result;
408
614
  }
409
615
  };
410
616
  };
411
- /** No session started: nothing was left for one. */
412
- const notStarted = (reason, limited) => ({
413
- ok: false,
414
- error: `not started: ${reason}`,
415
- output: {
617
+ /**
618
+ * No session started: nothing was left for one, or nothing to start it. Its
619
+ * summary keeps Ork's reason, `end.detail` too; the message is the kind's.
620
+ */
621
+ const notStarted = (kind, reason, limited) => ({
622
+ ...failedWith(endFor(kind, {
623
+ reason,
624
+ program: "an agent driver"
625
+ }, reason), {
416
626
  status: "fail",
417
627
  summary: `not started: ${reason}`
418
- },
628
+ }),
419
629
  ...limited === void 0 ? {} : { limited }
420
630
  });
421
631
  // ../nodes/src/tool-output.ts
@@ -660,163 +870,51 @@ const tokenize = (command) => {
660
870
  args
661
871
  };
662
872
  };
663
- // ../nodes/src/environ.ts
664
- /** A variable of the user's. */
665
- const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
666
- // ../nodes/src/mcp-servers.ts
873
+ // ../nodes/src/tool-ends.ts
667
874
  /**
668
- * @file Reading an MCP server's definition from the project's `.mcp.json`, the
669
- * file Claude Code reads too, so a server is defined in one place (RFC-006).
670
- * `${VAR}` and `${VAR:-default}` read the user's environment, which is where
671
- * secrets stay: Ork stores none, and a run masks each value read this way. A
672
- * server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
673
- * so it is refused.
875
+ * @file How a tool node ended (specs/runtime/session-ends.md): a node that
876
+ * could not run its command, JSON that broke its declared output, or how a
877
+ * command that ran exited: done, stopped, never started, signed out, or
878
+ * failed. Ork's message names the program; its stderr is the detail.
674
879
  */
675
- const MCP_FILE = ".mcp.json";
676
- const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
677
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
678
- /** The file's `mcpServers`; `{}` when there is no file. */
679
- const serversIn = (root) => {
680
- const file = path.join(root, MCP_FILE);
681
- if (!existsSync(file)) return {
682
- ok: true,
683
- value: {}
684
- };
685
- try {
686
- const parsed = JSON.parse(readFileSync(file, "utf8"));
687
- const servers = isRecord(parsed) ? parsed["mcpServers"] : void 0;
688
- return {
689
- ok: true,
690
- value: isRecord(servers) ? servers : {}
691
- };
692
- } catch (error) {
693
- const reason = error instanceof SyntaxError ? "it is not valid JSON" : error instanceof Error ? error.message : String(error);
694
- return {
695
- ok: false,
696
- error: `${MCP_FILE} can't be read: ${reason}`
697
- };
880
+ /** A node that could not run its command: its own definition is wrong. */
881
+ const failed$1 = (error, kind = "invalid_node") => endedWith({
882
+ ok: false,
883
+ output: {
884
+ exitCode: -1,
885
+ stdout: "",
886
+ stderr: error
698
887
  }
699
- };
700
- const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
701
- const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
702
- /** A server's field that holds something other than text: it would be dropped. */
703
- const notTextIn = (server) => [
704
- "args",
705
- "env",
706
- "headers"
707
- ].find((field) => {
708
- const value = server[field];
709
- return (Array.isArray(value) ? value : isRecord(value) ? Object.values(value) : []).some((item) => typeof item !== "string");
710
- });
711
- /** The server as written, or why Ork can't call it. */
712
- const readMcpServer = (root, name) => {
713
- const servers = serversIn(root);
714
- if (!servers.ok) return servers;
715
- const known = Object.keys(servers.value);
716
- const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
717
- if (!isRecord(server)) return {
718
- ok: false,
719
- error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
720
- };
721
- if ("oauth" in server) return {
722
- ok: false,
723
- error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
724
- };
725
- const notText = notTextIn(server);
726
- if (notText !== void 0) return {
727
- ok: false,
728
- error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
729
- };
730
- const { type, url, command } = server;
731
- if (type === "http" && typeof url === "string") return {
732
- ok: true,
733
- value: {
734
- kind: "http",
735
- url,
736
- headers: stringsOf(server["headers"])
737
- }
738
- };
739
- if ((type === void 0 || type === "stdio") && typeof command === "string") return {
740
- ok: true,
741
- value: {
742
- kind: "stdio",
743
- command,
744
- args: listOf(server["args"]),
745
- env: stringsOf(server["env"])
746
- }
747
- };
748
- return {
749
- ok: false,
750
- error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
751
- };
752
- };
753
- /** Each variable a server reads, and whether it has a default. */
754
- const readsOf = (server) => {
755
- return (server.kind === "stdio" ? [
756
- server.command,
757
- ...server.args,
758
- ...Object.values(server.env)
759
- ] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
760
- name: String(match[1]),
761
- fallback: match[2] !== void 0
762
- })));
763
- };
888
+ }, endFor(kind, { reason: error }, error));
889
+ /** JSON that broke what the node declares, or that it could not read. */
890
+ const invalidOutput = (output, reason) => endedWith({
891
+ ok: false,
892
+ output
893
+ }, endFor("invalid_output", { reason }));
764
894
  /**
765
- * The server with its variables filled in, and the values to mask: all it read
766
- * but those `plain` names, which aren't secret.
895
+ * How a command that ran and exited non-zero ended: stopped, never started, a
896
+ * sign-in its program asks for (`signedOut`), or a plain failure.
767
897
  */
768
- const expandServer = (server, environ, plain = []) => {
769
- const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
770
- if (unset !== void 0) return {
771
- ok: false,
772
- error: `${MCP_FILE} reads ${unset.name}, which is not set`
773
- };
774
- const values = [];
775
- const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
776
- const value = readEnv(environ, name);
777
- if (value !== void 0 && !plain.includes(name)) values.push(value);
778
- return value ?? fallback ?? "";
779
- });
780
- const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
781
- return {
898
+ const exitKind = (run, context, signedOut) => {
899
+ if (context.signal.aborted) return "interrupted";
900
+ if (run.exitCode === -1 && /\bENOENT\b/u.test(run.stderr)) return "tool_missing";
901
+ return signedOut?.(run) === true ? "auth" : "command_failed";
902
+ };
903
+ /** How a command that ran ended, by its exit code; `program` is scrubbed. */
904
+ const exitEnd = (output, run, context, program, signedOut) => {
905
+ if (run.exitCode === 0) return endedWith({
782
906
  ok: true,
783
- value: {
784
- server: server.kind === "stdio" ? {
785
- kind: "stdio",
786
- command: fill(server.command),
787
- args: server.args.map((arg) => fill(arg)),
788
- env: fillAll(server.env)
789
- } : {
790
- kind: "http",
791
- url: fill(server.url),
792
- headers: fillAll(server.headers)
793
- },
794
- values
795
- }
796
- };
907
+ output
908
+ }, endFor("done"));
909
+ const end = endFor(exitKind(run, context, signedOut), {
910
+ program,
911
+ exitCode: run.exitCode
912
+ }, run.stderr.trim().slice(-END_DETAIL_LIMIT));
913
+ return endedWith({
914
+ ok: false,
915
+ output
916
+ }, end);
797
917
  };
798
- /** What a workflow's MCP servers read of the user's, unless called not secret. */
799
- const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
800
- if (node.type !== "mcp.call") return [];
801
- const read = readMcpServer(root, node.server);
802
- return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
803
- });
804
- /** A stdio server's program, when its name can be known before it is filled in. */
805
- const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
806
- /**
807
- * What stops a workflow's `mcp.call` nodes, found before a run spends anything:
808
- * a server that isn't there or can't be called, a program that isn't installed,
809
- * or a variable that is not set.
810
- */
811
- const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
812
- if (node.type !== "mcp.call" || finished.has(id)) return [];
813
- const read = readMcpServer(root, node.server);
814
- if (!read.ok) return [`${id}: ${read.error}`];
815
- const given = new Set(Object.keys(node.env ?? {}));
816
- const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
817
- const program = programOf(read.value);
818
- return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
819
- });
820
918
  // ../nodes/src/tool-scrub.ts
821
919
  /**
822
920
  * @file Masking a secret in what a program prints. A plain search, so a value
@@ -998,7 +1096,9 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
998
1096
  "ORK_WORKFLOW",
999
1097
  "ORK_BIN",
1000
1098
  OUTPUT_ENV,
1001
- STATE_ENV
1099
+ STATE_ENV,
1100
+ "ORK_ITEM",
1101
+ "ORK_INDEX"
1002
1102
  ]);
1003
1103
  /**
1004
1104
  * `env` without Ork's identity variables. A run started by a program in another
@@ -1026,7 +1126,11 @@ const identityOf = (context, orkBin) => ({
1026
1126
  ORK_RUN_DIR: context.runDir,
1027
1127
  ORK_PROJECT: context.projectRoot,
1028
1128
  ORK_WORKFLOW: context.workflow.name,
1029
- ...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
1129
+ ...orkBin === void 0 ? {} : { ORK_BIN: orkBin },
1130
+ ...context.item === void 0 ? {} : {
1131
+ ORK_ITEM: typeof context.item.value === "string" ? context.item.value : JSON.stringify(context.item.value),
1132
+ ORK_INDEX: String(context.item.index)
1133
+ }
1030
1134
  });
1031
1135
  /** The variables a workflow's nodes read that no `plainEnv` excuses. */
1032
1136
  const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
@@ -1068,7 +1172,7 @@ const secretsOf = (workflow, environ, also = []) => valuesOf(namesToMask(workflo
1068
1172
  const renderEnv = (node, context, environ) => {
1069
1173
  const values = {};
1070
1174
  const scope = {
1071
- ...runScope(context.inputs, context.outputs, context.runId),
1175
+ ...runScope(context.inputs, context.outputs, context.runId, context.item),
1072
1176
  output: { env: environ }
1073
1177
  };
1074
1178
  for (const [key, text] of Object.entries(node.env ?? {})) {
@@ -1158,7 +1262,7 @@ const render = (text, context) => {
1158
1262
  };
1159
1263
  return {
1160
1264
  ok: true,
1161
- value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
1265
+ value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId, context.item))
1162
1266
  };
1163
1267
  };
1164
1268
  /** The program and arguments a tool node runs, before the allow check. */
@@ -1212,19 +1316,10 @@ const capped = (text, file) => {
1212
1316
  * what a session is told. All of it is kept in the output's fields and files.
1213
1317
  */
1214
1318
  const clipError = (text) => text.length <= ERROR_LIMIT ? text : `${text.slice(0, ERROR_LIMIT)}…`;
1215
- const failed$1 = (error) => ({
1216
- ok: false,
1217
- output: {
1218
- exitCode: -1,
1219
- stdout: "",
1220
- stderr: error
1221
- },
1222
- error
1223
- });
1224
1319
  /** Runs a user's CLI through its integration's adapter, with its own login. */
1225
1320
  const toolExecutor = (given, options = {}) => async (context) => {
1226
1321
  const { node } = context;
1227
- if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
1322
+ if (!isToolNode(node)) return failed$1(`${context.id} is not a tool`, "unknown");
1228
1323
  const environ = options.environ ?? process.env;
1229
1324
  const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
1230
1325
  const command = commandOf(node, context);
@@ -1251,6 +1346,7 @@ const toolExecutor = (given, options = {}) => async (context) => {
1251
1346
  ...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
1252
1347
  };
1253
1348
  const names = Object.keys(nodeEnv.values);
1349
+ const release = await context.machine?.slot([command.program, ...command.args].join(" "), context.signal);
1254
1350
  context.emit({
1255
1351
  type: "tool.called",
1256
1352
  id: context.id,
@@ -1265,11 +1361,13 @@ const toolExecutor = (given, options = {}) => async (context) => {
1265
1361
  }
1266
1362
  });
1267
1363
  const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
1364
+ const priority = context.machine?.priority;
1268
1365
  const raw = await runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
1269
1366
  cwd,
1270
1367
  signal: context.signal,
1271
- env
1272
- });
1368
+ env,
1369
+ ...priority === void 0 ? {} : { priority }
1370
+ }).finally(() => release?.());
1273
1371
  if (scriptFile !== void 0) removeOutputFile(scriptFile);
1274
1372
  const run = {
1275
1373
  exitCode: raw.exitCode,
@@ -1306,30 +1404,14 @@ const toolExecutor = (given, options = {}) => async (context) => {
1306
1404
  context.emit({
1307
1405
  type: "tool.result",
1308
1406
  id: context.id,
1309
- isError: run.exitCode !== 0 || json?.ok === false || declared !== null
1407
+ isError: run.exitCode !== 0 || json?.ok === false || declared !== null,
1408
+ ...raw.cpu === void 0 ? {} : { cpu: raw.cpu }
1310
1409
  });
1311
- if (json?.ok === false) return {
1312
- ok: false,
1313
- output,
1314
- error: `${context.id}: ${json.error}`
1315
- };
1316
- if (declared !== null) return {
1317
- ok: false,
1318
- output,
1319
- error: declared
1320
- };
1410
+ if (json?.ok === false) return invalidOutput(output, json.error);
1411
+ if (declared !== null) return invalidOutput(output, declared);
1321
1412
  if (json !== null) output["json"] = json.json;
1322
- if (run.exitCode === 0) return {
1323
- ok: true,
1324
- output
1325
- };
1326
- const last = run.stderr.trim().split("\n").at(-1) ?? "";
1327
- return {
1328
- ok: false,
1329
- transient: true,
1330
- output,
1331
- error: `${scrub(command.program)} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${clipError(last)}`}`
1332
- };
1413
+ const signedOut = integration?.signedOut;
1414
+ return exitEnd(output, run, context, scrub(command.program), signedOut);
1333
1415
  };
1334
1416
  // ../nodes/src/verify.ts
1335
1417
  /**
@@ -1341,15 +1423,17 @@ const toolExecutor = (given, options = {}) => async (context) => {
1341
1423
  /** A setup that failed: no session started, so nothing was spent on it. */
1342
1424
  const beforeFailed = (ran) => {
1343
1425
  const error = `its before step failed: ${ran.error ?? "no reason given"}`;
1344
- return {
1426
+ return endedWith({
1345
1427
  ok: false,
1346
- error,
1347
1428
  output: {
1348
1429
  status: "fail",
1349
1430
  summary: error,
1350
1431
  before: ran.output
1351
1432
  }
1352
- };
1433
+ }, endOf({
1434
+ ...ran,
1435
+ ok: false
1436
+ }));
1353
1437
  };
1354
1438
  /** With nothing to run a step, a report cannot be accepted. */
1355
1439
  const noSteps = () => Promise.resolve({
@@ -1370,20 +1454,23 @@ const runStep = async (tool, given, { timeout, ...fields }, step) => {
1370
1454
  type: "tool.shell",
1371
1455
  ...fields
1372
1456
  };
1373
- const context = {
1374
- ...given,
1375
- step
1376
- };
1377
1457
  const ms = timeoutMs(timeout);
1378
1458
  if (ms === void 0) return tool({
1379
- ...context,
1459
+ ...given,
1460
+ step,
1380
1461
  node
1381
1462
  });
1463
+ const { clock, machine } = slotClock(given.hold, given.machine);
1464
+ const context = {
1465
+ ...given,
1466
+ step,
1467
+ ...machine === void 0 ? {} : { machine }
1468
+ };
1382
1469
  const limit = new AbortController();
1383
1470
  const finished = new AbortController();
1384
1471
  let expired = false;
1385
1472
  const watch = async () => {
1386
- if (await heldTimeout(context.hold, ms, AbortSignal.any([context.signal, finished.signal]))) {
1473
+ if (await heldTimeout(clock, ms, AbortSignal.any([context.signal, finished.signal]))) {
1387
1474
  expired = true;
1388
1475
  limit.abort();
1389
1476
  }
@@ -1494,17 +1581,19 @@ const checkedResult = (result, { check, resumes }) => {
1494
1581
  };
1495
1582
  const reason = `its check failed: ${check.error ?? "no reason given"}`;
1496
1583
  const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
1497
- return {
1584
+ return endedWith({
1498
1585
  ...result,
1499
1586
  ok: false,
1500
- error,
1501
1587
  output: {
1502
1588
  ...result.output,
1503
1589
  status: "fail",
1504
1590
  summary: error,
1505
1591
  verify
1506
1592
  }
1507
- };
1593
+ }, endOf(result.ok ? {
1594
+ ...check,
1595
+ ok: false
1596
+ } : result));
1508
1597
  };
1509
1598
  // ../nodes/src/claude.ts
1510
1599
  /**
@@ -1515,11 +1604,19 @@ const checkedResult = (result, { check, resumes }) => {
1515
1604
  * (ADR-019).
1516
1605
  */
1517
1606
  const MAX_INPUT = 8192;
1518
- /** On a loop re-entry, the brief carries what the workflow sent back. */
1607
+ const inputJson = (input) => JSON.stringify(input ?? {}, void 0, 2);
1608
+ /** What a sent-back input is whole, for `read_input`, when its note cuts it. */
1609
+ const wholeInput = (input) => {
1610
+ const json = inputJson(input);
1611
+ return json.length > MAX_INPUT ? json : void 0;
1612
+ };
1613
+ /** A re-entry's note of what was sent back; `read_input` has a cut rest. */
1519
1614
  const reentryNote = (round, input) => {
1520
- const json = JSON.stringify(input ?? {}, void 0, 2);
1521
- const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
1522
- return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
1615
+ const json = inputJson(input);
1616
+ const cut = json.length > MAX_INPUT;
1617
+ const body = cut ? `${json.slice(0, MAX_INPUT)}\n…` : json;
1618
+ const rest = cut ? `\n\nThat is the first ${String(MAX_INPUT)} of its ${String(json.length)} characters. Call read_input for all of it before you act on it.` : "";
1619
+ return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\`${rest}`;
1523
1620
  };
1524
1621
  /**
1525
1622
  * What a resumed leader is told: it already has its brief and its earlier work,
@@ -1545,22 +1642,7 @@ const specFor = (spec, context) => {
1545
1642
  };
1546
1643
  /** Runs a `claude.leader` or `claude.worker` as one session. */
1547
1644
  const claudeExecutor = (options) => async (context) => {
1548
- const planOptions = {
1549
- cwd: context.cwd,
1550
- inputs: context.inputs,
1551
- outputs: context.outputs,
1552
- inPlace: options.inPlace,
1553
- workflowDir: context.workflowDir,
1554
- projectRoot: context.projectRoot,
1555
- ...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
1556
- pluginDir: path.join(context.runDir, "plugins", context.id)
1557
- };
1558
- if (context.checkout !== void 0) planOptions.checkout = context.checkout;
1559
- if (context.brief !== void 0) planOptions.brief = context.brief;
1560
- if (context.template !== void 0) planOptions.template = context.template;
1561
- if (context.ownership !== void 0) planOptions.ownership = context.ownership;
1562
- if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
1563
- if (context.claims !== void 0) planOptions.claims = context.claims;
1645
+ const planOptions = planOptionsOf(context, options.inPlace);
1564
1646
  const before = beforeOf(context.node, context.workflow.defaults);
1565
1647
  if (before !== void 0) {
1566
1648
  const ran = await runStep(options.steps ?? noSteps, context, before, {
@@ -1569,49 +1651,56 @@ const claudeExecutor = (options) => async (context) => {
1569
1651
  });
1570
1652
  if (!ran.ok) return beforeFailed(ran);
1571
1653
  await context.hold?.wait("start", context.signal);
1572
- if (context.signal.aborted) return beforeFailed({
1573
- ...ran,
1574
- error: "the node was stopped"
1575
- });
1654
+ if (context.signal.aborted) {
1655
+ const end = stoppedEnd(context.signal);
1656
+ return beforeFailed({
1657
+ ...ran,
1658
+ ok: false,
1659
+ error: end.message,
1660
+ end
1661
+ });
1662
+ }
1576
1663
  planOptions.before = ran.output;
1577
1664
  }
1578
1665
  const blocked = context.quota?.().blocked;
1579
- if (blocked !== void 0) return notStarted(blocked, blocked);
1580
- const budget = budgetNow(context);
1581
- if (budget !== void 0 && budget <= 0) return notStarted("nothing is left of the run's budget");
1666
+ if (blocked !== void 0) return notStarted("plan_limit", blocked, blocked);
1667
+ const budget = await budgetToStart(context);
1668
+ if (budget !== void 0 && budget <= 0) return notStarted("budget_limit", "nothing is left of the run's budget");
1582
1669
  if (budget !== void 0) planOptions.budgetLeftUsd = budget;
1583
1670
  const plan = planNodeSession(context.workflow, context.id, planOptions);
1584
- if (!plan.ok) return {
1585
- ok: false,
1586
- output: {
1587
- status: "fail",
1588
- summary: plan.error
1589
- },
1590
- error: plan.error
1671
+ if (!plan.ok) return notStarted("invalid_node", plan.error);
1672
+ const servers = namedMcpEntries(context.projectRoot, plan.mcp);
1673
+ if (!servers.ok) return notStarted("invalid_node", servers.error);
1674
+ const spec = {
1675
+ ...specFor(plan.spec, context),
1676
+ ...servers.value
1591
1677
  };
1592
- const spec = specFor(plan.spec, context);
1593
1678
  if (options.machineClaude === true) spec.machineClaude = true;
1594
1679
  const driver = sessionDriver({
1595
1680
  override: options.driver,
1596
1681
  named: plan.driver,
1597
1682
  ready: options.readyDriver
1598
1683
  });
1599
- if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
1684
+ if (driver === void 0) return notStarted("tool_missing", "no agent driver is ready; see `ork doctor`");
1600
1685
  const runtime = await options.runtimeFor(driver);
1601
- const ports = { human: context.owner === void 0 ? context.human : ownerFirst(context, context.owner, {
1686
+ const ports = { human: context.owner === void 0 ? withdrawnOnStop(context) : ownerFirst(context, context.owner, {
1602
1687
  runtimeFor: options.runtimeFor,
1603
1688
  driver: options.driver,
1604
1689
  readyDriver: options.readyDriver,
1605
1690
  inPlace: options.inPlace
1606
1691
  }) };
1607
1692
  if (context.hold !== void 0) ports.hold = context.hold;
1693
+ if (context.machine !== void 0) ports.machine = context.machine;
1608
1694
  if (context.ownership !== void 0) ports.ownership = context.ownership;
1609
1695
  if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
1610
1696
  if (context.claims !== void 0) ports.claims = context.claims;
1611
1697
  if (context.feedback !== void 0) ports.feedback = context.feedback;
1698
+ const whole = context.round > 1 ? wholeInput(context.input) : void 0;
1699
+ if (whole !== void 0) ports.input = whole;
1612
1700
  const watch = limitWatch(context);
1613
1701
  const run = async (asked) => {
1614
1702
  const watching = watch.session(asked);
1703
+ context.reserveBudget?.(asked.budgetUsd);
1615
1704
  const session = runtime.start(asked, ports);
1616
1705
  const stop = () => {
1617
1706
  session.interrupt();
@@ -1629,6 +1718,7 @@ const claudeExecutor = (options) => async (context) => {
1629
1718
  context.emit(event);
1630
1719
  }
1631
1720
  } finally {
1721
+ context.reserveBudget?.(0);
1632
1722
  disconnect?.();
1633
1723
  context.signal.removeEventListener("abort", stop);
1634
1724
  }
@@ -1691,10 +1781,11 @@ const claudeExecutor = (options) => async (context) => {
1691
1781
  };
1692
1782
  // ../nodes/src/gate.ts
1693
1783
  /**
1694
- * @file The `human.gate` node type: asks the human the rendered question,
1695
- * outputs their choice with the note they gave, and answers with the
1696
- * `default` when the `timeout` passes. The timeout counts only while the run
1697
- * and the gate are not paused (ADR-041).
1784
+ * @file The `human.gate` node type: asks the human the rendered question, with
1785
+ * its document if it has one, both in readable text (no raw JSON), outputs
1786
+ * their choice with the note they gave, and answers with the `default` when
1787
+ * the `timeout` passes. The timeout counts only while the run and the gate
1788
+ * are not paused (ADR-041).
1698
1789
  */
1699
1790
  /** A promise that never settles: the losing side of a race. */
1700
1791
  const NEVER = new Promise(() => {});
@@ -1716,8 +1807,13 @@ const gateExecutor = async (context) => {
1716
1807
  output: {},
1717
1808
  error: `${context.id} is not a gate`
1718
1809
  };
1719
- const template = parseTemplate(node.question);
1720
- const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
1810
+ const scope = runScope(context.inputs, context.outputs, context.runId);
1811
+ const render = (text) => {
1812
+ const template = parseTemplate(text);
1813
+ return template.ok ? renderTemplate(template.parts, scope, { readable: true }) : text;
1814
+ };
1815
+ const question = render(node.question);
1816
+ const document = render(node.document ?? "");
1721
1817
  const options = node.options.map((o) => choiceOf(o));
1722
1818
  const withdraw = new AbortController();
1723
1819
  context.signal.addEventListener("abort", () => {
@@ -1734,6 +1830,7 @@ const gateExecutor = async (context) => {
1734
1830
  }
1735
1831
  };
1736
1832
  if (node.recommended !== void 0) asked.recommended = node.recommended;
1833
+ if (document.trim() !== "") asked.document = document;
1737
1834
  const answer = context.human.ask(asked).then((choice) => ({
1738
1835
  choice,
1739
1836
  answeredBy: "user",
@@ -1752,21 +1849,22 @@ const gateExecutor = async (context) => {
1752
1849
  })];
1753
1850
  try {
1754
1851
  const output = await Promise.race([answer, ...expired]);
1755
- if (!options.some((o) => o.id === output.choice)) return {
1756
- ok: false,
1757
- output,
1758
- error: "no one answered the gate"
1759
- };
1852
+ if (!options.some((o) => o.id === output.choice)) {
1853
+ const facts = node.timeout === void 0 ? {} : { timeout: node.timeout };
1854
+ return endedWith({
1855
+ ok: false,
1856
+ output
1857
+ }, endFor("timed_out", facts, "no one answered the gate"));
1858
+ }
1760
1859
  return {
1761
1860
  ok: true,
1762
1861
  output
1763
1862
  };
1764
1863
  } catch {
1765
- return {
1864
+ return endedWith({
1766
1865
  ok: false,
1767
- output: {},
1768
- error: "the gate was withdrawn"
1769
- };
1866
+ output: {}
1867
+ }, endFor("interrupted", {}, "the gate was withdrawn"));
1770
1868
  } finally {
1771
1869
  answered.abort();
1772
1870
  }
@@ -1781,18 +1879,16 @@ const gateExecutor = async (context) => {
1781
1879
  */
1782
1880
  /** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
1783
1881
  const NO_LIMIT_MS = 864e5;
1784
- const failed = (error, output = {}) => ({
1882
+ const failed = (kind, facts, detail, output = {}) => endedWith({
1785
1883
  ok: false,
1786
- output,
1787
- error
1788
- });
1789
- /** A failure another try might not have, so a `watch:` looks again. */
1790
- const missed = (error, output = {}) => ({
1791
- ok: false,
1792
- transient: true,
1793
- output,
1794
- error
1795
- });
1884
+ output
1885
+ }, endFor(kind, facts, detail));
1886
+ /** How a call that threw ended: a sign-in, a server that won't start, or else. */
1887
+ const thrownKind = (error) => {
1888
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
1889
+ if (code === 401 || error?.constructor.name === "UnauthorizedError") return "auth";
1890
+ return code === "ENOENT" ? "tool_missing" : "command_failed";
1891
+ };
1796
1892
  /**
1797
1893
  * A string's templates rendered. A string that is one `{{ … }}` and nothing
1798
1894
  * else keeps its value's type, so a tool that asks for a number gets one.
@@ -1836,19 +1932,22 @@ const reasonOf = (error, server) => {
1836
1932
  };
1837
1933
  const mcpCallExecutor = (options = {}) => async (context) => {
1838
1934
  const { node } = context;
1839
- if (node.type !== "mcp.call") return failed(`${context.id} is not an MCP call`);
1935
+ if (node.type !== "mcp.call") {
1936
+ const said = `${context.id} is not an MCP call`;
1937
+ return failed("unknown", {}, said);
1938
+ }
1840
1939
  const environ = options.environ ?? process.env;
1841
1940
  const declared = readMcpServer(context.projectRoot, node.server);
1842
- if (!declared.ok) return failed(declared.error);
1941
+ if (!declared.ok) return failed("invalid_node", { reason: declared.error });
1843
1942
  const nodeEnv = renderEnv(node, context, environ);
1844
- if (!nodeEnv.ok) return failed(nodeEnv.error);
1943
+ if (!nodeEnv.ok) return failed("invalid_node", { reason: nodeEnv.error });
1845
1944
  const expanded = expandServer(declared.value, {
1846
1945
  ...environ,
1847
1946
  ...nodeEnv.values
1848
1947
  }, node.plainEnv);
1849
- if (!expanded.ok) return failed(expanded.error);
1948
+ if (!expanded.ok) return failed("invalid_node", { reason: expanded.error });
1850
1949
  const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
1851
- const scope = runScope(context.inputs, context.outputs, context.runId);
1950
+ const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
1852
1951
  const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
1853
1952
  const name = `${node.server}.${node.tool}`;
1854
1953
  context.emit({
@@ -1889,19 +1988,22 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1889
1988
  id: context.id,
1890
1989
  isError: reported || tooLong || problem !== null
1891
1990
  });
1892
- if (reported) return missed(`${name} failed: ${clipError(said)}`, output);
1893
- if (tooLong) return failed(`${name}'s result is over 64 KiB`, output);
1894
- return problem === null ? {
1991
+ if (reported) return failed("command_failed", { program: name }, said, output);
1992
+ if (tooLong) {
1993
+ const reason = `${name}'s result is over 64 KiB`;
1994
+ return failed("invalid_output", { reason }, void 0, output);
1995
+ }
1996
+ return problem === null ? endedWith({
1895
1997
  ok: true,
1896
1998
  output
1897
- } : failed(problem, output);
1999
+ }, endFor("done")) : failed("invalid_output", { reason: problem }, void 0, output);
1898
2000
  } catch (error) {
1899
2001
  context.emit({
1900
2002
  type: "tool.result",
1901
2003
  id: context.id,
1902
2004
  isError: true
1903
2005
  });
1904
- return missed(clipError(scrub(reasonOf(error, node.server))));
2006
+ return failed(thrownKind(error), { program: `MCP server \`${node.server}\`` }, clipError(scrub(reasonOf(error, node.server))));
1905
2007
  } finally {
1906
2008
  await client.close().catch(() => {});
1907
2009
  }
@@ -1912,13 +2014,39 @@ const mcpCallExecutor = (options = {}) => async (context) => {
1912
2014
  * text. A program runs in its own process group, so aborting it reaches
1913
2015
  * whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
1914
2016
  * a timeout therefore leaves nothing running, and an escaped process that
1915
- * keeps the output pipes open cannot hold the node.
2017
+ * keeps the output pipes open cannot hold the node. Off Windows it runs under
2018
+ * `sh`, whose `times` says the CPU the program and its children used, and at
2019
+ * the project's priority: `low` starts it below Ork's own (nice, and on macOS
2020
+ * utility QoS).
1916
2021
  */
1917
2022
  /** How long a program may take to end after SIGTERM. */
1918
2023
  const KILL_AFTER_MS = 2e3;
1919
2024
  /** How long to wait for the pipes to close after SIGKILL. */
1920
2025
  const CLOSE_AFTER_MS = 500;
1921
- const spawnTool = (program, args, { cwd, signal, env }) => {
2026
+ /**
2027
+ * Runs `$0 "$@"`, then writes the shell's `times` to fd 3: its children's user
2028
+ * and system time, the program's whole tree that it waited for. A program that
2029
+ * isn't there says so on fd 3, as spawn's ENOENT would.
2030
+ */
2031
+ const TIMED = String.raw`command -v "$0" >/dev/null 2>&1 || { echo ork-missing >&3; exit 127; }; "$0" "$@" 3>&-; s=$?; times >&3; exit $s`;
2032
+ const MISSING = "ork-missing";
2033
+ /** The children's line of `times` (`0m1.250s 0m0.300s`), in milliseconds. */
2034
+ const cpuOfTimes = (text) => {
2035
+ const [, , user, system] = [...text.matchAll(/(\d+)m([\d.]+)s/gu)].map(([, m = "0", s = "0"]) => Number(m) * 60 + Number(s));
2036
+ return user === void 0 || system === void 0 ? void 0 : {
2037
+ userMs: Math.round(user * 1e3),
2038
+ systemMs: Math.round(system * 1e3)
2039
+ };
2040
+ };
2041
+ const timed = process.platform !== "win32";
2042
+ /** What to start: the program, under `sh` to time it, at the priority. */
2043
+ const startOf = (program, args, priority) => timed ? startAt(priority, "sh", [
2044
+ "-c",
2045
+ TIMED,
2046
+ program,
2047
+ ...args
2048
+ ]) : [program, [...args]];
2049
+ const spawnTool = (program, args, { cwd, signal, env, priority }) => {
1922
2050
  if (signal.aborted) return Promise.resolve({
1923
2051
  exitCode: -1,
1924
2052
  stdout: "",
@@ -1928,7 +2056,9 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1928
2056
  const stdout = [];
1929
2057
  const stderr = [];
1930
2058
  const timers = [];
1931
- const child = spawn(program, args, {
2059
+ const times = [];
2060
+ const [bin, argv] = startOf(program, args, priority);
2061
+ const child = spawn(bin, argv, {
1932
2062
  cwd,
1933
2063
  detached: true,
1934
2064
  env: {
@@ -1938,9 +2068,15 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1938
2068
  stdio: [
1939
2069
  "ignore",
1940
2070
  "pipe",
2071
+ "pipe",
1941
2072
  "pipe"
1942
2073
  ]
1943
2074
  });
2075
+ lowerChild(priority, child.pid);
2076
+ const fd3 = child.stdio[3];
2077
+ if (fd3 instanceof Readable) fd3.setEncoding("utf8").on("data", (chunk) => {
2078
+ times.push(chunk);
2079
+ });
1944
2080
  const killGroup = (kill) => {
1945
2081
  if (child.pid === void 0) return;
1946
2082
  try {
@@ -1952,11 +2088,21 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1952
2088
  signal.removeEventListener("abort", abort);
1953
2089
  resolve(run);
1954
2090
  };
1955
- const collected = (exitCode) => ({
1956
- exitCode,
1957
- stdout: stdout.join(""),
1958
- stderr: stderr.join("")
1959
- });
2091
+ const collected = (exitCode) => {
2092
+ const said = times.join("");
2093
+ if (said.includes(MISSING)) return {
2094
+ exitCode: -1,
2095
+ stdout: stdout.join(""),
2096
+ stderr: `could not run ${program}: spawn ${program} ENOENT`
2097
+ };
2098
+ const cpu = cpuOfTimes(said);
2099
+ return {
2100
+ exitCode,
2101
+ stdout: stdout.join(""),
2102
+ stderr: stderr.join(""),
2103
+ ...cpu === void 0 ? {} : { cpu }
2104
+ };
2105
+ };
1960
2106
  const abort = () => {
1961
2107
  killGroup("SIGTERM");
1962
2108
  timers.push(setTimeout(() => {
@@ -1967,10 +2113,10 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
1967
2113
  }, KILL_AFTER_MS));
1968
2114
  };
1969
2115
  signal.addEventListener("abort", abort, { once: true });
1970
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
2116
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => {
1971
2117
  stdout.push(chunk);
1972
2118
  });
1973
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
2119
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => {
1974
2120
  stderr.push(chunk);
1975
2121
  });
1976
2122
  child.on("error", (error) => {
@@ -2130,4 +2276,4 @@ const missingPrograms = (workflow, installed = (program) => onPath(program)) =>
2130
2276
  }
2131
2277
  return [...missing.values()];
2132
2278
  };
2133
- export { resultOf as _, builtinExecutors as a, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, clearOutputFiles as g, sessionEnv as h, onPath as i, clearStateFiles as l, runScrubber as m, missingProgramMessage as n, spawnTool as o, runMarkerIn as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u };
2279
+ export { namedMcpEntries as _, builtinExecutors as a, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, clearOutputFiles as g, sessionEnv as h, onPath as i, clearStateFiles as l, runScrubber as m, missingProgramMessage as n, spawnTool as o, runMarkerIn as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u, resultOf as v };