@krapht/ork 0.2.0 → 0.3.0

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 (64) hide show
  1. package/THIRD_PARTY_NOTICES.md +7 -0
  2. package/dist/__build_info__-CXXMLvjt.mjs +104 -0
  3. package/dist/bin.mjs +859 -3037
  4. package/dist/context-BFGC_C02.mjs +435 -0
  5. package/dist/find-BcvhT6UN.mjs +47 -0
  6. package/dist/format-DqpObyMl.mjs +553 -0
  7. package/dist/in-a-run-BbLhZOFW.mjs +22 -0
  8. package/dist/limits-BKYAmqCR.mjs +44 -0
  9. package/dist/mcp-BWsdQyri.mjs +91 -0
  10. package/dist/output-Q2EdeVUt.mjs +856 -0
  11. package/dist/pause-DKACDfV1.mjs +65 -0
  12. package/dist/relay-CbK-Wme9.mjs +122 -0
  13. package/dist/report-DK4LGqwF.mjs +129 -0
  14. package/dist/send-C6yr04Fe.mjs +163 -0
  15. package/dist/{src-DAsOYtoR.mjs → src-CZOVF2yU.mjs} +496 -193
  16. package/dist/src-Cz1yOUTg.mjs +11841 -0
  17. package/dist/src-D5ruZFgD.mjs +1973 -0
  18. package/dist/src-DtN1k-B3.mjs +1899 -0
  19. package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
  20. package/dist/ui/assets/app-BlDdP396.js +61 -0
  21. package/dist/ui/assets/app-C722xNZ3.css +1 -0
  22. package/dist/ui/assets/geist-mono-cyrillic-400-normal-C51Di1Mf.woff2 +0 -0
  23. package/dist/ui/assets/geist-mono-cyrillic-400-normal-CPJFfJgk.woff +0 -0
  24. package/dist/ui/assets/geist-mono-cyrillic-500-normal-D2cXvDHF.woff2 +0 -0
  25. package/dist/ui/assets/geist-mono-cyrillic-500-normal-DfuvdVgn.woff +0 -0
  26. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-CkxHOdCb.woff +0 -0
  27. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-XB2BPmDK.woff2 +0 -0
  28. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-BY1KTvO5.woff +0 -0
  29. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-C7aYOB1D.woff2 +0 -0
  30. package/dist/ui/assets/geist-mono-latin-400-normal-B2yvC1Cq.woff +0 -0
  31. package/dist/ui/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
  32. package/dist/ui/assets/geist-mono-latin-500-normal-D-GG86Jb.woff +0 -0
  33. package/dist/ui/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
  34. package/dist/ui/assets/geist-mono-latin-ext-400-normal-CfzLURNc.woff +0 -0
  35. package/dist/ui/assets/geist-mono-latin-ext-400-normal-DI-rJ0UV.woff2 +0 -0
  36. package/dist/ui/assets/geist-mono-latin-ext-500-normal-Buglb9-a.woff2 +0 -0
  37. package/dist/ui/assets/geist-mono-latin-ext-500-normal-CrWEgvU_.woff +0 -0
  38. package/dist/ui/assets/geist-mono-symbols2-400-normal-BS9BGVxz.woff2 +0 -0
  39. package/dist/ui/assets/geist-mono-symbols2-400-normal-DjbiTEEA.woff +0 -0
  40. package/dist/ui/assets/geist-mono-symbols2-500-normal-CmnCPGa0.woff +0 -0
  41. package/dist/ui/assets/geist-mono-symbols2-500-normal-Q_ksOahU.woff2 +0 -0
  42. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CKEaXIlx.woff2 +0 -0
  43. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CZEPLOgu.woff +0 -0
  44. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Bs29RrUZ.woff2 +0 -0
  45. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Dx9-epDo.woff +0 -0
  46. package/dist/ui/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
  47. package/dist/ui/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
  48. package/dist/ui/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
  49. package/dist/ui/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
  50. package/dist/ui/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
  51. package/dist/ui/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
  52. package/dist/ui/assets/index-BiO54L90.css +1 -0
  53. package/dist/ui/assets/index-BoG6O-Lp.js +10 -0
  54. package/dist/ui/assets/theme-3xH5jxKs.js +1 -0
  55. package/dist/ui/favicon.ico +0 -0
  56. package/dist/ui/favicon.svg +4 -0
  57. package/dist/ui/index.html +30 -0
  58. package/dist/ui/ork-wordmark-small-on-light.svg +4 -0
  59. package/dist/ui/ork-wordmark-small.svg +4 -0
  60. package/dist/ui-CZS1XKla.mjs +160 -0
  61. package/dist/usage-BIjf9Xdt.mjs +418 -0
  62. package/dist/watch-B8o4Szd_.mjs +394 -0
  63. package/package.json +4 -2
  64. package/dist/src-CJ_zF0Zj.mjs +0 -5433
@@ -1,10 +1,312 @@
1
- import { S as FEEDBACK_TARGETS, b as FEEDBACK_KINDS, f as parseReport, x as FEEDBACK_SEVERITIES } from "./src-CJ_zF0Zj.mjs";
1
+ import { Ct as FEEDBACK_SEVERITIES, D as parseReport, St as FEEDBACK_KINDS, wt as FEEDBACK_TARGETS } from "./src-Cz1yOUTg.mjs";
2
2
  import { z } from "zod";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { createInterface } from "node:readline";
5
5
  import { spawn } from "node:child_process";
6
6
  import { createServer } from "node:http";
7
7
  import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
8
+ // ../runtime-claude/src/normalize.ts
9
+ /**
10
+ * @file Both drivers emit the same message JSON (the Agent SDK's messages are
11
+ * the CLI's `stream-json` lines), so one normalizer serves both.
12
+ */
13
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
14
+ const get = (value, key) => isObject(value) ? value[key] : void 0;
15
+ const num = (value) => typeof value === "number" ? value : 0;
16
+ const str = (value) => typeof value === "string" ? value : "";
17
+ const blocks = (message) => {
18
+ const content = get(get(message, "message"), "content");
19
+ return Array.isArray(content) ? content.filter((item) => isObject(item)) : [];
20
+ };
21
+ const usageOf = (result) => {
22
+ const usage = get(result, "usage");
23
+ return {
24
+ costUsd: num(result["total_cost_usd"]),
25
+ inputTokens: num(get(usage, "input_tokens")),
26
+ outputTokens: num(get(usage, "output_tokens")),
27
+ cacheReadTokens: num(get(usage, "cache_read_input_tokens")),
28
+ cacheWriteTokens: num(get(usage, "cache_creation_input_tokens")),
29
+ turns: num(result["num_turns"]),
30
+ durationMs: num(result["duration_ms"])
31
+ };
32
+ };
33
+ /** The outcome a `result` message describes. */
34
+ const outcomeOf = (result) => {
35
+ const errors = result["errors"];
36
+ return {
37
+ subtype: str(result["subtype"]) || "unknown",
38
+ sessionId: str(result["session_id"]),
39
+ report: parseReport(result["structured_output"]),
40
+ text: str(result["result"]),
41
+ usage: usageOf(result),
42
+ errors: Array.isArray(errors) ? errors.filter((e) => typeof e === "string") : []
43
+ };
44
+ };
45
+ /** The year 2286: a reset beyond this is not in seconds. */
46
+ const MAX_RESET_SECONDS = 1e10;
47
+ /**
48
+ * What a subscription's limit said (RFC-007). Only the fields Claude Code
49
+ * documents are read, so `utilization`, often absent, plays no part.
50
+ */
51
+ const planLimitOf = (info) => {
52
+ const status = get(info, "status");
53
+ if (status !== "allowed" && status !== "allowed_warning" && status !== "rejected") return [];
54
+ const window = get(info, "rateLimitType");
55
+ const resets = get(info, "resetsAt");
56
+ const overage = get(info, "overageStatus");
57
+ return [{
58
+ type: "plan.limit",
59
+ status: status === "rejected" && !(status === "rejected" && overage !== "allowed" && overage !== "allowed_warning") ? "allowed_warning" : status,
60
+ window: typeof window === "string" ? window : "plan",
61
+ ...typeof resets === "number" && resets > 0 && resets < MAX_RESET_SECONDS ? { resetsAt: (/* @__PURE__ */ new Date(resets * 1e3)).toISOString() } : {}
62
+ }];
63
+ };
64
+ /** Maps one driver message to zero or more normalized events. */
65
+ const normalize = (message) => {
66
+ if (!isObject(message)) return [];
67
+ switch (message["type"]) {
68
+ case "system": {
69
+ if (message["subtype"] !== "init") return [];
70
+ const started = {
71
+ type: "session.started",
72
+ sessionId: str(message["session_id"]),
73
+ model: str(message["model"])
74
+ };
75
+ if (str(message["cwd"]) !== "") started.cwd = str(message["cwd"]);
76
+ return [started];
77
+ }
78
+ case "assistant": return blocks(message).flatMap((block) => {
79
+ if (block["type"] === "text" && str(block["text"]).trim() !== "") return [{
80
+ type: "assistant.text",
81
+ text: str(block["text"])
82
+ }];
83
+ if (block["type"] === "tool_use") {
84
+ const input = block["input"];
85
+ return [{
86
+ type: "tool.called",
87
+ id: str(block["id"]),
88
+ name: str(block["name"]),
89
+ input: isObject(input) ? input : {}
90
+ }];
91
+ }
92
+ return [];
93
+ });
94
+ case "user": return blocks(message).filter((block) => block["type"] === "tool_result").map((block) => ({
95
+ type: "tool.result",
96
+ id: str(block["tool_use_id"]),
97
+ isError: block["is_error"] === true
98
+ }));
99
+ case "rate_limit_event": return planLimitOf(message["rate_limit_info"]);
100
+ case "result": return [{
101
+ type: "session.finished",
102
+ outcome: outcomeOf(message)
103
+ }];
104
+ default: return [];
105
+ }
106
+ };
107
+ /** Marks a session's start with the driver that runs it. */
108
+ const withDriver = (event, driver) => event.type === "session.started" ? {
109
+ ...event,
110
+ driver
111
+ } : event;
112
+ /** A finished event for a session the driver could not run. */
113
+ const driverFailure = (message) => ({
114
+ type: "session.finished",
115
+ outcome: {
116
+ subtype: "error_driver",
117
+ sessionId: "",
118
+ report: null,
119
+ text: "",
120
+ usage: {
121
+ costUsd: 0,
122
+ inputTokens: 0,
123
+ outputTokens: 0,
124
+ cacheReadTokens: 0,
125
+ cacheWriteTokens: 0,
126
+ turns: 0,
127
+ durationMs: 0
128
+ },
129
+ errors: [message]
130
+ }
131
+ });
132
+ /** How long a hook holds a call before it defers it instead. */
133
+ const HOLD_WINDOW_MS = 54e4;
134
+ /**
135
+ * Waits at a tool call while `hold` is held: "go" once it is released within
136
+ * the window (or at once when it is not held), "defer" after the window.
137
+ */
138
+ const holdAtTool = async (hold, signal, windowMs = HOLD_WINDOW_MS) => {
139
+ if (!hold.held()) return "go";
140
+ const window = new AbortController();
141
+ const timer = setTimeout(() => {
142
+ window.abort();
143
+ }, windowMs);
144
+ try {
145
+ await hold.wait("tool", AbortSignal.any([signal, window.signal]));
146
+ } finally {
147
+ clearTimeout(timer);
148
+ }
149
+ return hold.held() && !signal.aborted ? "defer" : "go";
150
+ };
151
+ /** The hook's answer: nothing to say for "go", the deferral otherwise. */
152
+ const hookOutput = (answer) => answer === "go" ? {} : { hookSpecificOutput: {
153
+ hookEventName: "PreToolUse",
154
+ permissionDecision: "defer",
155
+ permissionDecisionReason: "Ork paused this node; it goes on from this call when it continues."
156
+ } };
157
+ /** Whether a stream-json message is the result of a deferred tool call. */
158
+ const isDeferredResult = (message) => typeof message === "object" && message !== null && "type" in message && message.type === "result" && "stop_reason" in message && message.stop_reason === "tool_deferred";
159
+ /**
160
+ * Two legs of one session, as one: the tokens of both, and the later leg's
161
+ * cost, which is already the session's total (ADR-046).
162
+ */
163
+ const addUsage = (a, b) => ({
164
+ costUsd: Math.max(a.costUsd, b.costUsd),
165
+ inputTokens: a.inputTokens + b.inputTokens,
166
+ outputTokens: a.outputTokens + b.outputTokens,
167
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
168
+ cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
169
+ turns: a.turns + b.turns,
170
+ durationMs: a.durationMs + b.durationMs
171
+ });
172
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
173
+ /**
174
+ * The node's `--settings` with Ork's `PreToolUse` HTTP hook added: before every
175
+ * tool call, pre-approved ones included, the session asks whether its node may
176
+ * go on.
177
+ */
178
+ const withHoldHook = (settings, holdUrl, token) => {
179
+ const hooks = isRecord(settings?.["hooks"]) ? settings["hooks"] : {};
180
+ const listed = hooks["PreToolUse"];
181
+ const before = Array.isArray(listed) ? [...listed] : [];
182
+ const hook = {
183
+ type: "http",
184
+ url: holdUrl,
185
+ headers: { Authorization: `Bearer ${token}` },
186
+ timeout: 600
187
+ };
188
+ return {
189
+ ...settings,
190
+ hooks: {
191
+ ...hooks,
192
+ PreToolUse: [...before, {
193
+ matcher: "*",
194
+ hooks: [hook]
195
+ }]
196
+ }
197
+ };
198
+ };
199
+ /**
200
+ * What one stream-json message of a leg yields, and how the leg ends if it
201
+ * does: a result is the session's end, unless the hold hook deferred a call in
202
+ * this leg; a resumed leg's start is not news, and its end carries what the
203
+ * legs before it spent.
204
+ */
205
+ const legStep = (message, input) => {
206
+ const deferred = input.deferred && isDeferredResult(message);
207
+ const events = [];
208
+ let ended;
209
+ for (const event of normalize(message)) {
210
+ if (event.type === "session.started" && input.carried !== void 0) continue;
211
+ if (event.type !== "session.finished") events.push(withDriver(event, input.driver));
212
+ else if (deferred) ended = {
213
+ kind: "deferred",
214
+ sessionId: event.outcome.sessionId,
215
+ usage: event.outcome.usage,
216
+ last: withDriver(event, input.driver)
217
+ };
218
+ else {
219
+ const usage = input.carried === void 0 ? event.outcome.usage : addUsage(input.carried, event.outcome.usage);
220
+ events.push(withDriver({
221
+ ...event,
222
+ outcome: {
223
+ ...event.outcome,
224
+ usage
225
+ }
226
+ }, input.driver));
227
+ ended = { kind: "finished" };
228
+ }
229
+ }
230
+ return ended === void 0 ? { events } : {
231
+ events,
232
+ ended
233
+ };
234
+ };
235
+ // ../runtime-claude/src/cli-leg.ts
236
+ /**
237
+ * @file One `claude -p` process of a CLI-driven session: it writes the queued
238
+ * stream-json lines, yields the normalized events, and says how it ended. A
239
+ * session is one leg, or several when a paused node's tool call was deferred
240
+ * and the session resumed (ADR-041); a resumed leg carries what the legs
241
+ * before it spent, and its start is not news.
242
+ */
243
+ const STDERR_LINES = 20;
244
+ const parseLine = (line) => {
245
+ try {
246
+ return JSON.parse(line);
247
+ } catch {
248
+ return;
249
+ }
250
+ };
251
+ /** Runs one `claude` process, yielding its events; returns how it ended. */
252
+ const runLeg = async function* (input) {
253
+ const child = spawn(input.bin, input.args, {
254
+ cwd: input.cwd,
255
+ env: { ...input.env },
256
+ stdio: [
257
+ "pipe",
258
+ "pipe",
259
+ "pipe"
260
+ ]
261
+ });
262
+ const exited = new Promise((resolve) => {
263
+ child.on("close", (code) => {
264
+ resolve(code);
265
+ });
266
+ });
267
+ const spawnError = new Promise((resolve) => {
268
+ child.on("error", resolve);
269
+ });
270
+ const stderr = [];
271
+ child.stderr.setEncoding("utf8");
272
+ child.stderr.on("data", (chunk) => {
273
+ stderr.push(...chunk.split("\n").filter((l) => l.trim() !== ""));
274
+ stderr.splice(0, Math.max(0, stderr.length - STDERR_LINES));
275
+ });
276
+ const write = (line) => {
277
+ if (child.stdin.writable) child.stdin.write(line);
278
+ };
279
+ input.attach({
280
+ write,
281
+ stop: () => {
282
+ child.kill("SIGINT");
283
+ }
284
+ });
285
+ for (const line of input.pending.splice(0)) write(line);
286
+ let outcome;
287
+ try {
288
+ for await (const line of createInterface({ input: child.stdout })) {
289
+ const { events, ended } = legStep(parseLine(line), {
290
+ carried: input.carried,
291
+ deferred: input.deferred(),
292
+ driver: "cli"
293
+ });
294
+ yield* events;
295
+ if (ended !== void 0) {
296
+ child.stdin.end();
297
+ outcome = ended;
298
+ }
299
+ }
300
+ const code = await Promise.race([exited, spawnError.then((error) => error)]);
301
+ return outcome ?? {
302
+ kind: "failed",
303
+ reason: code instanceof Error ? `could not start ${input.bin}: ${code.message}` : stderr.at(-1) ?? `claude exited with code ${String(code)}`
304
+ };
305
+ } finally {
306
+ input.attach();
307
+ if (child.exitCode === null) child.kill();
308
+ }
309
+ };
8
310
  /** The name Claude Code gives an Ork tool: `mcp__ork__<name>`. */
9
311
  const toolId = (name) => `mcp__ork__${name}`;
10
312
  const text = (value) => ({ content: [{
@@ -30,9 +332,11 @@ const defineTool = (definition) => {
30
332
  * @file A minimal MCP server over the streamable HTTP transport, for the
31
333
  * `claude` CLI driver. It answers with plain JSON (no SSE stream), which the
32
334
  * transport allows, and implements only what Ork needs: `initialize`, `ping`,
33
- * `tools/list` and `tools/call`.
335
+ * `tools/list` and `tools/call`. Beside it, `/hold` answers the session's
336
+ * `PreToolUse` HTTP hook, which waits while the node is paused (ADR-041).
34
337
  */
35
338
  const PATH = "/mcp";
339
+ const HOLD_PATH = "/hold";
36
340
  const MAX_BODY = 1048576;
37
341
  const MCP_PROTOCOL_FALLBACK = "2025-06-18";
38
342
  const JsonRpcSchema = z.object({
@@ -115,11 +419,32 @@ const respond = (res, status, body) => {
115
419
  }
116
420
  res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
117
421
  };
118
- const serve = async (req, res, tools, token) => {
422
+ /** The hook's POST: waits for `hold`; a closed connection gives up. */
423
+ const serveHold = async (req, res, hold) => {
424
+ if (req.method !== "POST") {
425
+ respond(res, 405);
426
+ return;
427
+ }
428
+ if (await readBody(req) === null) {
429
+ respond(res, 413);
430
+ return;
431
+ }
432
+ const gone = new AbortController();
433
+ res.once("close", () => {
434
+ gone.abort();
435
+ });
436
+ const output = await hold(gone.signal);
437
+ if (!gone.signal.aborted) respond(res, 200, output);
438
+ };
439
+ const serve = async (req, res, tools, token, hold) => {
119
440
  if (!authorized(req, token)) {
120
441
  respond(res, 401);
121
442
  return;
122
443
  }
444
+ if (req.url === HOLD_PATH && hold !== void 0) {
445
+ await serveHold(req, res, hold);
446
+ return;
447
+ }
123
448
  if (req.url !== PATH) {
124
449
  respond(res, 404);
125
450
  return;
@@ -156,10 +481,10 @@ const serve = async (req, res, tools, token) => {
156
481
  * Serves Ork's tools to one `claude` CLI session: on 127.0.0.1 only, on a
157
482
  * random port, behind a per-session bearer token.
158
483
  */
159
- const startOrkMcpHttp = async (tools) => {
484
+ const startOrkMcpHttp = async (tools, hold) => {
160
485
  const token = randomBytes(24).toString("hex");
161
486
  const http = createServer((req, res) => {
162
- serve(req, res, tools, token).catch(() => {
487
+ serve(req, res, tools, token, hold).catch(() => {
163
488
  if (!res.headersSent) respond(res, 500);
164
489
  });
165
490
  });
@@ -167,8 +492,10 @@ const startOrkMcpHttp = async (tools) => {
167
492
  http.listen(0, "127.0.0.1", resolve);
168
493
  });
169
494
  const address = http.address();
495
+ const port = typeof address === "object" && address !== null ? address.port : 0;
170
496
  return {
171
- url: `http://127.0.0.1:${typeof address === "object" && address !== null ? address.port : 0}${PATH}`,
497
+ url: `http://127.0.0.1:${port}${PATH}`,
498
+ holdUrl: `http://127.0.0.1:${port}${HOLD_PATH}`,
172
499
  token,
173
500
  close: () => new Promise((resolve) => {
174
501
  http.closeAllConnections();
@@ -178,110 +505,6 @@ const startOrkMcpHttp = async (tools) => {
178
505
  })
179
506
  };
180
507
  };
181
- // ../runtime-claude/src/normalize.ts
182
- /**
183
- * @file Both drivers emit the same message JSON (the Agent SDK's messages are
184
- * the CLI's `stream-json` lines), so one normalizer serves both.
185
- */
186
- const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
187
- const get = (value, key) => isObject(value) ? value[key] : void 0;
188
- const num = (value) => typeof value === "number" ? value : 0;
189
- const str = (value) => typeof value === "string" ? value : "";
190
- const blocks = (message) => {
191
- const content = get(get(message, "message"), "content");
192
- return Array.isArray(content) ? content.filter((item) => isObject(item)) : [];
193
- };
194
- const usageOf = (result) => {
195
- const usage = get(result, "usage");
196
- return {
197
- costUsd: num(result["total_cost_usd"]),
198
- inputTokens: num(get(usage, "input_tokens")),
199
- outputTokens: num(get(usage, "output_tokens")),
200
- cacheReadTokens: num(get(usage, "cache_read_input_tokens")),
201
- cacheWriteTokens: num(get(usage, "cache_creation_input_tokens")),
202
- turns: num(result["num_turns"]),
203
- durationMs: num(result["duration_ms"])
204
- };
205
- };
206
- /** The outcome a `result` message describes. */
207
- const outcomeOf = (result) => {
208
- const errors = result["errors"];
209
- return {
210
- subtype: str(result["subtype"]) || "unknown",
211
- sessionId: str(result["session_id"]),
212
- report: parseReport(result["structured_output"]),
213
- text: str(result["result"]),
214
- usage: usageOf(result),
215
- errors: Array.isArray(errors) ? errors.filter((e) => typeof e === "string") : []
216
- };
217
- };
218
- /** Maps one driver message to zero or more normalized events. */
219
- const normalize = (message) => {
220
- if (!isObject(message)) return [];
221
- switch (message["type"]) {
222
- case "system": {
223
- if (message["subtype"] !== "init") return [];
224
- const started = {
225
- type: "session.started",
226
- sessionId: str(message["session_id"]),
227
- model: str(message["model"])
228
- };
229
- if (str(message["cwd"]) !== "") started.cwd = str(message["cwd"]);
230
- return [started];
231
- }
232
- case "assistant": return blocks(message).flatMap((block) => {
233
- if (block["type"] === "text" && str(block["text"]).trim() !== "") return [{
234
- type: "assistant.text",
235
- text: str(block["text"])
236
- }];
237
- if (block["type"] === "tool_use") {
238
- const input = block["input"];
239
- return [{
240
- type: "tool.called",
241
- id: str(block["id"]),
242
- name: str(block["name"]),
243
- input: isObject(input) ? input : {}
244
- }];
245
- }
246
- return [];
247
- });
248
- case "user": return blocks(message).filter((block) => block["type"] === "tool_result").map((block) => ({
249
- type: "tool.result",
250
- id: str(block["tool_use_id"]),
251
- isError: block["is_error"] === true
252
- }));
253
- case "result": return [{
254
- type: "session.finished",
255
- outcome: outcomeOf(message)
256
- }];
257
- default: return [];
258
- }
259
- };
260
- /** Marks a session's start with the driver that runs it. */
261
- const withDriver = (event, driver) => event.type === "session.started" ? {
262
- ...event,
263
- driver
264
- } : event;
265
- /** A finished event for a session the driver could not run. */
266
- const driverFailure = (message) => ({
267
- type: "session.finished",
268
- outcome: {
269
- subtype: "error_driver",
270
- sessionId: "",
271
- report: null,
272
- text: "",
273
- usage: {
274
- costUsd: 0,
275
- inputTokens: 0,
276
- outputTokens: 0,
277
- cacheReadTokens: 0,
278
- cacheWriteTokens: 0,
279
- turns: 0,
280
- durationMs: 0
281
- },
282
- errors: [message]
283
- }
284
- });
285
508
  // ../runtime-claude/src/tools/ask-user.ts
286
509
  const askUserTool = defineTool({
287
510
  name: "ask_user",
@@ -428,13 +651,25 @@ const feedbackTool = defineTool({
428
651
  * session gets a one-line notice; `answer` replies to a question Ork asked an
429
652
  * owner about a node it owns. Only sessions inside a run get them.
430
653
  */
431
- /** Peers are mutual: a node no one may write to has no inbox to read. */
432
- const hasPeers = ({ ports }) => (ports.mailbox?.peers().length ?? 0) > 0;
654
+ /**
655
+ * Peers are mutual: a node no one may write to has no inbox to read, unless it
656
+ * may be linked later, since a session's tools are set when it starts.
657
+ */
658
+ const hasPeers = ({ ports }) => (ports.mailbox?.peers().length ?? 0) > 0 || ports.mailbox?.linkable() === true;
659
+ /** A program or a rule may write to a node with no one to write to itself. */
660
+ const hasInbox = (context) => hasPeers(context) || context.ports.mailbox?.hears() === true;
661
+ const MAX_WAIT_SECONDS = 3600;
662
+ const DEFAULT_WAIT_SECONDS = 300;
663
+ const inboxText = (unread) => JSON.stringify({ messages: unread.map((m) => ({
664
+ from: m.from,
665
+ ...m.via === void 0 ? {} : { via: m.via },
666
+ body: m.body
667
+ })) });
433
668
  const sendMessageTool = defineTool({
434
669
  name: "send_message",
435
- description: "Send a short message to another node in this run: your owner, a node you own, or one you share a declared channel with. It sees a notice and reads the message with read_inbox. Use it for what they need to know now; your report still goes to your owner at the end.",
670
+ description: "Send a short message to a node you can reach: your owner, a node you own, one you share a declared channel with, or a node of another run the user linked you to. It sees a notice and reads the message with read_inbox. Use it for what they need to know now; your report still goes to your owner at the end.",
436
671
  shape: {
437
- to: z.string().describe("The node's id."),
672
+ to: z.string().describe("The node's id, or <run>/<node> for a linked node."),
438
673
  body: z.string().min(1).describe("Self-contained: the recipient does not see your work.")
439
674
  },
440
675
  available: hasPeers,
@@ -442,20 +677,29 @@ const sendMessageTool = defineTool({
442
677
  const sent = ports.mailbox?.send(args.to, args.body);
443
678
  if (sent === void 0) return Promise.resolve(text("No message bus."));
444
679
  if (!sent.ok) return Promise.resolve(text(`Not sent: ${sent.error}.`));
445
- return Promise.resolve(text(sent.delivered === "now" ? `Sent to ${args.to}; it sees a notice at its next step.` : `Sent to ${args.to}; it is not running now and sees the message when it next starts.`));
680
+ return Promise.resolve(text(sent.delivered === "now" ? `Sent to ${args.to}; it sees a notice at its next step.` : `Sent to ${args.to}; it is not running now and sees the message if it starts again.`));
446
681
  }
447
682
  });
448
683
  const readInboxTool = defineTool({
449
684
  name: "read_inbox",
450
- description: "Read your unread messages from other nodes in this run, oldest first. Ork tells you when one arrives.",
685
+ description: "Read your unread messages from other nodes, oldest first. Ork tells you when one arrives.",
451
686
  shape: {},
452
- available: hasPeers,
687
+ available: hasInbox,
453
688
  run: ({ ports }) => {
454
689
  const unread = ports.mailbox?.read() ?? [];
455
- return Promise.resolve(text(unread.length === 0 ? "No unread messages." : JSON.stringify({ messages: unread.map((m) => ({
456
- from: m.from,
457
- body: m.body
458
- })) })));
690
+ return Promise.resolve(text(unread.length === 0 ? "No unread messages." : inboxText(unread)));
691
+ }
692
+ });
693
+ const waitForMessagesTool = defineTool({
694
+ name: "wait_for_messages",
695
+ description: "Wait for a message from another node when you cannot go on without its reply, instead of ending your session. Returns what arrived, or says none did in time. You hold no session slot while you wait.",
696
+ shape: { timeoutSeconds: z.number().int().positive().max(MAX_WAIT_SECONDS).optional().describe(`How long to wait. Default: ${String(DEFAULT_WAIT_SECONDS)}.`) },
697
+ available: hasInbox,
698
+ run: async ({ ports }, args) => {
699
+ const seconds = args.timeoutSeconds ?? DEFAULT_WAIT_SECONDS;
700
+ await ports.mailbox?.wait(seconds * 1e3);
701
+ const unread = ports.mailbox?.read() ?? [];
702
+ return text(unread.length === 0 ? `No message yet, after waiting up to ${String(seconds)} seconds. Ork may have told you something; if not, wait again or go on.` : inboxText(unread));
459
703
  }
460
704
  });
461
705
  const answerTool = defineTool({
@@ -508,6 +752,7 @@ const ORK_TOOLS = [
508
752
  spawnWorkerTool,
509
753
  sendMessageTool,
510
754
  readInboxTool,
755
+ waitForMessagesTool,
511
756
  answerTool,
512
757
  claimTool,
513
758
  releaseTool,
@@ -519,11 +764,6 @@ const orkTools = (context) => ORK_TOOLS.flatMap((tool) => tool.bind(context) ??
519
764
  const preapprovedTools = (tools) => tools.filter((t) => t.preapproved).map((t) => toolId(t.name));
520
765
  // ../runtime-claude/src/cli.ts
521
766
  /**
522
- * @file The CLI driver: runs a session through the user's own `claude -p` in
523
- * stream-json mode, with Ork's tools served by a local MCP server.
524
- */
525
- const STDERR_LINES = 20;
526
- /**
527
767
  * Per-request timeout for Ork's MCP server, in ms. The CLI otherwise gives an
528
768
  * HTTP server's request 60 seconds and drops a call silent for 5 minutes, and
529
769
  * `delegate` waits for whole child sessions (the per-server `timeout` raises
@@ -582,16 +822,10 @@ const buildCliArgs = (spec, mcp, allowed) => {
582
822
  if (spec.budgetUsd !== void 0) args.push("--max-budget-usd", String(spec.budgetUsd));
583
823
  if (spec.resume !== void 0) args.push("--resume", spec.resume);
584
824
  for (const dir of spec.plugins ?? []) args.push("--plugin-dir", dir);
585
- if (spec.settings !== void 0) args.push("--settings", JSON.stringify(spec.settings));
825
+ const settings = mcp.holdUrl === void 0 ? spec.settings : withHoldHook(spec.settings, mcp.holdUrl, mcp.token);
826
+ if (settings !== void 0) args.push("--settings", JSON.stringify(settings));
586
827
  return args;
587
828
  };
588
- const parseLine = (line) => {
589
- try {
590
- return JSON.parse(line);
591
- } catch {
592
- return;
593
- }
594
- };
595
829
  /**
596
830
  * Drives a session with the user's own `claude` CLI in print mode, logged in by
597
831
  * the user. Ork never reads or passes its credentials.
@@ -600,63 +834,62 @@ const createCliRuntime = (options = {}) => ({
600
834
  driver: "cli",
601
835
  start: (spec, ports) => {
602
836
  const pending = [userLine(spec.prompt)];
603
- let write;
604
- let stop;
837
+ let io;
838
+ const ended = new AbortController();
839
+ /** The hold hook deferred a call in the running process. */
840
+ let deferring = false;
841
+ const { hold } = ports;
605
842
  const events = async function* () {
606
843
  const tools = orkTools({
607
844
  nodeId: spec.nodeId,
608
845
  driver: "cli",
609
846
  ports
610
847
  });
611
- const mcp = await startOrkMcpHttp(tools);
612
- const args = buildCliArgs(spec, mcp, preapprovedTools(tools));
613
- const child = spawn(options.bin ?? "claude", args, {
614
- cwd: spec.cwd,
615
- env: { ...options.env ?? process.env },
616
- stdio: [
617
- "pipe",
618
- "pipe",
619
- "pipe"
620
- ]
621
- });
622
- const exited = new Promise((resolve) => {
623
- child.on("close", (code) => {
624
- resolve(code);
625
- });
626
- });
627
- const spawnError = new Promise((resolve) => {
628
- child.on("error", resolve);
848
+ const mcp = await startOrkMcpHttp(tools, hold === void 0 ? void 0 : async (gone) => {
849
+ const answer = await holdAtTool(hold, AbortSignal.any([gone, ended.signal]), options.holdWindowMs);
850
+ if (answer === "defer") deferring = true;
851
+ return hookOutput(answer);
629
852
  });
630
- const stderr = [];
631
- child.stderr.setEncoding("utf8");
632
- child.stderr.on("data", (chunk) => {
633
- stderr.push(...chunk.split("\n").filter((l) => l.trim() !== ""));
634
- stderr.splice(0, Math.max(0, stderr.length - STDERR_LINES));
853
+ const allowed = preapprovedTools(tools);
854
+ const endpoint = hold === void 0 ? {
855
+ url: mcp.url,
856
+ token: mcp.token
857
+ } : mcp;
858
+ const legOf = (args, carried) => runLeg({
859
+ bin: options.bin ?? "claude",
860
+ env: options.env ?? process.env,
861
+ cwd: spec.cwd,
862
+ args,
863
+ carried,
864
+ pending,
865
+ deferred: () => deferring,
866
+ attach: (attached) => {
867
+ io = attached;
868
+ }
635
869
  });
636
- write = (line) => {
637
- if (child.stdin.writable) child.stdin.write(line);
638
- };
639
- stop = () => {
640
- child.kill("SIGINT");
641
- };
642
- for (const line of pending.splice(0)) write(line);
643
- let finished = false;
870
+ let args = buildCliArgs(spec, endpoint, allowed);
871
+ let spent;
644
872
  try {
645
- const lines = createInterface({ input: child.stdout });
646
- for await (const line of lines) for (const event of normalize(parseLine(line))) {
647
- if (event.type === "session.finished") {
648
- finished = true;
649
- child.stdin.end();
873
+ for (;;) {
874
+ const leg = yield* legOf(args, spent);
875
+ if (leg.kind === "failed") {
876
+ yield driverFailure(leg.reason);
877
+ return;
650
878
  }
651
- yield withDriver(event, "cli");
652
- }
653
- const code = await Promise.race([exited, spawnError.then((error) => error)]);
654
- if (!finished) {
655
- const reason = code instanceof Error ? `could not start ${options.bin ?? "claude"}: ${code.message}` : stderr.at(-1) ?? `claude exited with code ${String(code)}`;
656
- yield driverFailure(reason);
879
+ if (leg.kind === "finished") return;
880
+ deferring = false;
881
+ spent = spent === void 0 ? leg.usage : addUsage(spent, leg.usage);
882
+ await hold?.wait("tool", ended.signal);
883
+ if (ended.signal.aborted) {
884
+ yield leg.last;
885
+ return;
886
+ }
887
+ args = buildCliArgs({
888
+ ...spec,
889
+ resume: leg.sessionId
890
+ }, endpoint, allowed);
657
891
  }
658
892
  } finally {
659
- if (child.exitCode === null) child.kill();
660
893
  await mcp.close();
661
894
  }
662
895
  };
@@ -664,11 +897,12 @@ const createCliRuntime = (options = {}) => ({
664
897
  events: events(),
665
898
  send: (text) => {
666
899
  const line = userLine(text, true);
667
- if (write === void 0) pending.push(line);
668
- else write(line);
900
+ if (io === void 0) pending.push(line);
901
+ else io.write(line);
669
902
  },
670
903
  interrupt: () => {
671
- stop?.();
904
+ ended.abort();
905
+ io?.stop();
672
906
  return Promise.resolve();
673
907
  }
674
908
  };
@@ -721,7 +955,10 @@ const createQueue = () => {
721
955
  // ../runtime-claude/src/sdk.ts
722
956
  /**
723
957
  * @file The SDK driver: runs a session in-process with the Claude Agent SDK,
724
- * only with an API key or a cloud provider, never a claude.ai login.
958
+ * only with an API key or a cloud provider, never a claude.ai login. A node
959
+ * that can be paused gets a `PreToolUse` callback hook: a short pause holds
960
+ * the call, a long one defers it and resumes the session when the node
961
+ * continues (ADR-041).
725
962
  */
726
963
  /**
727
964
  * The SDK driver runs only with an API key or a cloud provider. It must never
@@ -747,8 +984,24 @@ const userMessage = (text, later = false) => ({
747
984
  ...later ? { priority: "next" } : {}
748
985
  });
749
986
  const definedEnv = (env) => Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0));
987
+ /**
988
+ * The hold hook: waits before each tool call while the node is paused, and
989
+ * defers the call (`onDefer`) when the pause outlasts the window.
990
+ */
991
+ const holdHook = (hold, onDefer, windowMs) => ({
992
+ timeout: 600,
993
+ hooks: [async (_input, _toolUseId, { signal }) => {
994
+ if (await holdAtTool(hold, signal, windowMs) === "go") return {};
995
+ onDefer();
996
+ return { hookSpecificOutput: {
997
+ hookEventName: "PreToolUse",
998
+ permissionDecision: "defer",
999
+ permissionDecisionReason: "Ork paused this node; it goes on from this call when it continues."
1000
+ } };
1001
+ }]
1002
+ });
750
1003
  /** The Agent SDK options for a session spec. */
751
- const buildSdkOptions = (spec, ports, env) => {
1004
+ const buildSdkOptions = (spec, ports, env, held) => {
752
1005
  const { human } = ports;
753
1006
  const tools = orkTools({
754
1007
  nodeId: spec.nodeId,
@@ -804,28 +1057,77 @@ const buildSdkOptions = (spec, ports, env) => {
804
1057
  path: dir
805
1058
  }));
806
1059
  if (spec.settings !== void 0) options.settings = spec.settings;
1060
+ if (ports.hold !== void 0 && held !== void 0) options.hooks = { PreToolUse: [holdHook(ports.hold, held.onDefer, held.windowMs)] };
807
1061
  return options;
808
1062
  };
809
1063
  /** Drives a session with the Claude Agent SDK, in this process. */
810
- const createSdkRuntime = (env = process.env) => ({
1064
+ const createSdkRuntime = (env = process.env, holdWindowMs) => ({
811
1065
  driver: "sdk",
812
1066
  start: (spec, ports) => {
813
- const input = createQueue();
1067
+ let input = createQueue();
814
1068
  input.push(userMessage(spec.prompt));
815
1069
  let current;
1070
+ const ended = new AbortController();
1071
+ /** The hold hook deferred a call in the current query. */
1072
+ let deferring = false;
1073
+ const held = {
1074
+ onDefer: () => {
1075
+ deferring = true;
1076
+ },
1077
+ ...holdWindowMs === void 0 ? {} : { windowMs: holdWindowMs }
1078
+ };
1079
+ /** One query: a leg of the session, until its result. */
1080
+ const leg = async function* (resume, carried) {
1081
+ deferring = false;
1082
+ const legSpec = resume === void 0 ? spec : {
1083
+ ...spec,
1084
+ resume
1085
+ };
1086
+ current = query({
1087
+ prompt: input,
1088
+ options: buildSdkOptions(legSpec, ports, env, held)
1089
+ });
1090
+ let outcome = {
1091
+ kind: "failed",
1092
+ reason: "the session ended early"
1093
+ };
1094
+ for await (const message of current) {
1095
+ const { events, ended: end } = legStep(message, {
1096
+ carried,
1097
+ deferred: deferring,
1098
+ driver: "sdk"
1099
+ });
1100
+ yield* events;
1101
+ if (end !== void 0) {
1102
+ input.close();
1103
+ outcome = end;
1104
+ }
1105
+ }
1106
+ return outcome;
1107
+ };
816
1108
  const events = async function* () {
817
1109
  if (sdkCredentials(env) === null) {
818
1110
  yield driverFailure(NO_SDK_CREDENTIALS);
819
1111
  return;
820
1112
  }
1113
+ let resume = spec.resume;
1114
+ let spent;
821
1115
  try {
822
- current = query({
823
- prompt: input,
824
- options: buildSdkOptions(spec, ports, env)
825
- });
826
- for await (const message of current) for (const event of normalize(message)) {
827
- if (event.type === "session.finished") input.close();
828
- yield withDriver(event, "sdk");
1116
+ for (;;) {
1117
+ const result = yield* leg(resume, spent);
1118
+ if (result.kind === "failed") {
1119
+ yield driverFailure(result.reason);
1120
+ return;
1121
+ }
1122
+ if (result.kind === "finished") return;
1123
+ input = createQueue();
1124
+ spent = spent === void 0 ? result.usage : addUsage(spent, result.usage);
1125
+ await ports.hold?.wait("tool", ended.signal);
1126
+ if (ended.signal.aborted) {
1127
+ yield result.last;
1128
+ return;
1129
+ }
1130
+ resume = result.sessionId;
829
1131
  }
830
1132
  } catch (error) {
831
1133
  input.close();
@@ -838,6 +1140,7 @@ const createSdkRuntime = (env = process.env) => ({
838
1140
  input.push(userMessage(text, true));
839
1141
  },
840
1142
  interrupt: async () => {
1143
+ ended.abort();
841
1144
  await current?.interrupt();
842
1145
  }
843
1146
  };