@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.
- package/README.md +3 -4
- package/dist/{__build_info__-D9lSlMFy.mjs → __build_info__-CIchF6nr.mjs} +2 -2
- package/dist/bin.mjs +1479 -600
- package/dist/chips-BA7SrrH5.mjs +43 -0
- package/dist/{clean-CA_Oi2Wl.mjs → clean-Di-KT7Mf.mjs} +5 -2
- package/dist/{completion-DBGfL-gN.mjs → completion-C5D0sEs5.mjs} +23 -16
- package/dist/context-BaNshWr9.mjs +695 -0
- package/dist/{decide-CoIJh34e.mjs → decide-B6PnKeqo.mjs} +3 -3
- package/dist/{find-rBBk7Jui.mjs → find-BicBbGMg.mjs} +1 -1
- package/dist/format-lWhN-jYR.mjs +286 -0
- package/dist/{in-a-run-DdAakpRR.mjs → in-a-run-CgkVouyG.mjs} +1 -1
- package/dist/{limits-BPM9wUlV.mjs → limits-jZQp9ZJC.mjs} +1 -1
- package/dist/{list-Bv5Lxu7z.mjs → list-CEaJ07jd.mjs} +11 -4
- package/dist/{mcp-DM30MFBw.mjs → mcp-DYgVA9f5.mjs} +6 -6
- package/dist/output-CM95njYt.mjs +421 -0
- package/dist/{pause-C7lJkSma.mjs → pause-D6PuSlNl.mjs} +4 -4
- package/dist/{relay-BipJYszF.mjs → relay-DNx7be9k.mjs} +2 -2
- package/dist/{remove-BDs9isB6.mjs → remove-CpMb4NDl.mjs} +14 -7
- package/dist/{report-DK4LGqwF.mjs → report-CEN8VoUb.mjs} +31 -20
- package/dist/{send-nbkjf3sc.mjs → send-CLs_fyg7.mjs} +3 -3
- package/dist/{src-BxUJPGa5.mjs → src-9iGkjnsy.mjs} +487 -341
- package/dist/{src-B0TT7DBi.mjs → src-B6KvTm7B.mjs} +106 -90
- package/dist/src-BnZgZGrf.mjs +2697 -0
- package/dist/{src-hrj2h0IZ.mjs → src-D6_xjm-x.mjs} +6719 -3389
- package/dist/ui/THIRD_PARTY_LICENSES.txt +594 -0
- package/dist/ui/assets/app-CxURph7a.js +75 -0
- package/dist/ui/assets/app-QoHVi-mb.css +1 -0
- package/dist/ui/assets/{index-CPx74lFf.js → index-By_8KKG6.js} +3 -3
- package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{ui-B98FSboa.mjs → ui-CIci4UBW.mjs} +4 -4
- package/dist/{usage-EW7DI6Z7.mjs → usage-GceBGMjU.mjs} +43 -32
- package/dist/{watch-BAkSGaOp.mjs → watch-BmoybMQg.mjs} +28 -16
- package/dist/{workflows-vsEohqa7.mjs → workflows-DZvW3i1e.mjs} +4 -3
- package/dist/{worktrees-Ahj9rdWK.mjs → worktrees-Be51E2rB.mjs} +84 -24
- package/package.json +2 -4
- package/dist/context-6x3kXREO.mjs +0 -274
- package/dist/format-zMmhqyLE.mjs +0 -427
- package/dist/output-TV_j7Ni0.mjs +0 -950
- package/dist/src-B9v0lDc6.mjs +0 -1204
- package/dist/ui/assets/app-BNT0SuIz.css +0 -1
- package/dist/ui/assets/app-CmkynZyW.js +0 -67
- package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
|
@@ -0,0 +1,2697 @@
|
|
|
1
|
+
import { Ar as AGENT_DRIVERS, Bt as lowerChild, En as maskSecrets, Vt as startAt, an as FEEDBACK_SEVERITIES, en as outcomeEnd, ft as parseReport, in as FEEDBACK_KINDS, nn as END_KINDS, on as FEEDBACK_TARGETS, rn as endFor, wn as MASK } from "./src-D6_xjm-x.mjs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { execFile, spawn } from "node:child_process";
|
|
9
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
// ../runtime/src/hold.ts
|
|
12
|
+
/**
|
|
13
|
+
* Waits at a tool call while `hold` is held: "go" once it is released within
|
|
14
|
+
* the window (or at once when it is not held), "defer" after the window.
|
|
15
|
+
*/
|
|
16
|
+
const holdAtTool = async (hold, signal, windowMs) => {
|
|
17
|
+
if (!hold.held()) return "go";
|
|
18
|
+
const window = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
window.abort();
|
|
21
|
+
}, windowMs);
|
|
22
|
+
try {
|
|
23
|
+
await hold.wait("tool", AbortSignal.any([signal, window.signal]));
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
return hold.held() && !signal.aborted ? "defer" : "go";
|
|
28
|
+
};
|
|
29
|
+
// ../runtime/src/heavy-hook.ts
|
|
30
|
+
/**
|
|
31
|
+
* @file A session's slot for a heavy Bash call (the project's `heavy` rule),
|
|
32
|
+
* taken in the hook before a tool call, where a pause holds the call too
|
|
33
|
+
* (ADR-041): a slot that frees within the hold window lets the call go; past
|
|
34
|
+
* it the call is deferred, as a pause defers it, and the session resumes once
|
|
35
|
+
* the slot is taken. The slot is released after the call (the hook after it),
|
|
36
|
+
* at the session's next call, or at the session's end. The wait itself (its
|
|
37
|
+
* events, the clock standing still) is the node's machine port.
|
|
38
|
+
*/
|
|
39
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
/** The command of a Bash call's hook input, as Claude Code sends it. */
|
|
41
|
+
const bashCommand = (input) => {
|
|
42
|
+
if (!isRecord$1(input) || input["tool_name"] !== "Bash") return void 0;
|
|
43
|
+
const tool = input["tool_input"];
|
|
44
|
+
const command = isRecord$1(tool) ? tool["command"] : void 0;
|
|
45
|
+
return typeof command === "string" ? command : void 0;
|
|
46
|
+
};
|
|
47
|
+
/** The hook for one session; with no machine port, every call goes. */
|
|
48
|
+
const heavyHook = (machine) => {
|
|
49
|
+
let held;
|
|
50
|
+
let waiting;
|
|
51
|
+
const free = () => {
|
|
52
|
+
held?.release();
|
|
53
|
+
held = void 0;
|
|
54
|
+
};
|
|
55
|
+
/** Gives up the wait; a slot it already got is given back. */
|
|
56
|
+
const abandon = () => {
|
|
57
|
+
const pending = waiting;
|
|
58
|
+
waiting = void 0;
|
|
59
|
+
if (pending === void 0) return;
|
|
60
|
+
pending.stop.abort();
|
|
61
|
+
pending.slot.then((release) => {
|
|
62
|
+
release();
|
|
63
|
+
return null;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
before: async (input, signal, windowMs) => {
|
|
68
|
+
const command = bashCommand(input);
|
|
69
|
+
if (held !== void 0 && !held.ran && held.command === command) {
|
|
70
|
+
held.ran = true;
|
|
71
|
+
return "go";
|
|
72
|
+
}
|
|
73
|
+
free();
|
|
74
|
+
if (waiting !== void 0 && waiting.command !== command) abandon();
|
|
75
|
+
if (machine === void 0 || command === void 0) return "go";
|
|
76
|
+
if (waiting === void 0) {
|
|
77
|
+
const stop = new AbortController();
|
|
78
|
+
waiting = {
|
|
79
|
+
command,
|
|
80
|
+
stop,
|
|
81
|
+
slot: machine.slot(command, stop.signal)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const pending = waiting;
|
|
85
|
+
const window = new AbortController();
|
|
86
|
+
const release = await Promise.race([pending.slot, setTimeout$1(windowMs, null, { signal: AbortSignal.any([signal, window.signal]) }).catch(() => null)]);
|
|
87
|
+
window.abort();
|
|
88
|
+
if (release === null) return signal.aborted ? "go" : "defer";
|
|
89
|
+
waiting = void 0;
|
|
90
|
+
held = {
|
|
91
|
+
command,
|
|
92
|
+
release,
|
|
93
|
+
ran: true
|
|
94
|
+
};
|
|
95
|
+
return "go";
|
|
96
|
+
},
|
|
97
|
+
after: () => {
|
|
98
|
+
if (held?.ran === true) free();
|
|
99
|
+
},
|
|
100
|
+
ready: async () => {
|
|
101
|
+
const pending = waiting;
|
|
102
|
+
if (pending === void 0) return;
|
|
103
|
+
const release = await pending.slot;
|
|
104
|
+
if (waiting !== pending) return;
|
|
105
|
+
waiting = void 0;
|
|
106
|
+
held = {
|
|
107
|
+
command: pending.command,
|
|
108
|
+
release,
|
|
109
|
+
ran: false
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
release: () => {
|
|
113
|
+
abandon();
|
|
114
|
+
free();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/** Whether a hook's input is from after a tool call. */
|
|
119
|
+
const afterCall = (input) => isRecord$1(input) && input["hook_event_name"] === "PostToolUse";
|
|
120
|
+
/**
|
|
121
|
+
* A session's hook on its tool calls. Before one, a pause holds it (ADR-041),
|
|
122
|
+
* then a heavy Bash call waits for its slot; past the window either defers it
|
|
123
|
+
* (`onDefer`). After a call, its slot is freed.
|
|
124
|
+
*/
|
|
125
|
+
const toolHook = (given) => async (gone, input) => {
|
|
126
|
+
if (afterCall(input)) {
|
|
127
|
+
given.heavy.after();
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
const signal = AbortSignal.any([gone, given.ended]);
|
|
131
|
+
let answer = await holdAtTool(given.hold, signal, given.windowMs);
|
|
132
|
+
if (answer === "go") answer = await given.heavy.before(input, signal, given.windowMs);
|
|
133
|
+
if (answer === "defer") given.onDefer();
|
|
134
|
+
return given.answer(answer);
|
|
135
|
+
};
|
|
136
|
+
/** The name Claude Code gives an Ork tool: `mcp__ork__<name>`. */
|
|
137
|
+
const toolId = (name) => `mcp__ork__${name}`;
|
|
138
|
+
const text = (value) => ({ content: [{
|
|
139
|
+
type: "text",
|
|
140
|
+
text: value
|
|
141
|
+
}] });
|
|
142
|
+
const issueAt = (issue) => {
|
|
143
|
+
const where = z.core.toDotPath(issue.path);
|
|
144
|
+
return where === "" ? "" : `${where}: `;
|
|
145
|
+
};
|
|
146
|
+
/** Whether `issue` is about a field of the object `parent` is about. */
|
|
147
|
+
const isFieldOf = (issue, parent) => issue.path.length === parent.path.length + 1 && parent.path.every((key, index) => key === issue.path[index]);
|
|
148
|
+
const expectedField = (issue) => `; the field is \`${String(issue.path.at(-1))}\`${issue.code === "invalid_value" ? `, one of: ${issue.values.join(", ")}` : ""}`;
|
|
149
|
+
/**
|
|
150
|
+
* A failed parse as text a model can act on. A stray key is reported with the
|
|
151
|
+
* fields its object lacks, read from the schema's own issues: a wrong name is
|
|
152
|
+
* otherwise only a "missing" error that hides what was sent.
|
|
153
|
+
*/
|
|
154
|
+
const readableIssues = (issues) => {
|
|
155
|
+
const strays = issues.filter((issue) => issue.code === "unrecognized_keys");
|
|
156
|
+
const lacking = issues.filter((issue) => issue.input === void 0 && strays.some((stray) => isFieldOf(issue, stray)));
|
|
157
|
+
return [...strays.map((stray) => `${issueAt(stray)}unknown field ${stray.keys.map((key) => `\`${key}\``).join(", ")}${lacking.filter((issue) => isFieldOf(issue, stray)).map((issue) => expectedField(issue)).join("")}`), ...issues.filter((issue) => issue.code !== "unrecognized_keys" && !lacking.includes(issue)).map((issue) => `${issueAt(issue)}${issue.message}`)].join("\n");
|
|
158
|
+
};
|
|
159
|
+
const defineTool = (definition) => ({
|
|
160
|
+
name: definition.name,
|
|
161
|
+
bind: (context) => {
|
|
162
|
+
if (!(definition.available?.(context) ?? true)) return null;
|
|
163
|
+
const shape = typeof definition.shape === "function" ? definition.shape(context) : definition.shape;
|
|
164
|
+
const schema = z.object(shape);
|
|
165
|
+
return {
|
|
166
|
+
name: definition.name,
|
|
167
|
+
description: typeof definition.description === "function" ? definition.description(context) : definition.description,
|
|
168
|
+
shape,
|
|
169
|
+
schema,
|
|
170
|
+
preapproved: definition.preapproved ?? true,
|
|
171
|
+
call: (args) => {
|
|
172
|
+
const parsed = schema.safeParse(args, { reportInput: true });
|
|
173
|
+
return parsed.success ? definition.run(context, parsed.data) : Promise.resolve({
|
|
174
|
+
...text(readableIssues(parsed.error.issues)),
|
|
175
|
+
isError: true
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
// ../runtime/src/mcp-http.ts
|
|
182
|
+
/**
|
|
183
|
+
* @file A minimal MCP server over the streamable HTTP transport, for the
|
|
184
|
+
* `claude` CLI driver. It answers with plain JSON (no SSE stream), which the
|
|
185
|
+
* transport allows, and implements only what Ork needs: `initialize`, `ping`,
|
|
186
|
+
* `tools/list` and `tools/call`. Beside it, `/hold` answers the session's
|
|
187
|
+
* `PreToolUse` HTTP hook, which waits while the node is paused (ADR-041) or
|
|
188
|
+
* for a heavy command's slot, and its `PostToolUse` hook, which frees it.
|
|
189
|
+
*/
|
|
190
|
+
const PATH = "/mcp";
|
|
191
|
+
const HOLD_PATH = "/hold";
|
|
192
|
+
const MAX_BODY = 1048576;
|
|
193
|
+
const MCP_PROTOCOL_FALLBACK = "2025-06-18";
|
|
194
|
+
const JsonRpcSchema = z.object({
|
|
195
|
+
jsonrpc: z.literal("2.0"),
|
|
196
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
197
|
+
method: z.string(),
|
|
198
|
+
params: z.record(z.string(), z.unknown()).optional()
|
|
199
|
+
});
|
|
200
|
+
/** Handles one JSON-RPC request; `null` for a notification (no reply). */
|
|
201
|
+
const handleRpc = async (tools, message) => {
|
|
202
|
+
const parsed = JsonRpcSchema.safeParse(message);
|
|
203
|
+
if (!parsed.success) return { error: {
|
|
204
|
+
code: -32600,
|
|
205
|
+
message: "invalid request"
|
|
206
|
+
} };
|
|
207
|
+
const { id, method, params } = parsed.data;
|
|
208
|
+
if (id === void 0) return null;
|
|
209
|
+
switch (method) {
|
|
210
|
+
case "initialize": {
|
|
211
|
+
const requested = params?.["protocolVersion"];
|
|
212
|
+
return { result: {
|
|
213
|
+
protocolVersion: typeof requested === "string" ? requested : MCP_PROTOCOL_FALLBACK,
|
|
214
|
+
capabilities: { tools: {} },
|
|
215
|
+
serverInfo: {
|
|
216
|
+
name: "ork",
|
|
217
|
+
version: "0.0.0"
|
|
218
|
+
}
|
|
219
|
+
} };
|
|
220
|
+
}
|
|
221
|
+
case "ping": return { result: {} };
|
|
222
|
+
case "tools/list": return { result: { tools: tools.map((t) => ({
|
|
223
|
+
name: t.name,
|
|
224
|
+
description: t.description,
|
|
225
|
+
inputSchema: z.toJSONSchema(t.schema)
|
|
226
|
+
})) } };
|
|
227
|
+
case "tools/call": {
|
|
228
|
+
const tool = tools.find((t) => t.name === params?.["name"]);
|
|
229
|
+
if (tool === void 0) return { error: {
|
|
230
|
+
code: -32602,
|
|
231
|
+
message: "unknown tool"
|
|
232
|
+
} };
|
|
233
|
+
try {
|
|
234
|
+
return { result: await tool.call(params?.["arguments"] ?? {}) };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { result: {
|
|
237
|
+
content: [{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: error instanceof Error ? error.message : "tool failed"
|
|
240
|
+
}],
|
|
241
|
+
isError: true
|
|
242
|
+
} };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
default: return { error: {
|
|
246
|
+
code: -32601,
|
|
247
|
+
message: `unknown method ${method}`
|
|
248
|
+
} };
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const authorized = (req, token) => {
|
|
252
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
253
|
+
const given = Buffer.from(req.headers.authorization ?? "");
|
|
254
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
255
|
+
};
|
|
256
|
+
const readBody = async (req) => {
|
|
257
|
+
const chunks = [];
|
|
258
|
+
let size = 0;
|
|
259
|
+
for await (const chunk of req) {
|
|
260
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
261
|
+
size += buffer.length;
|
|
262
|
+
if (size > MAX_BODY) return null;
|
|
263
|
+
chunks.push(buffer);
|
|
264
|
+
}
|
|
265
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
266
|
+
};
|
|
267
|
+
const respond = (res, status, body) => {
|
|
268
|
+
if (body === void 0) {
|
|
269
|
+
res.writeHead(status).end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
273
|
+
};
|
|
274
|
+
/** The hook's POST: waits for `hold`; a closed connection gives up. */
|
|
275
|
+
const serveHold = async (req, res, hold) => {
|
|
276
|
+
if (req.method !== "POST") {
|
|
277
|
+
respond(res, 405);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const body = await readBody(req);
|
|
281
|
+
if (body === null) {
|
|
282
|
+
respond(res, 413);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
let input;
|
|
286
|
+
try {
|
|
287
|
+
input = JSON.parse(body);
|
|
288
|
+
} catch {
|
|
289
|
+
input = void 0;
|
|
290
|
+
}
|
|
291
|
+
const gone = new AbortController();
|
|
292
|
+
res.once("close", () => {
|
|
293
|
+
gone.abort();
|
|
294
|
+
});
|
|
295
|
+
const output = await hold(gone.signal, input);
|
|
296
|
+
if (!gone.signal.aborted) respond(res, 200, output);
|
|
297
|
+
};
|
|
298
|
+
const serve = async (req, res, tools, token, hold) => {
|
|
299
|
+
if (!authorized(req, token)) {
|
|
300
|
+
respond(res, 401);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (req.url === HOLD_PATH && hold !== void 0) {
|
|
304
|
+
await serveHold(req, res, hold);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (req.url !== PATH) {
|
|
308
|
+
respond(res, 404);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (req.method !== "POST") {
|
|
312
|
+
respond(res, 405);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const body = await readBody(req);
|
|
316
|
+
if (body === null) {
|
|
317
|
+
respond(res, 413);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let message;
|
|
321
|
+
try {
|
|
322
|
+
message = JSON.parse(body);
|
|
323
|
+
} catch {
|
|
324
|
+
respond(res, 400);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const id = message !== null && typeof message === "object" && "id" in message ? message.id : null;
|
|
328
|
+
const reply = await handleRpc(tools, message);
|
|
329
|
+
if (reply === null) {
|
|
330
|
+
respond(res, 202);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
respond(res, 200, {
|
|
334
|
+
jsonrpc: "2.0",
|
|
335
|
+
id,
|
|
336
|
+
...reply
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* Serves Ork's tools to one `claude` CLI session: on 127.0.0.1 only, on a
|
|
341
|
+
* random port, behind a per-session bearer token.
|
|
342
|
+
*/
|
|
343
|
+
const startOrkMcpHttp = async (tools, hold) => {
|
|
344
|
+
const token = randomBytes(24).toString("hex");
|
|
345
|
+
const http = createServer((req, res) => {
|
|
346
|
+
serve(req, res, tools, token, hold).catch(() => {
|
|
347
|
+
if (!res.headersSent) respond(res, 500);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
await new Promise((resolve) => {
|
|
351
|
+
http.listen(0, "127.0.0.1", resolve);
|
|
352
|
+
});
|
|
353
|
+
const address = http.address();
|
|
354
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
355
|
+
return {
|
|
356
|
+
url: `http://127.0.0.1:${port}${PATH}`,
|
|
357
|
+
holdUrl: `http://127.0.0.1:${port}${HOLD_PATH}`,
|
|
358
|
+
token,
|
|
359
|
+
close: () => new Promise((resolve) => {
|
|
360
|
+
http.closeAllConnections();
|
|
361
|
+
http.close(() => {
|
|
362
|
+
resolve();
|
|
363
|
+
});
|
|
364
|
+
})
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
// ../runtime/src/provider-errors.ts
|
|
368
|
+
/**
|
|
369
|
+
* @file The provider-error classifier every driver's adapter uses (specs/
|
|
370
|
+
* runtime/session-ends.md): an error a model provider sent back (Anthropic,
|
|
371
|
+
* OpenAI-style, or a proxy such as OpenCode's `commandcode`) to one end kind.
|
|
372
|
+
* It reads the error's type or code first, its HTTP status next, and its
|
|
373
|
+
* words last, so a changed sentence rarely changes the kind.
|
|
374
|
+
*/
|
|
375
|
+
/** Error types and codes the providers document, by the kind they mean. */
|
|
376
|
+
const TYPES = {
|
|
377
|
+
rate_limit_error: "rate_limited",
|
|
378
|
+
overloaded_error: "provider_unavailable",
|
|
379
|
+
api_error: "provider_unavailable",
|
|
380
|
+
timeout_error: "provider_unavailable",
|
|
381
|
+
authentication_error: "auth",
|
|
382
|
+
permission_error: "auth",
|
|
383
|
+
billing_error: "out_of_credits",
|
|
384
|
+
not_found_error: "model_unavailable",
|
|
385
|
+
request_too_large: "context_too_long",
|
|
386
|
+
insufficient_quota: "out_of_credits",
|
|
387
|
+
invalid_api_key: "auth",
|
|
388
|
+
context_length_exceeded: "context_too_long",
|
|
389
|
+
model_not_found: "model_unavailable",
|
|
390
|
+
rate_limit_exceeded: "rate_limited",
|
|
391
|
+
server_error: "provider_unavailable",
|
|
392
|
+
ProviderAuthError: "auth",
|
|
393
|
+
MessageAbortedError: "interrupted"
|
|
394
|
+
};
|
|
395
|
+
const TYPE = new RegExp(`\\b(${Object.keys(TYPES).join("|")})\\b`, "u");
|
|
396
|
+
/** HTTP statuses that decide by themselves; 400 needs the words. */
|
|
397
|
+
const statusKind = (status) => {
|
|
398
|
+
if (status === 401 || status === 403) return "auth";
|
|
399
|
+
if (status === 402) return "out_of_credits";
|
|
400
|
+
if (status === 404) return "model_unavailable";
|
|
401
|
+
if (status === 413) return "context_too_long";
|
|
402
|
+
if (status === 429) return "rate_limited";
|
|
403
|
+
if (status === 408 || status === 529 || status >= 500 && status < 600) return "provider_unavailable";
|
|
404
|
+
return null;
|
|
405
|
+
};
|
|
406
|
+
/** The words, when neither type nor status decided; the first match wins. */
|
|
407
|
+
const WORDS = [
|
|
408
|
+
["plan_limit", /\busage limit reached\b|\bhit your (?:usage )?limit\b|\blimit will reset\b/iu],
|
|
409
|
+
["context_too_long", /\bprompt is too long\b|\bcontext (?:length|window)\b|\bmaximum context\b|\btoo many tokens\b/iu],
|
|
410
|
+
["out_of_credits", /\bcredit balance\b|\binsufficient (?:credits?|funds|balance|quota)\b|\bout of credits?\b|\bexceeded your current quota\b/iu],
|
|
411
|
+
["rate_limited", /\brate.?limit(?:ed)?\b|\btoo many requests\b/iu],
|
|
412
|
+
["auth", /\binvalid (?:x-)?api.?key\b|\bnot (?:logged|signed) in\b|\bplease run \/login\b|\bunauthori[sz]ed\b|\bOAuth token has expired\b/iu],
|
|
413
|
+
["provider_unavailable", /\boverloaded\b|\bservice unavailable\b|\bbad gateway\b|\bECONNRESET\b|\bECONNREFUSED\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bfetch failed\b|\bsocket hang up\b|\bnetwork error\b/iu],
|
|
414
|
+
["model_unavailable", /\bassistant message prefill\b|\bInvalid `signature` in `thinking` block\b|\bmodel(?: \S+)? (?:not found|does not exist|is not supported)\b|\bdoes not support\b|\bunknown model\b/iu]
|
|
415
|
+
];
|
|
416
|
+
/** Generic refusals: the provider would not serve the request as it was. */
|
|
417
|
+
const REFUSED$1 = /\binvalid_request_error\b|\binvalid_request\b/u;
|
|
418
|
+
/** A status written in the text, e.g. Claude Code's `API Error: 529 {…}`. */
|
|
419
|
+
const STATUS_IN_TEXT = /\bAPI Error:? (\d{3})\b|"(?:status|statusCode)"\s*:\s*(\d{3})\b|\b(?:status|HTTP)[ :]+(\d{3})\b/u;
|
|
420
|
+
const statusIn = (text) => {
|
|
421
|
+
const found = STATUS_IN_TEXT.exec(text);
|
|
422
|
+
const digits = found?.[1] ?? found?.[2] ?? found?.[3];
|
|
423
|
+
return digits === void 0 ? void 0 : Number(digits);
|
|
424
|
+
};
|
|
425
|
+
const classed = (kind) => ({
|
|
426
|
+
kind,
|
|
427
|
+
message: END_KINDS[kind].message({})
|
|
428
|
+
});
|
|
429
|
+
/**
|
|
430
|
+
* The end kind a provider's error means, with Ork's sentence for it; `null`
|
|
431
|
+
* when nothing in it is recognized. `status` is the HTTP status when the caller
|
|
432
|
+
* has it; otherwise one written in the text is read.
|
|
433
|
+
*/
|
|
434
|
+
const classifyProviderError = (text, status) => {
|
|
435
|
+
const type = TYPE.exec(text)?.[1];
|
|
436
|
+
if (type !== void 0) return classed(TYPES[type] ?? "unknown");
|
|
437
|
+
const code = status ?? statusIn(text);
|
|
438
|
+
const byStatus = code === void 0 ? null : statusKind(code);
|
|
439
|
+
if (byStatus !== null) return classed(byStatus);
|
|
440
|
+
const byWords = WORDS.find(([, pattern]) => pattern.test(text))?.[0];
|
|
441
|
+
if (byWords !== void 0) return classed(byWords);
|
|
442
|
+
return REFUSED$1.test(text) || code === 400 ? classed("model_unavailable") : null;
|
|
443
|
+
};
|
|
444
|
+
// ../runtime/src/session-end.ts
|
|
445
|
+
/**
|
|
446
|
+
* @file How a session ended, as its adapter's decoder and the session runner
|
|
447
|
+
* settle it (specs/runtime/session-ends.md): a decoder names the signal only
|
|
448
|
+
* it can see (a turn cap, an exit code, a provider's error), and the report
|
|
449
|
+
* the session handed in decides the rest. The runner then writes the end in
|
|
450
|
+
* the node's numbers, its detail scrubbed with the feedback redactor. A
|
|
451
|
+
* session the driver could not run ends as `driverFailure` says.
|
|
452
|
+
*/
|
|
453
|
+
/** The end a report gives: done, a reported failure, or none handed in. */
|
|
454
|
+
const reportEnd = (report) => {
|
|
455
|
+
if (report === null) return endFor("no_report");
|
|
456
|
+
return endFor(report.status === "fail" ? "reported_failure" : "done");
|
|
457
|
+
};
|
|
458
|
+
/**
|
|
459
|
+
* A session's end: a report it handed in decides it (the node's result is that
|
|
460
|
+
* report), then the decoder's `signal`, then `no_report`.
|
|
461
|
+
*/
|
|
462
|
+
const sessionEnd = (signal, report) => report !== null || signal === null ? reportEnd(report) : signal;
|
|
463
|
+
/**
|
|
464
|
+
* The end an error's text means: the provider classifier's kind, or `fallback`
|
|
465
|
+
* when it recognizes nothing. The text is the detail either way.
|
|
466
|
+
*/
|
|
467
|
+
const errorEnd = (text, fallback = "unknown", status) => endFor(classifyProviderError(text, status)?.kind ?? fallback, {}, text);
|
|
468
|
+
/**
|
|
469
|
+
* The outcome with its end settled: a report decides it, also one handed in
|
|
470
|
+
* through Ork's `report` tool after the decoder's end said none; the message is
|
|
471
|
+
* written with the node's numbers; the detail is scrubbed and capped. An
|
|
472
|
+
* adapter whose decoder sets no end gets one read from the outcome.
|
|
473
|
+
*/
|
|
474
|
+
const settleEnd = (outcome, facts, scrub) => {
|
|
475
|
+
const end = outcome.report === null ? outcome.end ?? outcomeEnd(outcome) : reportEnd(outcome.report);
|
|
476
|
+
return {
|
|
477
|
+
...outcome,
|
|
478
|
+
end: endFor(end.kind, facts, end.detail, scrub)
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* The kind of a failure to run the driver: its program missing, else what a
|
|
483
|
+
* provider's words in it say, else a crash.
|
|
484
|
+
*/
|
|
485
|
+
const failureKind = (error) => {
|
|
486
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return "tool_missing";
|
|
487
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
488
|
+
if (/\bENOENT\b/u.test(text)) return "tool_missing";
|
|
489
|
+
return classifyProviderError(text)?.kind ?? "driver_crashed";
|
|
490
|
+
};
|
|
491
|
+
/**
|
|
492
|
+
* A finished event for a session the driver could not run, ended as `kind` with
|
|
493
|
+
* `message` (Ork's words or the tool's, masked) as its detail.
|
|
494
|
+
*/
|
|
495
|
+
const driverFailure = (message, kind, scope) => ({
|
|
496
|
+
type: "session.finished",
|
|
497
|
+
outcome: {
|
|
498
|
+
subtype: "error_driver",
|
|
499
|
+
end: endFor(kind, scope.facts, kind === "interrupted" ? void 0 : message, scope.scrub),
|
|
500
|
+
sessionId: "",
|
|
501
|
+
report: null,
|
|
502
|
+
text: "",
|
|
503
|
+
usage: {
|
|
504
|
+
costUsd: 0,
|
|
505
|
+
inputTokens: 0,
|
|
506
|
+
outputTokens: 0,
|
|
507
|
+
cacheReadTokens: 0,
|
|
508
|
+
cacheWriteTokens: 0,
|
|
509
|
+
turns: 0,
|
|
510
|
+
durationMs: 0
|
|
511
|
+
},
|
|
512
|
+
errors: [message]
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
// ../runtime/src/session-leg.ts
|
|
516
|
+
/**
|
|
517
|
+
* @file One process (leg) of a session the runner drives: it writes the queued
|
|
518
|
+
* stdin lines, yields the adapter's decoded events, and says how it ended. A
|
|
519
|
+
* session is one leg, or several when a paused node's tool call was deferred
|
|
520
|
+
* or a message waits for a tool that takes none mid-session; a resumed leg
|
|
521
|
+
* carries what the legs before it spent, and its start is not news.
|
|
522
|
+
*/
|
|
523
|
+
const REASON_LIMIT = 500;
|
|
524
|
+
/** Marks a session's start with the driver that runs it. */
|
|
525
|
+
const withDriver = (event, driver) => event.type === "session.started" ? {
|
|
526
|
+
...event,
|
|
527
|
+
driver
|
|
528
|
+
} : event;
|
|
529
|
+
/**
|
|
530
|
+
* A session's outcome with its report (the one Ork's `report` tool accepted,
|
|
531
|
+
* when the session has that tool) and its end settled.
|
|
532
|
+
*/
|
|
533
|
+
const settled = (outcome, input) => settleEnd(input.reported === void 0 ? outcome : {
|
|
534
|
+
...outcome,
|
|
535
|
+
report: input.reported()
|
|
536
|
+
}, input.scope.facts, input.scope.scrub);
|
|
537
|
+
/** The kinds of a session that ended well, so a queued message continues it. */
|
|
538
|
+
const wentWell = (kind) => kind === "done" || kind === "reported_failure" || kind === "no_report";
|
|
539
|
+
/**
|
|
540
|
+
* What one decoded line yields, and how the leg ends if it does: a result is
|
|
541
|
+
* the session's end, unless the hold hook deferred a call or a message waits; a
|
|
542
|
+
* resumed leg's start is not news, and its end carries what the legs before it
|
|
543
|
+
* spent.
|
|
544
|
+
*/
|
|
545
|
+
const legStep = (decoded, input) => {
|
|
546
|
+
const { adapter, carried } = input;
|
|
547
|
+
const deferred = input.deferred() && decoded.deferred === true;
|
|
548
|
+
const events = [];
|
|
549
|
+
let ended;
|
|
550
|
+
for (const said of decoded.events) {
|
|
551
|
+
const event = said.type === "session.finished" ? {
|
|
552
|
+
...said,
|
|
553
|
+
outcome: settled(said.outcome, input)
|
|
554
|
+
} : said;
|
|
555
|
+
if (event.type === "session.started" && carried !== void 0) continue;
|
|
556
|
+
if (event.type !== "session.finished") events.push(withDriver(event, adapter.driver));
|
|
557
|
+
else if (deferred) ended = {
|
|
558
|
+
kind: "deferred",
|
|
559
|
+
sessionId: event.outcome.sessionId,
|
|
560
|
+
usage: event.outcome.usage,
|
|
561
|
+
last: event
|
|
562
|
+
};
|
|
563
|
+
else {
|
|
564
|
+
const usage = carried === void 0 ? event.outcome.usage : adapter.usage.combine(carried, event.outcome.usage);
|
|
565
|
+
const last = {
|
|
566
|
+
...event,
|
|
567
|
+
outcome: {
|
|
568
|
+
...event.outcome,
|
|
569
|
+
usage
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
if (input.withhold() && wentWell(event.outcome.end?.kind)) ended = {
|
|
573
|
+
kind: "continued",
|
|
574
|
+
sessionId: event.outcome.sessionId,
|
|
575
|
+
usage: event.outcome.usage,
|
|
576
|
+
last
|
|
577
|
+
};
|
|
578
|
+
else {
|
|
579
|
+
events.push(last);
|
|
580
|
+
ended = { kind: "finished" };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return ended === void 0 ? { events } : {
|
|
585
|
+
events,
|
|
586
|
+
ended
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
/** Noise a Node crash ends stderr with, or a stack frame: never the reason. */
|
|
590
|
+
const isNoise = (line) => /^Node\.js v\d/u.test(line) || /^\s+at /u.test(line);
|
|
591
|
+
/**
|
|
592
|
+
* Why a process ended without a result: the last block of its stderr as one
|
|
593
|
+
* masked, capped line (the reason reaches the terminal, which is not masked).
|
|
594
|
+
*/
|
|
595
|
+
const failureReason = (name, stderr, code) => {
|
|
596
|
+
const block = stderr.split("\n").filter((line) => !isNoise(line)).join("\n").split(/\n\s*\n/u).map((text) => text.trim().replaceAll(/\s*\n\s*/gu, " ")).findLast((text) => text !== "");
|
|
597
|
+
return block === void 0 ? `${name} exited with code ${String(code)}` : maskSecrets(block).slice(0, REASON_LIMIT);
|
|
598
|
+
};
|
|
599
|
+
/** Runs one process of a session, yielding its events; returns how it ended. */
|
|
600
|
+
const runLeg = async function* (input) {
|
|
601
|
+
const [bin, args] = startAt(input.priority, input.bin, input.args, input.env);
|
|
602
|
+
const child = spawn(bin, args, {
|
|
603
|
+
cwd: input.cwd,
|
|
604
|
+
env: { ...input.env },
|
|
605
|
+
stdio: [
|
|
606
|
+
"pipe",
|
|
607
|
+
"pipe",
|
|
608
|
+
"pipe"
|
|
609
|
+
]
|
|
610
|
+
});
|
|
611
|
+
lowerChild(input.priority, child.pid);
|
|
612
|
+
const exited = new Promise((resolve) => {
|
|
613
|
+
child.on("close", (code, signal) => {
|
|
614
|
+
resolve({
|
|
615
|
+
code,
|
|
616
|
+
signal
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
const spawnError = new Promise((resolve) => {
|
|
621
|
+
child.on("error", resolve);
|
|
622
|
+
});
|
|
623
|
+
let stderr = "";
|
|
624
|
+
child.stderr.setEncoding("utf8");
|
|
625
|
+
child.stderr.on("data", (chunk) => {
|
|
626
|
+
stderr = (stderr + chunk).slice(-16384);
|
|
627
|
+
});
|
|
628
|
+
const write = (line) => {
|
|
629
|
+
if (child.stdin.writable) child.stdin.write(line);
|
|
630
|
+
};
|
|
631
|
+
let stopped = false;
|
|
632
|
+
input.attach({
|
|
633
|
+
write,
|
|
634
|
+
stop: () => {
|
|
635
|
+
stopped = true;
|
|
636
|
+
child.kill("SIGINT");
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
for (const line of input.pending.splice(0)) write(line);
|
|
640
|
+
if (input.adapter.command.input === void 0) child.stdin.end();
|
|
641
|
+
let outcome;
|
|
642
|
+
const { decoder } = input;
|
|
643
|
+
const decode = decoder?.decode ?? input.adapter.decode;
|
|
644
|
+
try {
|
|
645
|
+
for await (const line of createInterface({ input: child.stdout })) {
|
|
646
|
+
const { events, ended } = legStep(decode(line), input);
|
|
647
|
+
yield* events;
|
|
648
|
+
if (ended !== void 0) {
|
|
649
|
+
child.stdin.end();
|
|
650
|
+
outcome = ended;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const end = await Promise.race([exited, spawnError.then((error) => error)]);
|
|
654
|
+
if (outcome !== void 0) return outcome;
|
|
655
|
+
if (decoder !== void 0 && !(end instanceof Error) && !stopped) {
|
|
656
|
+
const { events, ended } = legStep({ events: decoder.exit(end.code) }, input);
|
|
657
|
+
yield* events;
|
|
658
|
+
if (ended !== void 0) return ended;
|
|
659
|
+
}
|
|
660
|
+
if (end instanceof Error) return {
|
|
661
|
+
kind: "failed",
|
|
662
|
+
reason: `could not start ${input.bin}: ${end.message}`,
|
|
663
|
+
end: failureKind(end)
|
|
664
|
+
};
|
|
665
|
+
if (stopped || end.signal !== null) return {
|
|
666
|
+
kind: "failed",
|
|
667
|
+
reason: "interrupted",
|
|
668
|
+
end: "interrupted"
|
|
669
|
+
};
|
|
670
|
+
const reason = failureReason(input.adapter.command.bin, stderr, end.code);
|
|
671
|
+
return {
|
|
672
|
+
kind: "failed",
|
|
673
|
+
reason,
|
|
674
|
+
end: classifyProviderError(reason)?.kind ?? "driver_crashed"
|
|
675
|
+
};
|
|
676
|
+
} finally {
|
|
677
|
+
input.attach();
|
|
678
|
+
if (child.exitCode === null) child.kill();
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
// ../runtime/src/tools/ask-user.ts
|
|
682
|
+
const askUserTool = defineTool({
|
|
683
|
+
name: "ask_user",
|
|
684
|
+
description: "Ask the user to decide something only they can decide. Give two to four options and the one you recommend. The run waits for the answer.",
|
|
685
|
+
shape: {
|
|
686
|
+
question: z.string().describe("One clear question."),
|
|
687
|
+
options: z.array(z.object({
|
|
688
|
+
id: z.string().describe("A short id, e.g. 'keep-api'."),
|
|
689
|
+
label: z.string().optional(),
|
|
690
|
+
description: z.string().optional().describe("What choosing it means.")
|
|
691
|
+
})).min(2).max(4),
|
|
692
|
+
recommended: z.string().optional().describe("The id you recommend."),
|
|
693
|
+
context: z.string().optional().describe("What the user needs to know to decide, in a few sentences.")
|
|
694
|
+
},
|
|
695
|
+
run: async ({ nodeId, ports }, args) => {
|
|
696
|
+
let note;
|
|
697
|
+
let owner;
|
|
698
|
+
const question = {
|
|
699
|
+
nodeId,
|
|
700
|
+
onNote: (given) => {
|
|
701
|
+
note = given;
|
|
702
|
+
},
|
|
703
|
+
onOwner: (given) => {
|
|
704
|
+
owner = given;
|
|
705
|
+
},
|
|
706
|
+
question: args.question,
|
|
707
|
+
options: args.options.map((o) => {
|
|
708
|
+
const choice = { id: o.id };
|
|
709
|
+
if (o.label !== void 0) choice.label = o.label;
|
|
710
|
+
if (o.description !== void 0) choice.description = o.description;
|
|
711
|
+
return choice;
|
|
712
|
+
})
|
|
713
|
+
};
|
|
714
|
+
if (args.recommended !== void 0) question.recommended = args.recommended;
|
|
715
|
+
if (args.context !== void 0) question.context = args.context;
|
|
716
|
+
const answer = await ports.human.ask(question);
|
|
717
|
+
const who = owner === void 0 ? "The user" : `Your owner, \`${owner}\`,`;
|
|
718
|
+
return text(note === void 0 ? `${who} chose: ${answer}` : `${who} chose: ${answer}\nThey said: ${note}`);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
// ../runtime/src/tools/claims.ts
|
|
722
|
+
/**
|
|
723
|
+
* @file `claim` and `release`: a session's side of the run's claims. A node
|
|
724
|
+
* claims a file, folder or other resource before changing it, so nodes
|
|
725
|
+
* running at the same time do not change the same thing; a refused claim says
|
|
726
|
+
* who holds it. Only sessions that may write while another does get them.
|
|
727
|
+
*/
|
|
728
|
+
const contended = ({ ports }) => ports.claims?.contended() === true;
|
|
729
|
+
const resource = z.string().min(1).describe("A path from the project root (a folder covers everything in it), or a name for something else shared, e.g. 'db:migrations'.");
|
|
730
|
+
const claimTool = defineTool({
|
|
731
|
+
name: "claim",
|
|
732
|
+
description: "Claim a file, folder or other shared resource before you change it, so other nodes running in this run leave it alone. If another node holds it (or something overlapping), you are told who; Ork tells you when it is released.",
|
|
733
|
+
shape: { resource },
|
|
734
|
+
available: contended,
|
|
735
|
+
run: ({ ports }, args) => {
|
|
736
|
+
const claimed = ports.claims?.claim(args.resource);
|
|
737
|
+
if (claimed === void 0) return Promise.resolve(text("No claims."));
|
|
738
|
+
if (!claimed.ok) return Promise.resolve(text(`Not claimed: ${claimed.error}.`));
|
|
739
|
+
return Promise.resolve(text(claimed.already ? `You already hold ${claimed.resource}.` : `Claimed ${claimed.resource}.`));
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
const releaseTool = defineTool({
|
|
743
|
+
name: "release",
|
|
744
|
+
description: "Release a claim you are done with, so other nodes can take it. Ork releases your remaining claims when you finish.",
|
|
745
|
+
shape: { resource },
|
|
746
|
+
available: contended,
|
|
747
|
+
run: ({ ports }, args) => {
|
|
748
|
+
const released = ports.claims?.release(args.resource);
|
|
749
|
+
if (released === void 0) return Promise.resolve(text("No claims."));
|
|
750
|
+
return Promise.resolve(text(released.ok ? `Released ${released.resource}.` : `Not released: ${released.error}.`));
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
// ../runtime/src/tools/delegate.ts
|
|
754
|
+
const BRIEF = z.string().min(1).describe("Self-contained: the goal, the files that matter, and what done looks like. The child does not see your conversation.");
|
|
755
|
+
/** What the leader reads back: each report without the usage details. */
|
|
756
|
+
const reportLine = (report) => {
|
|
757
|
+
const { usage, ...output } = report.output;
|
|
758
|
+
const cost = usage !== null && typeof usage === "object" && "costUsd" in usage ? usage.costUsd : void 0;
|
|
759
|
+
return {
|
|
760
|
+
child: report.child,
|
|
761
|
+
ok: report.ok,
|
|
762
|
+
...output,
|
|
763
|
+
...cost === void 0 ? {} : { costUsd: cost },
|
|
764
|
+
...report.ok || report.error === void 0 || report.error === output["summary"] ? {} : { error: report.error }
|
|
765
|
+
};
|
|
766
|
+
};
|
|
767
|
+
const delegationText = (delegation) => text(JSON.stringify({
|
|
768
|
+
reports: delegation.reports.map(reportLine),
|
|
769
|
+
...delegation.notes.length > 0 ? { notes: delegation.notes } : {}
|
|
770
|
+
}));
|
|
771
|
+
/** Why a child the leader owns isn't offered, so it doesn't go looking. */
|
|
772
|
+
const fannedNote = (fanned) => fanned.length === 0 ? "" : ` Not listed: ${fanned.join(", ")}; such a node runs once per item of a list, from its edges, so it can't be delegated to.`;
|
|
773
|
+
const delegateTool = defineTool({
|
|
774
|
+
name: "delegate",
|
|
775
|
+
description: ({ ports }) => `Hand tasks to nodes you own. Tasks in one call run in parallel; the call returns every child's report when all are done. Ork merges a passing child's branch into yours.${fannedNote(ports.ownership?.fanned ?? [])}`,
|
|
776
|
+
shape: ({ ports }) => {
|
|
777
|
+
const ids = ports.ownership?.children.map((child) => child.id) ?? [];
|
|
778
|
+
return { tasks: z.array(z.strictObject({
|
|
779
|
+
child: z.enum(ids).describe(`The id of a node you own: ${ids.join(", ")}.`),
|
|
780
|
+
brief: BRIEF
|
|
781
|
+
})).min(1) };
|
|
782
|
+
},
|
|
783
|
+
available: ({ ports }) => (ports.ownership?.children.length ?? 0) > 0,
|
|
784
|
+
run: async ({ ports }, args) => {
|
|
785
|
+
if (ports.ownership === void 0) return text("You own no nodes.");
|
|
786
|
+
return delegationText(await ports.ownership.delegate(args.tasks));
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
const spawnWorkerTool = defineTool({
|
|
790
|
+
name: "spawn_worker",
|
|
791
|
+
description: "Start new instances of a child worker for work that splits into independent parts. Each runs in its own worktree; the call returns their reports when all are done.",
|
|
792
|
+
shape: ({ ports }) => {
|
|
793
|
+
const ids = ports.ownership?.templates ?? [];
|
|
794
|
+
return { tasks: z.array(z.strictObject({
|
|
795
|
+
template: z.enum(ids).describe(`The id of a child worker you own: ${ids.join(", ")}.`),
|
|
796
|
+
brief: BRIEF
|
|
797
|
+
})).min(1) };
|
|
798
|
+
},
|
|
799
|
+
available: ({ ports }) => ports.ownership !== void 0 && ports.ownership.templates.length > 0 && ports.ownership.workersLeft() > 0,
|
|
800
|
+
run: async ({ ports }, args) => {
|
|
801
|
+
if (ports.ownership === void 0) return text("You own no workers.");
|
|
802
|
+
return delegationText(await ports.ownership.spawn(args.tasks));
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
// ../runtime/src/tools/feedback.ts
|
|
806
|
+
/**
|
|
807
|
+
* @file `feedback`: a session's way to report what got in its way, kept on the
|
|
808
|
+
* user's machine (specs/feedback.md). Every session inside a run gets it; Ork
|
|
809
|
+
* caps reports per session and drops a run's duplicates.
|
|
810
|
+
*/
|
|
811
|
+
const feedbackTool = defineTool({
|
|
812
|
+
name: "feedback",
|
|
813
|
+
description: "Report something that materially got in the way of your task: a tool that failed or misled you, context or instructions that were missing or wrong, a skill that did not fit, or a flaw in the workflow. One line plus a short body; at most a few per session. Do not report routine work or your own mistakes.",
|
|
814
|
+
shape: {
|
|
815
|
+
kind: z.enum(FEEDBACK_KINDS),
|
|
816
|
+
target: z.enum(FEEDBACK_TARGETS).optional().describe("What it is about: ork (the harness and its tools), workflow (this workflow's briefs, models, budgets, loops), or project (AGENTS.md, CLAUDE.md, skills, docs). Default: from the kind."),
|
|
817
|
+
severity: z.enum(FEEDBACK_SEVERITIES).optional(),
|
|
818
|
+
title: z.string().min(1).describe("One line."),
|
|
819
|
+
body: z.string().optional().describe("A few sentences: what happened and what would have helped."),
|
|
820
|
+
evidence: z.array(z.string()).optional().describe("File paths, tool names; never pasted file contents.")
|
|
821
|
+
},
|
|
822
|
+
available: ({ ports }) => ports.feedback !== void 0,
|
|
823
|
+
run: ({ ports }, args) => {
|
|
824
|
+
const draft = {
|
|
825
|
+
kind: args.kind,
|
|
826
|
+
title: args.title
|
|
827
|
+
};
|
|
828
|
+
if (args.target !== void 0) draft.target = args.target;
|
|
829
|
+
if (args.severity !== void 0) draft.severity = args.severity;
|
|
830
|
+
if (args.body !== void 0) draft.body = args.body;
|
|
831
|
+
if (args.evidence !== void 0) draft.evidence = args.evidence;
|
|
832
|
+
const sent = ports.feedback?.report(draft);
|
|
833
|
+
if (sent === void 0) return Promise.resolve(text("No feedback here."));
|
|
834
|
+
return Promise.resolve(text(sent.ok ? "Recorded. Thank you; carry on with your task." : `Not recorded: ${sent.error}.`));
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
// ../runtime/src/tools/input.ts
|
|
838
|
+
/**
|
|
839
|
+
* @file `read_input`: what the workflow sent a node back with, whole. A loop's
|
|
840
|
+
* re-entry note shows the first 8 KiB of it; when that cut it, the session
|
|
841
|
+
* gets this tool and reads the rest a page at a time, so a long review's last
|
|
842
|
+
* findings reach the node.
|
|
843
|
+
*/
|
|
844
|
+
/** The characters one call returns. */
|
|
845
|
+
const INPUT_PAGE = 32e3;
|
|
846
|
+
const readInputTool = defineTool({
|
|
847
|
+
name: "read_input",
|
|
848
|
+
description: `Read what the workflow sent your task back with, whole, when Ork's message cut it. Returns up to ${INPUT_PAGE.toLocaleString("en-US")} characters, from \`from\`.`,
|
|
849
|
+
shape: { from: z.number().int().nonnegative().optional().describe("The character to start at. Default: 0.") },
|
|
850
|
+
available: ({ ports }) => ports.input !== void 0,
|
|
851
|
+
run: ({ ports }, args) => {
|
|
852
|
+
const whole = ports.input ?? "";
|
|
853
|
+
const from = Math.min(args.from ?? 0, whole.length);
|
|
854
|
+
const end = Math.min(from + INPUT_PAGE, whole.length);
|
|
855
|
+
const where = end < whole.length ? `[characters ${String(from)}–${String(end)} of ${String(whole.length)}; call read_input with from: ${String(end)} for the rest]` : `[characters ${String(from)}–${String(end)} of ${String(whole.length)}: the end]`;
|
|
856
|
+
return Promise.resolve(text(`${whole.slice(from, end)}\n\n${where}`));
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
// ../runtime/src/tools/messages.ts
|
|
860
|
+
/**
|
|
861
|
+
* @file `send_message`, `read_inbox` and `answer`: a session's side of the
|
|
862
|
+
* run's message bus. A message lands in the recipient's inbox and a live
|
|
863
|
+
* session gets a one-line notice; `answer` replies to a question Ork asked an
|
|
864
|
+
* owner about a node it owns. Only sessions inside a run get them.
|
|
865
|
+
*/
|
|
866
|
+
/**
|
|
867
|
+
* Peers are mutual: a node no one may write to has no inbox to read, unless it
|
|
868
|
+
* may be linked later, since a session's tools are set when it starts.
|
|
869
|
+
*/
|
|
870
|
+
const hasPeers = ({ ports }) => (ports.mailbox?.peers().length ?? 0) > 0 || ports.mailbox?.linkable() === true;
|
|
871
|
+
/** A program or a rule may write to a node with no one to write to itself. */
|
|
872
|
+
const hasInbox = (context) => hasPeers(context) || context.ports.mailbox?.hears() === true;
|
|
873
|
+
const MAX_WAIT_SECONDS = 3600;
|
|
874
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
875
|
+
const inboxText = (unread) => JSON.stringify({ messages: unread.map((m) => ({
|
|
876
|
+
from: m.from,
|
|
877
|
+
...m.via === void 0 ? {} : { via: m.via },
|
|
878
|
+
body: m.body
|
|
879
|
+
})) });
|
|
880
|
+
const sendMessageTool = defineTool({
|
|
881
|
+
name: "send_message",
|
|
882
|
+
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.",
|
|
883
|
+
shape: {
|
|
884
|
+
to: z.string().describe("The node's id, or <run>/<node> for a linked node."),
|
|
885
|
+
body: z.string().min(1).describe("Self-contained: the recipient does not see your work.")
|
|
886
|
+
},
|
|
887
|
+
available: hasPeers,
|
|
888
|
+
run: ({ ports }, args) => {
|
|
889
|
+
const sent = ports.mailbox?.send(args.to, args.body);
|
|
890
|
+
if (sent === void 0) return Promise.resolve(text("No message bus."));
|
|
891
|
+
if (!sent.ok) return Promise.resolve(text(`Not sent: ${sent.error}.`));
|
|
892
|
+
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.`));
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
const readInboxTool = defineTool({
|
|
896
|
+
name: "read_inbox",
|
|
897
|
+
description: "Read your unread messages from other nodes, oldest first. Ork tells you when one arrives.",
|
|
898
|
+
shape: {},
|
|
899
|
+
available: hasInbox,
|
|
900
|
+
run: ({ ports }) => {
|
|
901
|
+
const unread = ports.mailbox?.read() ?? [];
|
|
902
|
+
return Promise.resolve(text(unread.length === 0 ? "No unread messages." : inboxText(unread)));
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
const waitForMessagesTool = defineTool({
|
|
906
|
+
name: "wait_for_messages",
|
|
907
|
+
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.",
|
|
908
|
+
shape: { timeoutSeconds: z.number().int().positive().max(MAX_WAIT_SECONDS).optional().describe(`How long to wait. Default: ${String(DEFAULT_WAIT_SECONDS)}.`) },
|
|
909
|
+
available: hasInbox,
|
|
910
|
+
run: async ({ ports }, args) => {
|
|
911
|
+
const seconds = args.timeoutSeconds ?? DEFAULT_WAIT_SECONDS;
|
|
912
|
+
await ports.mailbox?.wait(seconds * 1e3);
|
|
913
|
+
const unread = ports.mailbox?.read() ?? [];
|
|
914
|
+
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));
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
const answerTool = defineTool({
|
|
918
|
+
name: "answer",
|
|
919
|
+
description: "Answer a question Ork asked you as the owner of a node, such as a loop of yours that ran out of rounds. The question's id and options are in Ork's message.",
|
|
920
|
+
shape: {
|
|
921
|
+
question: z.string().describe("The question's id, e.g. 'q1'."),
|
|
922
|
+
choice: z.string().describe("One of its options."),
|
|
923
|
+
note: z.string().optional().describe("Why, in a sentence or two; it is recorded with your answer.")
|
|
924
|
+
},
|
|
925
|
+
available: ({ ports }) => ports.mailbox !== void 0 && ports.ownership !== void 0,
|
|
926
|
+
run: ({ ports }, args) => {
|
|
927
|
+
const answered = ports.mailbox?.answer(args.question, args.choice, args.note);
|
|
928
|
+
if (answered === void 0) return Promise.resolve(text("No questions."));
|
|
929
|
+
return Promise.resolve(text(answered.ok ? `Answered ${args.question}: ${args.choice}.` : `Not answered: ${answered.error}.`));
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
// ../runtime/src/tools/permission.ts
|
|
933
|
+
/**
|
|
934
|
+
* @file `permission`: the `claude` CLI's `--permission-prompt-tool`, which
|
|
935
|
+
* sends each permission prompt to the run's `HumanChannel`.
|
|
936
|
+
*/
|
|
937
|
+
const permissionTool = defineTool({
|
|
938
|
+
name: "permission",
|
|
939
|
+
description: "Ork's permission prompt for the claude CLI.",
|
|
940
|
+
shape: {
|
|
941
|
+
tool_name: z.string(),
|
|
942
|
+
input: z.record(z.string(), z.unknown()),
|
|
943
|
+
tool_use_id: z.string().optional()
|
|
944
|
+
},
|
|
945
|
+
preapproved: false,
|
|
946
|
+
available: ({ driver }) => driver === "claude-code",
|
|
947
|
+
run: async ({ nodeId, ports }, args) => {
|
|
948
|
+
const decision = await ports.human.permit({
|
|
949
|
+
nodeId,
|
|
950
|
+
toolName: args.tool_name,
|
|
951
|
+
input: args.input
|
|
952
|
+
});
|
|
953
|
+
return text(JSON.stringify(decision.allow ? {
|
|
954
|
+
behavior: "allow",
|
|
955
|
+
updatedInput: args.input
|
|
956
|
+
} : {
|
|
957
|
+
behavior: "deny",
|
|
958
|
+
message: decision.message
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
// ../runtime/src/tools/report.ts
|
|
963
|
+
/**
|
|
964
|
+
* @file The `report` tool: how a session hands Ork its final report when its
|
|
965
|
+
* driver cannot hold the final answer to a JSON Schema itself (`reportSchema`
|
|
966
|
+
* false, RFC-014). The report is checked against the node's report schema
|
|
967
|
+
* with zod; a report that breaks it comes back to the model as a tool error
|
|
968
|
+
* naming each violation, so it can fix it and call again. One that fits is
|
|
969
|
+
* kept as the session's report, and the session runner ends the session with
|
|
970
|
+
* it instead of what the tool's last line says.
|
|
971
|
+
*/
|
|
972
|
+
const REPORT_TOOL = "report";
|
|
973
|
+
/** Whether the driver holds a session's final answer to its schema itself. */
|
|
974
|
+
const checksItsReport = (driver) => AGENT_DRIVERS.find((d) => d.id === driver)?.capabilities.reportSchema ?? true;
|
|
975
|
+
/**
|
|
976
|
+
* The report schema as zod. shortcut: a schema zod cannot read takes any
|
|
977
|
+
* object, and `parseReport` still demands a status and a summary; upgrade if a
|
|
978
|
+
* node's `output.schema` ever uses JSON Schema zod does not support.
|
|
979
|
+
*/
|
|
980
|
+
const reportValidator = (schema) => {
|
|
981
|
+
try {
|
|
982
|
+
return z.fromJSONSchema(schema);
|
|
983
|
+
} catch {
|
|
984
|
+
return z.record(z.string(), z.unknown());
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
const failed = (why) => ({
|
|
988
|
+
...text(`The report was not accepted; fix it and call ${REPORT_TOOL} again.\n${why}`),
|
|
989
|
+
isError: true
|
|
990
|
+
});
|
|
991
|
+
const bound = (report) => {
|
|
992
|
+
const schema = reportValidator(report.schema);
|
|
993
|
+
return {
|
|
994
|
+
name: REPORT_TOOL,
|
|
995
|
+
description: "Hand in your final report: call this once your work is done, as your last step, with the report the task asks for. A report that does not fit comes back with what to fix; call again with the fixed report. The last report accepted is the one your owner gets.",
|
|
996
|
+
shape: schema instanceof z.ZodObject ? schema.shape : {},
|
|
997
|
+
schema,
|
|
998
|
+
preapproved: true,
|
|
999
|
+
call: (args) => {
|
|
1000
|
+
const parsed = schema.safeParse(args, { reportInput: true });
|
|
1001
|
+
if (!parsed.success) return Promise.resolve(failed(readableIssues(parsed.error.issues)));
|
|
1002
|
+
const kept = parseReport(parsed.data);
|
|
1003
|
+
if (kept === null) return Promise.resolve(failed("A report needs a `status` and a `summary`."));
|
|
1004
|
+
report.submit(kept);
|
|
1005
|
+
return Promise.resolve(text("Report accepted. End your turn now; there is nothing more to do."));
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
// ../runtime/src/tools/registry.ts
|
|
1010
|
+
/**
|
|
1011
|
+
* @file The registry of Ork's MCP tools. Both drivers serve what it binds for a
|
|
1012
|
+
* session; a new tool is one file here and one line in `ORK_TOOLS`.
|
|
1013
|
+
*/
|
|
1014
|
+
const ORK_TOOLS = [
|
|
1015
|
+
askUserTool,
|
|
1016
|
+
permissionTool,
|
|
1017
|
+
delegateTool,
|
|
1018
|
+
spawnWorkerTool,
|
|
1019
|
+
sendMessageTool,
|
|
1020
|
+
readInboxTool,
|
|
1021
|
+
waitForMessagesTool,
|
|
1022
|
+
answerTool,
|
|
1023
|
+
readInputTool,
|
|
1024
|
+
claimTool,
|
|
1025
|
+
releaseTool,
|
|
1026
|
+
feedbackTool,
|
|
1027
|
+
{
|
|
1028
|
+
name: REPORT_TOOL,
|
|
1029
|
+
bind: (context) => context.report === void 0 || checksItsReport(context.driver) ? null : bound(context.report)
|
|
1030
|
+
}
|
|
1031
|
+
];
|
|
1032
|
+
/** The tools one session gets. */
|
|
1033
|
+
const orkTools = (context) => ORK_TOOLS.flatMap((tool) => tool.bind(context) ?? []);
|
|
1034
|
+
/** Their Claude Code names, for the session's allowed tools. */
|
|
1035
|
+
const preapprovedTools = (tools) => tools.filter((t) => t.preapproved).map((t) => toolId(t.name));
|
|
1036
|
+
// ../runtime/src/session-runner.ts
|
|
1037
|
+
/**
|
|
1038
|
+
* @file The session runner: one agent CLI session, driven through an adapter's
|
|
1039
|
+
* strategies (`adapter.ts`), written once for every driver. It serves Ork's
|
|
1040
|
+
* tools on a local MCP endpoint, wires the session to it, and runs the tool
|
|
1041
|
+
* as one or more processes (legs). A paused node's tool call is held, and
|
|
1042
|
+
* past the hold window deferred; the next leg resumes the session once the
|
|
1043
|
+
* node continues (ADR-041). A tool that takes no message mid-session gets one
|
|
1044
|
+
* at the next leg, which resumes the session with it. A driver that cannot
|
|
1045
|
+
* hold the final answer to the report schema gets Ork's `report` tool, and
|
|
1046
|
+
* the session ends with the report it accepted. Interrupting stops the
|
|
1047
|
+
* running process.
|
|
1048
|
+
*/
|
|
1049
|
+
/**
|
|
1050
|
+
* What a session's end is written with: the node's limits and program, and its
|
|
1051
|
+
* folder and the home folder taken out of the detail.
|
|
1052
|
+
*/
|
|
1053
|
+
const scopeOf = (spec, bin, env) => ({
|
|
1054
|
+
facts: {
|
|
1055
|
+
...spec.maxTurns === void 0 ? {} : { maxTurns: spec.maxTurns },
|
|
1056
|
+
...spec.budgetUsd === void 0 ? {} : { budgetUsd: spec.budgetUsd },
|
|
1057
|
+
program: path.basename(bin)
|
|
1058
|
+
},
|
|
1059
|
+
scrub: {
|
|
1060
|
+
roots: [spec.cwd],
|
|
1061
|
+
home: env["HOME"] ?? homedir()
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
/** A spec that resumes `sessionId`; a fork's later legs continue the fork. */
|
|
1065
|
+
const resumed = (spec, sessionId) => ({
|
|
1066
|
+
...spec,
|
|
1067
|
+
resume: sessionId,
|
|
1068
|
+
fork: false
|
|
1069
|
+
});
|
|
1070
|
+
/**
|
|
1071
|
+
* Wires a session to Ork's endpoint, yielding a `session.waiting` event each
|
|
1072
|
+
* time the wiring says it waits (a lease waits for its checkout).
|
|
1073
|
+
*/
|
|
1074
|
+
const wireSaying = async function* (adapter, spec, endpoint, signal, options) {
|
|
1075
|
+
const waits = [];
|
|
1076
|
+
let woke;
|
|
1077
|
+
const woken = () => new Promise((resolve) => {
|
|
1078
|
+
woke = () => {
|
|
1079
|
+
resolve(false);
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
const wiring = adapter.mcp.wire(spec, endpoint, {
|
|
1083
|
+
waiting: (reason) => {
|
|
1084
|
+
waits.push(reason);
|
|
1085
|
+
woke?.();
|
|
1086
|
+
},
|
|
1087
|
+
signal,
|
|
1088
|
+
bin: options.bin ?? adapter.command.bin,
|
|
1089
|
+
env: options.env ?? process.env,
|
|
1090
|
+
...options.runs === void 0 ? {} : { runs: options.runs }
|
|
1091
|
+
});
|
|
1092
|
+
const done = wiring.then(() => true, () => true);
|
|
1093
|
+
for (;;) {
|
|
1094
|
+
const finished = waits.length === 0 && await Promise.race([done, woken()]);
|
|
1095
|
+
for (const reason of waits.splice(0)) yield {
|
|
1096
|
+
type: "session.waiting",
|
|
1097
|
+
reason
|
|
1098
|
+
};
|
|
1099
|
+
if (finished) return wiring;
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
/**
|
|
1103
|
+
* A runtime that drives `adapter`'s tool, as the user installed and logged it
|
|
1104
|
+
* in. Ork never reads or passes its credentials.
|
|
1105
|
+
*/
|
|
1106
|
+
const createSessionRuntime = (adapter, options = {}) => ({
|
|
1107
|
+
driver: adapter.driver,
|
|
1108
|
+
start: (spec, ports) => {
|
|
1109
|
+
const { input } = adapter.command;
|
|
1110
|
+
const pending = input === void 0 ? [] : [input(spec.prompt, false)];
|
|
1111
|
+
const queued = [];
|
|
1112
|
+
let io;
|
|
1113
|
+
const ended = new AbortController();
|
|
1114
|
+
/** The hold hook deferred a call in the running process. */
|
|
1115
|
+
let deferring = false;
|
|
1116
|
+
/** The last report the `report` tool accepted, across legs. */
|
|
1117
|
+
let reported = null;
|
|
1118
|
+
const { hold } = ports;
|
|
1119
|
+
const hook = hold === void 0 ? void 0 : adapter.hold;
|
|
1120
|
+
const heavy = heavyHook(ports.machine);
|
|
1121
|
+
ended.signal.addEventListener("abort", () => {
|
|
1122
|
+
heavy.release();
|
|
1123
|
+
}, { once: true });
|
|
1124
|
+
const bin = options.bin ?? adapter.command.bin;
|
|
1125
|
+
const scope = scopeOf(spec, bin, options.env ?? process.env);
|
|
1126
|
+
const events = async function* () {
|
|
1127
|
+
const tools = orkTools({
|
|
1128
|
+
nodeId: spec.nodeId,
|
|
1129
|
+
driver: adapter.driver,
|
|
1130
|
+
ports,
|
|
1131
|
+
report: {
|
|
1132
|
+
schema: spec.reportSchema,
|
|
1133
|
+
submit: (report) => {
|
|
1134
|
+
reported = report;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
const byTool = tools.some((tool) => tool.name === REPORT_TOOL);
|
|
1139
|
+
const mcp = await startOrkMcpHttp(tools, hold === void 0 || hook === void 0 ? void 0 : toolHook({
|
|
1140
|
+
hold,
|
|
1141
|
+
heavy,
|
|
1142
|
+
answer: hook.answer,
|
|
1143
|
+
windowMs: options.holdWindowMs ?? hook.windowMs,
|
|
1144
|
+
ended: ended.signal,
|
|
1145
|
+
onDefer: () => {
|
|
1146
|
+
deferring = true;
|
|
1147
|
+
}
|
|
1148
|
+
}));
|
|
1149
|
+
const endpoint = hook === void 0 ? {
|
|
1150
|
+
url: mcp.url,
|
|
1151
|
+
token: mcp.token
|
|
1152
|
+
} : mcp;
|
|
1153
|
+
const allowed = preapprovedTools(tools);
|
|
1154
|
+
let wired;
|
|
1155
|
+
try {
|
|
1156
|
+
try {
|
|
1157
|
+
wired = yield* wireSaying(adapter, spec, endpoint, ended.signal, options);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
yield driverFailure(`could not reach Ork's tools: ${error instanceof Error ? error.message : String(error)}`, failureKind(error), scope);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const env = {
|
|
1163
|
+
...options.env ?? process.env,
|
|
1164
|
+
PWD: spec.cwd,
|
|
1165
|
+
...adapter.context.env(spec),
|
|
1166
|
+
...wired.env
|
|
1167
|
+
};
|
|
1168
|
+
const legOf = (legSpec, carried) => ({
|
|
1169
|
+
adapter,
|
|
1170
|
+
bin,
|
|
1171
|
+
env,
|
|
1172
|
+
scope,
|
|
1173
|
+
cwd: spec.cwd,
|
|
1174
|
+
...ports.machine === void 0 ? {} : { priority: ports.machine.priority },
|
|
1175
|
+
args: [...adapter.command.args(legSpec, {
|
|
1176
|
+
endpoint,
|
|
1177
|
+
allowed
|
|
1178
|
+
}), ...wired?.args ?? []],
|
|
1179
|
+
carried,
|
|
1180
|
+
...adapter.legDecoder === void 0 ? {} : { decoder: adapter.legDecoder(legSpec) },
|
|
1181
|
+
pending,
|
|
1182
|
+
deferred: () => deferring,
|
|
1183
|
+
withhold: () => input === void 0 && queued.length > 0,
|
|
1184
|
+
...byTool ? { reported: () => reported } : {},
|
|
1185
|
+
attach: (attached) => {
|
|
1186
|
+
io = attached;
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
let legSpec = spec;
|
|
1190
|
+
let spent;
|
|
1191
|
+
for (;;) {
|
|
1192
|
+
if (ended.signal.aborted) {
|
|
1193
|
+
yield driverFailure("interrupted", "interrupted", scope);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const leg = yield* runLeg(legOf(legSpec, spent));
|
|
1197
|
+
if (leg.kind === "failed") {
|
|
1198
|
+
yield driverFailure(leg.reason, leg.end, scope);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (leg.kind === "finished") return;
|
|
1202
|
+
deferring = false;
|
|
1203
|
+
spent = spent === void 0 ? leg.usage : adapter.usage.combine(spent, leg.usage);
|
|
1204
|
+
if (leg.kind === "deferred") {
|
|
1205
|
+
await heavy.ready();
|
|
1206
|
+
await hold?.wait("tool", ended.signal);
|
|
1207
|
+
}
|
|
1208
|
+
if (ended.signal.aborted) {
|
|
1209
|
+
yield leg.last;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
legSpec = leg.kind === "continued" ? {
|
|
1213
|
+
...resumed(spec, leg.sessionId),
|
|
1214
|
+
prompt: queued.splice(0).join("\n\n")
|
|
1215
|
+
} : resumed(spec, leg.sessionId);
|
|
1216
|
+
}
|
|
1217
|
+
} finally {
|
|
1218
|
+
heavy.release();
|
|
1219
|
+
await wired?.release();
|
|
1220
|
+
await mcp.close();
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
return {
|
|
1224
|
+
events: events(),
|
|
1225
|
+
send: (text) => {
|
|
1226
|
+
if (input === void 0) {
|
|
1227
|
+
queued.push(text);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const line = input(text, true);
|
|
1231
|
+
if (io === void 0) pending.push(line);
|
|
1232
|
+
else io.write(line);
|
|
1233
|
+
},
|
|
1234
|
+
interrupt: () => {
|
|
1235
|
+
ended.abort();
|
|
1236
|
+
io?.stop();
|
|
1237
|
+
return Promise.resolve();
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
// ../runtime/src/adapters/claude-code-events.ts
|
|
1243
|
+
/**
|
|
1244
|
+
* @file The `claude-code` adapter's `EventDecoder` and `UsageReader`: maps the
|
|
1245
|
+
* `claude` CLI's `stream-json` lines to Ork's runtime events, marks the
|
|
1246
|
+
* result of a turn the hold hook deferred, and adds up a session's legs.
|
|
1247
|
+
*/
|
|
1248
|
+
const isObject$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1249
|
+
const get$1 = (value, key) => isObject$2(value) ? value[key] : void 0;
|
|
1250
|
+
const num$2 = (value) => typeof value === "number" ? value : 0;
|
|
1251
|
+
const str$2 = (value) => typeof value === "string" ? value : "";
|
|
1252
|
+
const blocks = (message) => {
|
|
1253
|
+
const content = get$1(get$1(message, "message"), "content");
|
|
1254
|
+
return Array.isArray(content) ? content.filter((item) => isObject$2(item)) : [];
|
|
1255
|
+
};
|
|
1256
|
+
const usageOf = (result) => {
|
|
1257
|
+
const usage = get$1(result, "usage");
|
|
1258
|
+
return {
|
|
1259
|
+
costUsd: num$2(result["total_cost_usd"]),
|
|
1260
|
+
inputTokens: num$2(get$1(usage, "input_tokens")),
|
|
1261
|
+
outputTokens: num$2(get$1(usage, "output_tokens")),
|
|
1262
|
+
cacheReadTokens: num$2(get$1(usage, "cache_read_input_tokens")),
|
|
1263
|
+
cacheWriteTokens: num$2(get$1(usage, "cache_creation_input_tokens")),
|
|
1264
|
+
turns: num$2(result["num_turns"]),
|
|
1265
|
+
durationMs: num$2(result["duration_ms"])
|
|
1266
|
+
};
|
|
1267
|
+
};
|
|
1268
|
+
/** Claude Code's error subtypes that name their own kind. */
|
|
1269
|
+
const SUBTYPE_KINDS = {
|
|
1270
|
+
error_max_turns: "turn_limit",
|
|
1271
|
+
error_max_budget_usd: "budget_limit",
|
|
1272
|
+
error_max_structured_output_retries: "invalid_report"
|
|
1273
|
+
};
|
|
1274
|
+
/**
|
|
1275
|
+
* What a result says went wrong, if anything: a subtype that names its kind, or
|
|
1276
|
+
* an error (`is_error`, or an error subtype) read by the provider classifier,
|
|
1277
|
+
* `unknown` when it says nothing Ork knows.
|
|
1278
|
+
*/
|
|
1279
|
+
const signalOf$1 = (subtype, result, errors) => {
|
|
1280
|
+
const text = [...errors, str$2(result["result"])].join("\n").trim();
|
|
1281
|
+
const named = SUBTYPE_KINDS[subtype];
|
|
1282
|
+
if (named !== void 0) return endFor(named, {}, text);
|
|
1283
|
+
if (subtype === "success" && result["is_error"] !== true) return null;
|
|
1284
|
+
return errorEnd(text);
|
|
1285
|
+
};
|
|
1286
|
+
/** The outcome a `result` message describes. */
|
|
1287
|
+
const outcomeOf = (result) => {
|
|
1288
|
+
const said = result["errors"];
|
|
1289
|
+
const errors = Array.isArray(said) ? said.filter((e) => typeof e === "string") : [];
|
|
1290
|
+
const subtype = str$2(result["subtype"]) || "unknown";
|
|
1291
|
+
const report = parseReport(result["structured_output"]);
|
|
1292
|
+
return {
|
|
1293
|
+
subtype,
|
|
1294
|
+
end: sessionEnd(signalOf$1(subtype, result, errors), report),
|
|
1295
|
+
sessionId: str$2(result["session_id"]),
|
|
1296
|
+
report,
|
|
1297
|
+
text: str$2(result["result"]),
|
|
1298
|
+
usage: usageOf(result),
|
|
1299
|
+
errors
|
|
1300
|
+
};
|
|
1301
|
+
};
|
|
1302
|
+
/** The year 2286: a reset beyond this is not in seconds. */
|
|
1303
|
+
const MAX_RESET_SECONDS = 1e10;
|
|
1304
|
+
/**
|
|
1305
|
+
* What a subscription's limit said (RFC-007). Only the fields Claude Code
|
|
1306
|
+
* documents are read, so `utilization`, often absent, plays no part.
|
|
1307
|
+
*/
|
|
1308
|
+
const planLimitOf = (info) => {
|
|
1309
|
+
const status = get$1(info, "status");
|
|
1310
|
+
if (status !== "allowed" && status !== "allowed_warning" && status !== "rejected") return [];
|
|
1311
|
+
const window = get$1(info, "rateLimitType");
|
|
1312
|
+
const resets = get$1(info, "resetsAt");
|
|
1313
|
+
const overage = get$1(info, "overageStatus");
|
|
1314
|
+
return [{
|
|
1315
|
+
type: "plan.limit",
|
|
1316
|
+
status: status === "rejected" && !(status === "rejected" && overage !== "allowed" && overage !== "allowed_warning") ? "allowed_warning" : status,
|
|
1317
|
+
window: typeof window === "string" ? window : "plan",
|
|
1318
|
+
...typeof resets === "number" && resets > 0 && resets < MAX_RESET_SECONDS ? { resetsAt: (/* @__PURE__ */ new Date(resets * 1e3)).toISOString() } : {}
|
|
1319
|
+
}];
|
|
1320
|
+
};
|
|
1321
|
+
/** Maps one driver message to zero or more normalized events. */
|
|
1322
|
+
const normalize = (message) => {
|
|
1323
|
+
if (!isObject$2(message)) return [];
|
|
1324
|
+
switch (message["type"]) {
|
|
1325
|
+
case "system": {
|
|
1326
|
+
if (message["subtype"] !== "init") return [];
|
|
1327
|
+
const started = {
|
|
1328
|
+
type: "session.started",
|
|
1329
|
+
sessionId: str$2(message["session_id"]),
|
|
1330
|
+
model: str$2(message["model"])
|
|
1331
|
+
};
|
|
1332
|
+
if (str$2(message["cwd"]) !== "") started.cwd = str$2(message["cwd"]);
|
|
1333
|
+
return [started];
|
|
1334
|
+
}
|
|
1335
|
+
case "assistant": return blocks(message).flatMap((block) => {
|
|
1336
|
+
if (block["type"] === "text" && str$2(block["text"]).trim() !== "") return [{
|
|
1337
|
+
type: "assistant.text",
|
|
1338
|
+
text: str$2(block["text"])
|
|
1339
|
+
}];
|
|
1340
|
+
if (block["type"] === "tool_use") {
|
|
1341
|
+
const input = block["input"];
|
|
1342
|
+
return [{
|
|
1343
|
+
type: "tool.called",
|
|
1344
|
+
id: str$2(block["id"]),
|
|
1345
|
+
name: str$2(block["name"]),
|
|
1346
|
+
input: isObject$2(input) ? input : {}
|
|
1347
|
+
}];
|
|
1348
|
+
}
|
|
1349
|
+
return [];
|
|
1350
|
+
});
|
|
1351
|
+
case "user": return blocks(message).filter((block) => block["type"] === "tool_result").map((block) => ({
|
|
1352
|
+
type: "tool.result",
|
|
1353
|
+
id: str$2(block["tool_use_id"]),
|
|
1354
|
+
isError: block["is_error"] === true
|
|
1355
|
+
}));
|
|
1356
|
+
case "rate_limit_event": return planLimitOf(message["rate_limit_info"]);
|
|
1357
|
+
case "result": return [{
|
|
1358
|
+
type: "session.finished",
|
|
1359
|
+
outcome: outcomeOf(message)
|
|
1360
|
+
}];
|
|
1361
|
+
default: return [];
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
/** Whether a stream-json message is the result of a deferred tool call. */
|
|
1365
|
+
const isDeferredResult = (message) => get$1(message, "type") === "result" && get$1(message, "stop_reason") === "tool_deferred";
|
|
1366
|
+
const parseLine$2 = (line) => {
|
|
1367
|
+
try {
|
|
1368
|
+
return JSON.parse(line);
|
|
1369
|
+
} catch {
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
/** One `stream-json` line, decoded. */
|
|
1374
|
+
const decodeClaudeLine = (line) => {
|
|
1375
|
+
const message = parseLine$2(line);
|
|
1376
|
+
return {
|
|
1377
|
+
events: normalize(message),
|
|
1378
|
+
deferred: isDeferredResult(message)
|
|
1379
|
+
};
|
|
1380
|
+
};
|
|
1381
|
+
/**
|
|
1382
|
+
* Two legs of one session, as one: the tokens of both, and the later leg's
|
|
1383
|
+
* cost, which is already the session's total (ADR-046).
|
|
1384
|
+
*/
|
|
1385
|
+
const addUsage = (a, b) => ({
|
|
1386
|
+
costUsd: Math.max(a.costUsd, b.costUsd),
|
|
1387
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
1388
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
1389
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
1390
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
1391
|
+
turns: a.turns + b.turns,
|
|
1392
|
+
durationMs: a.durationMs + b.durationMs
|
|
1393
|
+
});
|
|
1394
|
+
/** `claude` reports dollars (a client-side estimate) and tokens. */
|
|
1395
|
+
const claudeUsage = {
|
|
1396
|
+
dollars: true,
|
|
1397
|
+
combine: addUsage
|
|
1398
|
+
};
|
|
1399
|
+
// ../runtime/src/adapters/claude-code.ts
|
|
1400
|
+
/**
|
|
1401
|
+
* Per-request timeout for Ork's MCP server, in ms. The CLI otherwise gives an
|
|
1402
|
+
* HTTP server's request 60 seconds and drops a call silent for 5 minutes, and
|
|
1403
|
+
* `delegate` waits for whole child sessions (the per-server `timeout` raises
|
|
1404
|
+
* both; `MCP_TOOL_TIMEOUT` still caps it).
|
|
1405
|
+
*/
|
|
1406
|
+
const ORK_TOOL_TIMEOUT_MS = 864e5;
|
|
1407
|
+
/** Claude Code's switch for the one-hour prompt cache. */
|
|
1408
|
+
const PROMPT_CACHE_1H = "ENABLE_PROMPT_CACHING_1H";
|
|
1409
|
+
/** Claude Code's switch that turns its auto-memory off. */
|
|
1410
|
+
const NO_AUTO_MEMORY = "CLAUDE_CODE_DISABLE_AUTO_MEMORY";
|
|
1411
|
+
/**
|
|
1412
|
+
* The CLI flag that moves the per-machine sections (working directory, memory
|
|
1413
|
+
* path, git status) out of the system prompt.
|
|
1414
|
+
*/
|
|
1415
|
+
const EXCLUDE_DYNAMIC_SECTIONS = "--exclude-dynamic-system-prompt-sections";
|
|
1416
|
+
/** How long a hook holds a call before it defers it instead. */
|
|
1417
|
+
const HOLD_WINDOW_MS = 54e4;
|
|
1418
|
+
/** The variables a session's prompt cache asks of Claude Code. */
|
|
1419
|
+
const cacheEnv = (spec) => spec.promptCache === "1h" ? { [PROMPT_CACHE_1H]: "1" } : {};
|
|
1420
|
+
/** Whether the session loads no MCP server but Ork's. */
|
|
1421
|
+
const strictMcpOf = (spec) => spec.tools.strictMcp === true || spec.machineClaude !== true;
|
|
1422
|
+
/** The variables that keep the machine's auto-memory out of a session. */
|
|
1423
|
+
const memoryEnv = (spec) => spec.machineClaude === true ? {} : { [NO_AUTO_MEMORY]: "1" };
|
|
1424
|
+
/** Everything a session adds to its driver's environment. */
|
|
1425
|
+
const driverEnv = (spec) => ({
|
|
1426
|
+
...cacheEnv(spec),
|
|
1427
|
+
...memoryEnv(spec)
|
|
1428
|
+
});
|
|
1429
|
+
/** A later message goes in at the next step, even mid-turn. */
|
|
1430
|
+
const userLine = (text, later = false) => `${JSON.stringify({
|
|
1431
|
+
type: "user",
|
|
1432
|
+
message: {
|
|
1433
|
+
role: "user",
|
|
1434
|
+
content: text
|
|
1435
|
+
},
|
|
1436
|
+
parent_tool_use_id: null,
|
|
1437
|
+
...later ? { priority: "next" } : {}
|
|
1438
|
+
})}\n`;
|
|
1439
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1440
|
+
/**
|
|
1441
|
+
* The node's `--settings` with Ork's `PreToolUse` HTTP hook added: before every
|
|
1442
|
+
* tool call, pre-approved ones included, the session asks whether its node may
|
|
1443
|
+
* go on. Its `PostToolUse` hook after a Bash call frees the call's slot.
|
|
1444
|
+
*/
|
|
1445
|
+
const withHoldHook = (settings, holdUrl, token) => {
|
|
1446
|
+
const hooks = isRecord(settings?.["hooks"]) ? settings["hooks"] : {};
|
|
1447
|
+
const listed = (event) => {
|
|
1448
|
+
const own = hooks[event];
|
|
1449
|
+
return Array.isArray(own) ? [...own] : [];
|
|
1450
|
+
};
|
|
1451
|
+
const hook = {
|
|
1452
|
+
type: "http",
|
|
1453
|
+
url: holdUrl,
|
|
1454
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1455
|
+
timeout: 600
|
|
1456
|
+
};
|
|
1457
|
+
return {
|
|
1458
|
+
...settings,
|
|
1459
|
+
hooks: {
|
|
1460
|
+
...hooks,
|
|
1461
|
+
PreToolUse: [...listed("PreToolUse"), {
|
|
1462
|
+
matcher: "*",
|
|
1463
|
+
hooks: [hook]
|
|
1464
|
+
}],
|
|
1465
|
+
PostToolUse: [...listed("PostToolUse"), {
|
|
1466
|
+
matcher: "Bash",
|
|
1467
|
+
hooks: [hook]
|
|
1468
|
+
}]
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
};
|
|
1472
|
+
/** The hook's answer: nothing to say for "go", the deferral otherwise. */
|
|
1473
|
+
const hookOutput = (answer) => answer === "go" ? {} : { hookSpecificOutput: {
|
|
1474
|
+
hookEventName: "PreToolUse",
|
|
1475
|
+
permissionDecision: "defer",
|
|
1476
|
+
permissionDecisionReason: "Ork paused this node; it goes on from this call when it continues."
|
|
1477
|
+
} };
|
|
1478
|
+
const claudeHold = {
|
|
1479
|
+
answer: hookOutput,
|
|
1480
|
+
windowMs: HOLD_WINDOW_MS
|
|
1481
|
+
};
|
|
1482
|
+
/**
|
|
1483
|
+
* The `claude -p` arguments for a session spec. The brief goes on stdin;
|
|
1484
|
+
* `allowed` are the Ork tools that need no permission prompt.
|
|
1485
|
+
*/
|
|
1486
|
+
const buildCliArgs = (spec, mcp, allowed) => {
|
|
1487
|
+
const mcpConfig = { mcpServers: {
|
|
1488
|
+
...spec.mcpServers,
|
|
1489
|
+
["ork"]: {
|
|
1490
|
+
type: "http",
|
|
1491
|
+
url: mcp.url,
|
|
1492
|
+
headers: { Authorization: `Bearer ${mcp.token}` },
|
|
1493
|
+
timeout: ORK_TOOL_TIMEOUT_MS
|
|
1494
|
+
}
|
|
1495
|
+
} };
|
|
1496
|
+
const args = [
|
|
1497
|
+
"-p",
|
|
1498
|
+
"--input-format",
|
|
1499
|
+
"stream-json",
|
|
1500
|
+
"--output-format",
|
|
1501
|
+
"stream-json",
|
|
1502
|
+
"--verbose",
|
|
1503
|
+
"--setting-sources",
|
|
1504
|
+
spec.settingSources.join(","),
|
|
1505
|
+
"--append-system-prompt",
|
|
1506
|
+
spec.systemAppend,
|
|
1507
|
+
EXCLUDE_DYNAMIC_SECTIONS,
|
|
1508
|
+
"--permission-mode",
|
|
1509
|
+
spec.permissionMode,
|
|
1510
|
+
"--json-schema",
|
|
1511
|
+
JSON.stringify(spec.reportSchema),
|
|
1512
|
+
"--mcp-config",
|
|
1513
|
+
JSON.stringify(mcpConfig),
|
|
1514
|
+
"--permission-prompt-tool",
|
|
1515
|
+
toolId("permission"),
|
|
1516
|
+
"--allowedTools",
|
|
1517
|
+
[...spec.tools.allow, ...allowed].join(",")
|
|
1518
|
+
];
|
|
1519
|
+
if (spec.tools.only !== void 0) args.push("--tools", spec.tools.only.join(","));
|
|
1520
|
+
if (strictMcpOf(spec)) args.push("--strict-mcp-config");
|
|
1521
|
+
if (spec.agents !== void 0) args.push("--agents", JSON.stringify(spec.agents));
|
|
1522
|
+
if (spec.tools.deny.length > 0) args.push("--disallowedTools", spec.tools.deny.join(","));
|
|
1523
|
+
if (spec.model !== void 0) args.push("--model", spec.model);
|
|
1524
|
+
if (spec.effort !== void 0) args.push("--effort", spec.effort);
|
|
1525
|
+
if (spec.maxTurns !== void 0) args.push("--max-turns", String(spec.maxTurns));
|
|
1526
|
+
if (spec.budgetUsd !== void 0) args.push("--max-budget-usd", String(spec.budgetUsd));
|
|
1527
|
+
if (spec.resume !== void 0) args.push("--resume", spec.resume);
|
|
1528
|
+
if (spec.resume !== void 0 && spec.fork === true) args.push("--fork-session");
|
|
1529
|
+
for (const dir of spec.plugins ?? []) args.push("--plugin-dir", dir);
|
|
1530
|
+
const settings = mcp.holdUrl === void 0 ? spec.settings : withHoldHook(spec.settings, mcp.holdUrl, mcp.token);
|
|
1531
|
+
if (settings !== void 0) args.push("--settings", JSON.stringify(settings));
|
|
1532
|
+
return args;
|
|
1533
|
+
};
|
|
1534
|
+
/** `claude`, as the session runner's strategies. */
|
|
1535
|
+
const claudeCodeAdapter = {
|
|
1536
|
+
driver: "claude-code",
|
|
1537
|
+
command: {
|
|
1538
|
+
bin: "claude",
|
|
1539
|
+
args: (spec, leg) => buildCliArgs(spec, leg.endpoint, leg.allowed),
|
|
1540
|
+
input: userLine
|
|
1541
|
+
},
|
|
1542
|
+
decode: decodeClaudeLine,
|
|
1543
|
+
mcp: {
|
|
1544
|
+
kind: "flag",
|
|
1545
|
+
wire: () => Promise.resolve({
|
|
1546
|
+
args: [],
|
|
1547
|
+
env: {},
|
|
1548
|
+
release: () => Promise.resolve()
|
|
1549
|
+
})
|
|
1550
|
+
},
|
|
1551
|
+
permissions: {
|
|
1552
|
+
kind: "callback",
|
|
1553
|
+
denyByDefault: false
|
|
1554
|
+
},
|
|
1555
|
+
usage: claudeUsage,
|
|
1556
|
+
context: {
|
|
1557
|
+
instructions: ["CLAUDE.md", "AGENTS.md"],
|
|
1558
|
+
skills: ".claude/skills",
|
|
1559
|
+
agents: ".claude/agents",
|
|
1560
|
+
env: driverEnv
|
|
1561
|
+
},
|
|
1562
|
+
hold: claudeHold
|
|
1563
|
+
};
|
|
1564
|
+
// ../runtime/src/lease-ledger.ts
|
|
1565
|
+
/**
|
|
1566
|
+
* @file The ledger of Command Code leases (RFC-014, `lease.ts`): one record per
|
|
1567
|
+
* checkout in a folder of the project's runs folder, naming the session's
|
|
1568
|
+
* process and its `ork` entry, and the sweep that removes the entry of every
|
|
1569
|
+
* record whose process is gone (the next session's start, and `ork doctor`).
|
|
1570
|
+
* Only the `ork` entry is touched; the user's other entries stay as they
|
|
1571
|
+
* were.
|
|
1572
|
+
*/
|
|
1573
|
+
/** One session's hold on a checkout. `pid` 0: its entry could not be removed. */
|
|
1574
|
+
const LeaseSchema = z.object({
|
|
1575
|
+
id: z.string(),
|
|
1576
|
+
folder: z.string(),
|
|
1577
|
+
pid: z.number(),
|
|
1578
|
+
entry: z.string()
|
|
1579
|
+
});
|
|
1580
|
+
/** A local-scope file: only `mcpServers` is read; the rest is kept as is. */
|
|
1581
|
+
const LocalScopeSchema = z.looseObject({ mcpServers: z.record(z.string(), z.unknown()).optional() });
|
|
1582
|
+
const codeOf = (error) => error instanceof Error && "code" in error ? error.code : void 0;
|
|
1583
|
+
const CMD_TIMEOUT_MS = 3e4;
|
|
1584
|
+
/** Where a project's lease records live, inside its runs folder. */
|
|
1585
|
+
const leaseLedger = (runsDir) => path.join(runsDir, ".leases");
|
|
1586
|
+
const alive = (pid) => {
|
|
1587
|
+
if (pid <= 0) return false;
|
|
1588
|
+
try {
|
|
1589
|
+
process.kill(pid, 0);
|
|
1590
|
+
return true;
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
return codeOf(error) === "EPERM";
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
const readLease = (file) => {
|
|
1596
|
+
try {
|
|
1597
|
+
return LeaseSchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
1598
|
+
} catch {
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
const runCmd = (options, args, cwd) => new Promise((resolve) => {
|
|
1603
|
+
execFile(options.bin, args, {
|
|
1604
|
+
cwd,
|
|
1605
|
+
env: { ...options.env ?? process.env },
|
|
1606
|
+
encoding: "utf8",
|
|
1607
|
+
timeout: CMD_TIMEOUT_MS
|
|
1608
|
+
}, (error, stdout, stderr) => {
|
|
1609
|
+
const said = `${stdout}${stderr}`.trim();
|
|
1610
|
+
const missing = error?.code === "ENOENT" ? `spawn ${options.bin} ENOENT` : "";
|
|
1611
|
+
resolve({
|
|
1612
|
+
ok: error === null,
|
|
1613
|
+
out: said || missing
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
/** Drops the `ork` entry from a local-scope file whose folder is gone. */
|
|
1618
|
+
const dropFromFile = (entry) => {
|
|
1619
|
+
if (!existsSync(entry)) return true;
|
|
1620
|
+
try {
|
|
1621
|
+
const config = LocalScopeSchema.parse(JSON.parse(readFileSync(entry, "utf8")));
|
|
1622
|
+
const { ["ork"]: ours, ...others } = config.mcpServers ?? {};
|
|
1623
|
+
if (ours === void 0) return true;
|
|
1624
|
+
const kept = {
|
|
1625
|
+
...config,
|
|
1626
|
+
mcpServers: others
|
|
1627
|
+
};
|
|
1628
|
+
writeFileSync(entry, `${JSON.stringify(kept, null, 2)}\n`, { mode: 384 });
|
|
1629
|
+
return true;
|
|
1630
|
+
} catch {
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
/** Removes a lease's `ork` entry; true once it is gone. */
|
|
1635
|
+
const removeEntry = async (options, lease) => {
|
|
1636
|
+
if (!existsSync(lease.folder)) return dropFromFile(lease.entry);
|
|
1637
|
+
const removed = await runCmd(options, [
|
|
1638
|
+
"mcp",
|
|
1639
|
+
"remove",
|
|
1640
|
+
"--scope",
|
|
1641
|
+
"local",
|
|
1642
|
+
"ork"
|
|
1643
|
+
], lease.folder);
|
|
1644
|
+
return removed.ok || removed.out.includes("not found");
|
|
1645
|
+
};
|
|
1646
|
+
/** Deletes a record if it is still `lease`'s, not a newer one's. */
|
|
1647
|
+
const dropRecord = (file, lease) => {
|
|
1648
|
+
if (readLease(file)?.id === lease.id) rmSync(file, { force: true });
|
|
1649
|
+
};
|
|
1650
|
+
/**
|
|
1651
|
+
* Deletes a record that exists but can't be read: records are written whole (a
|
|
1652
|
+
* link, a rename), so one that does not parse is corrupt, and stale.
|
|
1653
|
+
*/
|
|
1654
|
+
const dropIfCorrupt = (file) => {
|
|
1655
|
+
let text;
|
|
1656
|
+
try {
|
|
1657
|
+
text = readFileSync(file, "utf8");
|
|
1658
|
+
} catch {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
LeaseSchema.parse(JSON.parse(text));
|
|
1663
|
+
} catch {
|
|
1664
|
+
rmSync(file, { force: true });
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
/**
|
|
1668
|
+
* Removes the `ork` entry of every lease whose session is gone (a crash, or a
|
|
1669
|
+
* removal that failed), and its record. Live leases are left alone.
|
|
1670
|
+
*/
|
|
1671
|
+
const cleanStaleLeases = async (options) => {
|
|
1672
|
+
const sweep = {
|
|
1673
|
+
cleaned: [],
|
|
1674
|
+
failed: []
|
|
1675
|
+
};
|
|
1676
|
+
if (!existsSync(options.ledger)) return sweep;
|
|
1677
|
+
for (const name of readdirSync(options.ledger)) {
|
|
1678
|
+
if (!name.endsWith(".json")) continue;
|
|
1679
|
+
const file = path.join(options.ledger, name);
|
|
1680
|
+
const lease = readLease(file);
|
|
1681
|
+
if (lease === void 0) dropIfCorrupt(file);
|
|
1682
|
+
if (lease === void 0 || alive(lease.pid)) continue;
|
|
1683
|
+
if (await removeEntry(options, lease)) {
|
|
1684
|
+
dropRecord(file, lease);
|
|
1685
|
+
sweep.cleaned.push(lease.folder);
|
|
1686
|
+
} else sweep.failed.push(lease.folder);
|
|
1687
|
+
}
|
|
1688
|
+
return sweep;
|
|
1689
|
+
};
|
|
1690
|
+
// ../runtime/src/lease.ts
|
|
1691
|
+
/**
|
|
1692
|
+
* @file The `lease` kind of `McpWiring` (RFC-014): Command Code reads MCP
|
|
1693
|
+
* servers from a per-folder local scope (`~/.commandcode/projects/<slug>/`),
|
|
1694
|
+
* so a session gets Ork's endpoint through an `ork` entry added there with
|
|
1695
|
+
* `cmd mcp add --scope local` and removed with `cmd mcp remove` after it.
|
|
1696
|
+
* Because the entry is per folder, a checkout holds one session at a time: a
|
|
1697
|
+
* record in the ledger (`lease-ledger.ts`) is the lock, and a second session
|
|
1698
|
+
* waits for it, and says so.
|
|
1699
|
+
*/
|
|
1700
|
+
/**
|
|
1701
|
+
* Command Code's folder name for a checkout, as measured on 1.88.5: its real
|
|
1702
|
+
* path with every character but letters and digits made `-`, the leading one
|
|
1703
|
+
* dropped (`/private/tmp/a b` -> `private-tmp-a-b`). Leases key on it, so two
|
|
1704
|
+
* folders Command Code would share also share one lease.
|
|
1705
|
+
*/
|
|
1706
|
+
const slugOf = (folder) => realpathSync(folder).replaceAll(/[^a-zA-Z0-9]/gu, "-").replace(/^-+/u, "");
|
|
1707
|
+
const entryFileOf = (env, slug) => path.join(env["HOME"] ?? homedir(), ".commandcode", "projects", slug, "mcp.json");
|
|
1708
|
+
/** Takes the record for `slug`, or returns the live lease that holds it. */
|
|
1709
|
+
const tryTake = (file, lease) => {
|
|
1710
|
+
const draft = `${file}.${lease.id}`;
|
|
1711
|
+
writeFileSync(draft, JSON.stringify(lease), { mode: 384 });
|
|
1712
|
+
try {
|
|
1713
|
+
linkSync(draft, file);
|
|
1714
|
+
return;
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
if (codeOf(error) !== "EEXIST") throw error;
|
|
1717
|
+
const holder = readLease(file);
|
|
1718
|
+
if (holder !== void 0) return holder;
|
|
1719
|
+
dropIfCorrupt(file);
|
|
1720
|
+
return {
|
|
1721
|
+
...lease,
|
|
1722
|
+
pid: 0
|
|
1723
|
+
};
|
|
1724
|
+
} finally {
|
|
1725
|
+
rmSync(draft, { force: true });
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
const masked = (text, endpoint) => maskSecrets(text.replaceAll(endpoint.token, MASK));
|
|
1729
|
+
/**
|
|
1730
|
+
* The lease's options: those given, else the runner's program, environment and
|
|
1731
|
+
* the project's runs folder (the ledger lives there, where `ork doctor`
|
|
1732
|
+
* looks).
|
|
1733
|
+
*/
|
|
1734
|
+
const optionsOf = (given, hooks) => {
|
|
1735
|
+
const ledger = given.ledger ?? (hooks.runs === void 0 ? void 0 : leaseLedger(hooks.runs));
|
|
1736
|
+
if (ledger === void 0) throw new Error("a Command Code session needs the project's runs folder for its lease");
|
|
1737
|
+
const env = given.env ?? hooks.env;
|
|
1738
|
+
return {
|
|
1739
|
+
...given,
|
|
1740
|
+
bin: given.bin ?? hooks.bin ?? "cmd",
|
|
1741
|
+
ledger,
|
|
1742
|
+
...env === void 0 ? {} : { env }
|
|
1743
|
+
};
|
|
1744
|
+
};
|
|
1745
|
+
/**
|
|
1746
|
+
* Ork's endpoint for a Command Code session, through an `ork` entry in the
|
|
1747
|
+
* checkout's local scope, held for the session. A second session in the same
|
|
1748
|
+
* checkout waits for the first, and says so.
|
|
1749
|
+
*/
|
|
1750
|
+
const leaseWiring = (given) => ({
|
|
1751
|
+
kind: "lease",
|
|
1752
|
+
wire: async (spec, endpoint, hooks) => {
|
|
1753
|
+
const options = optionsOf(given, hooks);
|
|
1754
|
+
const env = options.env ?? process.env;
|
|
1755
|
+
const slug = slugOf(spec.cwd);
|
|
1756
|
+
const file = path.join(options.ledger, `${slug}.json`);
|
|
1757
|
+
const lease = {
|
|
1758
|
+
id: randomUUID(),
|
|
1759
|
+
folder: realpathSync(spec.cwd),
|
|
1760
|
+
pid: process.pid,
|
|
1761
|
+
entry: entryFileOf(env, slug)
|
|
1762
|
+
};
|
|
1763
|
+
const fail = (message) => {
|
|
1764
|
+
dropRecord(file, lease);
|
|
1765
|
+
throw new Error(masked(message, endpoint));
|
|
1766
|
+
};
|
|
1767
|
+
mkdirSync(options.ledger, {
|
|
1768
|
+
recursive: true,
|
|
1769
|
+
mode: 448
|
|
1770
|
+
});
|
|
1771
|
+
await cleanStaleLeases(options);
|
|
1772
|
+
let told = false;
|
|
1773
|
+
for (;;) {
|
|
1774
|
+
const holder = tryTake(file, lease);
|
|
1775
|
+
if (holder === void 0) break;
|
|
1776
|
+
if (alive(holder.pid)) {
|
|
1777
|
+
if (!told) {
|
|
1778
|
+
hooks.waiting(`waiting for ${lease.folder}: Command Code runs one session per checkout`);
|
|
1779
|
+
told = true;
|
|
1780
|
+
}
|
|
1781
|
+
await setTimeout$1(options.pollMs ?? 1e3, void 0, { signal: hooks.signal });
|
|
1782
|
+
} else {
|
|
1783
|
+
const { failed } = await cleanStaleLeases(options);
|
|
1784
|
+
if (failed.includes(holder.folder)) fail(`could not remove the \`ork\` MCP entry a stopped session left in ${holder.folder}; run \`ork doctor\``);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const existing = await runCmd(options, [
|
|
1788
|
+
"mcp",
|
|
1789
|
+
"get",
|
|
1790
|
+
"ork"
|
|
1791
|
+
], lease.folder);
|
|
1792
|
+
if (existing.ok && /Scope:\s+local/u.test(existing.out)) fail(`Command Code already has an \`ork\` MCP entry for ${lease.folder}; remove it with \`cmd mcp remove --scope local ork\` there`);
|
|
1793
|
+
const added = await runCmd(options, [
|
|
1794
|
+
"mcp",
|
|
1795
|
+
"add",
|
|
1796
|
+
"--transport",
|
|
1797
|
+
"http",
|
|
1798
|
+
"--scope",
|
|
1799
|
+
"local",
|
|
1800
|
+
"ork",
|
|
1801
|
+
endpoint.url,
|
|
1802
|
+
"--header",
|
|
1803
|
+
`Authorization: Bearer ${endpoint.token}`
|
|
1804
|
+
], lease.folder);
|
|
1805
|
+
if (!added.ok) fail(`cmd mcp add failed: ${added.out}`);
|
|
1806
|
+
return {
|
|
1807
|
+
args: [],
|
|
1808
|
+
env: {},
|
|
1809
|
+
release: async () => {
|
|
1810
|
+
if (await removeEntry(options, lease)) {
|
|
1811
|
+
dropRecord(file, lease);
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const draft = `${file}.${lease.id}`;
|
|
1815
|
+
writeFileSync(draft, JSON.stringify({
|
|
1816
|
+
...lease,
|
|
1817
|
+
pid: 0
|
|
1818
|
+
}), { mode: 384 });
|
|
1819
|
+
renameSync(draft, file);
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
// ../runtime/src/adapters/command-code-events.ts
|
|
1825
|
+
/**
|
|
1826
|
+
* @file The `command-code` adapter's `LegDecoder` and `UsageReader`: maps
|
|
1827
|
+
* Command Code's `--output-format json` lines (`{"type":"event"}` frames,
|
|
1828
|
+
* then one `{"type":"result"}` line; measured on 1.88.5,
|
|
1829
|
+
* specs/architecture.md, "Command Code") to Ork's runtime events. Tool names
|
|
1830
|
+
* are given Ork's (Claude Code's) names, so logs and the node's rules read
|
|
1831
|
+
* the same on every driver. One decoder reads one leg: the session id comes
|
|
1832
|
+
* in an early `run_start` frame and the result line counts no turns, so it
|
|
1833
|
+
* keeps both. The result line is held until the process exits: its exit code
|
|
1834
|
+
* says how the session ended (specs/architecture/command-code.md).
|
|
1835
|
+
*/
|
|
1836
|
+
const isObject$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1837
|
+
const num$1 = (value) => typeof value === "number" ? value : 0;
|
|
1838
|
+
const str$1 = (value) => typeof value === "string" ? value : "";
|
|
1839
|
+
/** Command Code's own tool names, as Ork (and Claude Code) names them. */
|
|
1840
|
+
const COMMAND_CODE_TOOLS = {
|
|
1841
|
+
shell: "Bash",
|
|
1842
|
+
shell_command: "Bash",
|
|
1843
|
+
read_file: "Read",
|
|
1844
|
+
read_multiple_files: "Read",
|
|
1845
|
+
read_directory: "Read",
|
|
1846
|
+
glob: "Glob",
|
|
1847
|
+
grep: "Grep",
|
|
1848
|
+
edit_file: "Edit",
|
|
1849
|
+
write_file: "Write",
|
|
1850
|
+
web_fetch: "WebFetch",
|
|
1851
|
+
web_search: "WebSearch",
|
|
1852
|
+
agent: "Task"
|
|
1853
|
+
};
|
|
1854
|
+
/** A tool's name as Ork shows it; MCP and unknown names stay as they are. */
|
|
1855
|
+
const orkToolName = (name) => COMMAND_CODE_TOOLS[name] ?? name;
|
|
1856
|
+
/** The result line's subtypes, as Ork's outcome names them. */
|
|
1857
|
+
const SUBTYPES = {
|
|
1858
|
+
success: "success",
|
|
1859
|
+
max_turns: "error_max_turns",
|
|
1860
|
+
error: "error_during_execution"
|
|
1861
|
+
};
|
|
1862
|
+
/**
|
|
1863
|
+
* Command Code's exit codes, by the kind they mean; 0 is a session that ended
|
|
1864
|
+
* well and 1 an error its text explains.
|
|
1865
|
+
*/
|
|
1866
|
+
const COMMAND_CODE_EXITS = {
|
|
1867
|
+
3: "auth",
|
|
1868
|
+
4: "auth",
|
|
1869
|
+
5: "rate_limited",
|
|
1870
|
+
6: "provider_unavailable",
|
|
1871
|
+
7: "provider_unavailable",
|
|
1872
|
+
8: "turn_limit",
|
|
1873
|
+
9: "provider_unavailable",
|
|
1874
|
+
10: "out_of_credits",
|
|
1875
|
+
130: "interrupted"
|
|
1876
|
+
};
|
|
1877
|
+
/** What a leg's exit code and result line say went wrong, if anything. */
|
|
1878
|
+
const signalOf = (code, error) => {
|
|
1879
|
+
const kind = code === null ? void 0 : COMMAND_CODE_EXITS[code];
|
|
1880
|
+
if (kind !== void 0) return endFor(kind, {}, error);
|
|
1881
|
+
if ((code === 0 || code === null) && error === "") return null;
|
|
1882
|
+
return errorEnd(error);
|
|
1883
|
+
};
|
|
1884
|
+
/** The text blocks of a `message_end` frame's content. */
|
|
1885
|
+
const textOf = (content) => (Array.isArray(content) ? content : []).filter((item) => isObject$1(item)).filter((block) => block["type"] === "text").map((block) => str$1(block["text"])).join("");
|
|
1886
|
+
/** The frames that end a tool call as an error: failed, denied, or blocked. */
|
|
1887
|
+
const REFUSED = /* @__PURE__ */ new Set([
|
|
1888
|
+
"tool_errored",
|
|
1889
|
+
"tool_denied",
|
|
1890
|
+
"tool_hook_blocked"
|
|
1891
|
+
]);
|
|
1892
|
+
const parseLine$1 = (line) => {
|
|
1893
|
+
try {
|
|
1894
|
+
return JSON.parse(line);
|
|
1895
|
+
} catch {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
/**
|
|
1900
|
+
* A decoder for one leg of a Command Code session; its end comes at the exit,
|
|
1901
|
+
* from the held result line and the exit code.
|
|
1902
|
+
*/
|
|
1903
|
+
const commandCodeDecoder = () => {
|
|
1904
|
+
let sessionId = "";
|
|
1905
|
+
let turns = 0;
|
|
1906
|
+
let held;
|
|
1907
|
+
const frame = (event) => {
|
|
1908
|
+
const type = event["type"];
|
|
1909
|
+
const id = str$1(event["toolCallId"]);
|
|
1910
|
+
const name = orkToolName(str$1(event["toolName"]));
|
|
1911
|
+
if (type === "run_start") {
|
|
1912
|
+
sessionId = str$1(event["sessionId"]);
|
|
1913
|
+
return [{
|
|
1914
|
+
type: "session.started",
|
|
1915
|
+
sessionId,
|
|
1916
|
+
model: ""
|
|
1917
|
+
}];
|
|
1918
|
+
}
|
|
1919
|
+
if (type === "turn_start") turns += 1;
|
|
1920
|
+
if (type === "message_end") {
|
|
1921
|
+
const text = textOf(event["content"]);
|
|
1922
|
+
return text.trim() === "" ? [] : [{
|
|
1923
|
+
type: "assistant.text",
|
|
1924
|
+
text
|
|
1925
|
+
}];
|
|
1926
|
+
}
|
|
1927
|
+
if (type === "tool_queued") {
|
|
1928
|
+
const input = event["input"];
|
|
1929
|
+
return [{
|
|
1930
|
+
type: "tool.called",
|
|
1931
|
+
id,
|
|
1932
|
+
name,
|
|
1933
|
+
input: isObject$1(input) ? input : {}
|
|
1934
|
+
}];
|
|
1935
|
+
}
|
|
1936
|
+
if (type === "tool_completed") return [{
|
|
1937
|
+
type: "tool.result",
|
|
1938
|
+
id,
|
|
1939
|
+
isError: false
|
|
1940
|
+
}];
|
|
1941
|
+
if (typeof type === "string" && REFUSED.has(type)) return [{
|
|
1942
|
+
type: "tool.result",
|
|
1943
|
+
id,
|
|
1944
|
+
isError: true
|
|
1945
|
+
}];
|
|
1946
|
+
return [];
|
|
1947
|
+
};
|
|
1948
|
+
const result = (line, code) => {
|
|
1949
|
+
const usage = isObject$1(line["usage"]) ? line["usage"] : {};
|
|
1950
|
+
const error = str$1(line["error"]);
|
|
1951
|
+
const subtype = str$1(line["subtype"]);
|
|
1952
|
+
const signal = subtype === "max_turns" ? endFor("turn_limit", {}, error) : signalOf(code, error);
|
|
1953
|
+
return {
|
|
1954
|
+
type: "session.finished",
|
|
1955
|
+
outcome: {
|
|
1956
|
+
subtype: SUBTYPES[subtype] ?? "error_during_execution",
|
|
1957
|
+
end: signal ?? endFor("no_report"),
|
|
1958
|
+
sessionId: str$1(line["sessionId"]) || sessionId,
|
|
1959
|
+
report: null,
|
|
1960
|
+
text: str$1(line["finalText"]),
|
|
1961
|
+
usage: {
|
|
1962
|
+
costUsd: 0,
|
|
1963
|
+
inputTokens: num$1(usage["inputTokens"]),
|
|
1964
|
+
outputTokens: num$1(usage["outputTokens"]),
|
|
1965
|
+
cacheReadTokens: num$1(usage["cacheReadTokens"]),
|
|
1966
|
+
cacheWriteTokens: num$1(usage["cacheWriteTokens"]),
|
|
1967
|
+
turns,
|
|
1968
|
+
durationMs: num$1(line["durationMs"])
|
|
1969
|
+
},
|
|
1970
|
+
errors: error === "" ? [] : [error]
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
};
|
|
1974
|
+
return {
|
|
1975
|
+
decode: (line) => {
|
|
1976
|
+
const message = parseLine$1(line);
|
|
1977
|
+
if (!isObject$1(message)) return { events: [] };
|
|
1978
|
+
if (message["type"] === "result") {
|
|
1979
|
+
held = message;
|
|
1980
|
+
return { events: [] };
|
|
1981
|
+
}
|
|
1982
|
+
const event = message["event"];
|
|
1983
|
+
return { events: message["type"] === "event" && isObject$1(event) ? frame(event) : [] };
|
|
1984
|
+
},
|
|
1985
|
+
exit: (code) => {
|
|
1986
|
+
if (held !== void 0) return [result(held, code)];
|
|
1987
|
+
return (code === null ? void 0 : COMMAND_CODE_EXITS[code]) === void 0 ? [] : [result({ error: "" }, code)];
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
};
|
|
1991
|
+
/** Command Code reports tokens, never dollars. */
|
|
1992
|
+
const commandCodeUsage = {
|
|
1993
|
+
dollars: false,
|
|
1994
|
+
combine: (a, b) => addUsage(a, b)
|
|
1995
|
+
};
|
|
1996
|
+
// ../runtime/src/adapters/command-code-mod.ts
|
|
1997
|
+
/**
|
|
1998
|
+
* @file The Command Code mod that holds a session to its node's tool rules
|
|
1999
|
+
* (RFC-014, deny by default). Command Code takes permission rules only from
|
|
2000
|
+
* settings files (the user's, and two in the checkout), so Ork runs it with
|
|
2001
|
+
* `--permission-mode yolo` and loads, with `--mod`, a mod written for the
|
|
2002
|
+
* session outside the checkout: its `beforeToolCall` hook blocks every call
|
|
2003
|
+
* the node did not allow. Ork's own tools (`mcp__ork__*`) always go through.
|
|
2004
|
+
* Command Code's tool loader passes, and reads inside the worktree need no
|
|
2005
|
+
* rule, as with Claude Code; one reaching outside it does. The rules are the
|
|
2006
|
+
* node's, in Claude Code's words (`Bash(git:*)`, `Read`, `mcp__github`); a
|
|
2007
|
+
* specifier Ork does not read never allows a call and always denies one.
|
|
2008
|
+
*/
|
|
2009
|
+
/** The rules of a session: an `acceptEdits` node may edit without a rule. */
|
|
2010
|
+
const modRules = (spec) => ({
|
|
2011
|
+
cwd: spec.cwd,
|
|
2012
|
+
allow: [...spec.tools.allow, ...spec.permissionMode === "acceptEdits" ? ["Edit"] : []],
|
|
2013
|
+
deny: spec.tools.deny,
|
|
2014
|
+
...spec.tools.only === void 0 ? {} : { only: spec.tools.only }
|
|
2015
|
+
});
|
|
2016
|
+
/**
|
|
2017
|
+
* The mod's source. It is plain JavaScript that Command Code loads (a default
|
|
2018
|
+
* export taking the mod API), so it carries its own matcher.
|
|
2019
|
+
*/
|
|
2020
|
+
const modSource = (rules) => `// Ork's rules for one session; written by Ork, removed after it.
|
|
2021
|
+
import { relative, resolve } from "node:path";
|
|
2022
|
+
const RULES = ${JSON.stringify(rules)};
|
|
2023
|
+
// Command Code's own: loading a deferred tool's schema, Ork's report included.
|
|
2024
|
+
const META = new Set(["search_tools", "tool_search"]);
|
|
2025
|
+
// Reads a session makes in its worktree without a rule, as Claude Code does.
|
|
2026
|
+
const READS = new Set(["read_file", "read_multiple_files", "read_directory", "glob", "grep"]);
|
|
2027
|
+
const pathsOf = input => Object.entries(input ?? {})
|
|
2028
|
+
.filter(([key]) => /path|file|dir/i.test(key))
|
|
2029
|
+
.flatMap(([, value]) => (Array.isArray(value) ? value : [value]))
|
|
2030
|
+
.filter(value => typeof value === "string");
|
|
2031
|
+
const inside = input => pathsOf(input).every(each => {
|
|
2032
|
+
const from = relative(RULES.cwd, resolve(RULES.cwd, each));
|
|
2033
|
+
return from === "" || (!from.startsWith("..") && !from.startsWith("/"));
|
|
2034
|
+
});
|
|
2035
|
+
const TOOLS = ${JSON.stringify(COMMAND_CODE_TOOLS)};
|
|
2036
|
+
// A rule's word, as Command Code reads it, covers these of Ork's tool names.
|
|
2037
|
+
const COVERS = { read: ["Read", "Glob", "Grep"], edit: ["Edit", "Write"] };
|
|
2038
|
+
const norm = text => text.trim().replace(/\\s+/g, " ");
|
|
2039
|
+
const split = rule => {
|
|
2040
|
+
const at = rule.indexOf("(");
|
|
2041
|
+
return at < 0 || !rule.endsWith(")")
|
|
2042
|
+
? { token: rule.trim() }
|
|
2043
|
+
: { token: rule.slice(0, at).trim(), spec: rule.slice(at + 1, -1) };
|
|
2044
|
+
};
|
|
2045
|
+
const shellMatches = (command, pattern) => {
|
|
2046
|
+
let p = norm(pattern);
|
|
2047
|
+
const c = norm(command);
|
|
2048
|
+
if (p.endsWith(":*")) p = p.slice(0, -2) + " *";
|
|
2049
|
+
if (!p.endsWith(" *")) return c === p;
|
|
2050
|
+
const head = p.slice(0, -2);
|
|
2051
|
+
return c === head || c.startsWith(head + " ");
|
|
2052
|
+
};
|
|
2053
|
+
// true: the rule covers the call; false: it does not; null: Ork can't tell.
|
|
2054
|
+
const matches = (rule, name, input) => {
|
|
2055
|
+
const { token, spec } = split(rule);
|
|
2056
|
+
if (token.startsWith("mcp__")) {
|
|
2057
|
+
const rest = token.slice(5);
|
|
2058
|
+
const at = rest.indexOf("__");
|
|
2059
|
+
if (at < 0) return name.startsWith("mcp__" + rest + "__");
|
|
2060
|
+
const tool = rest.slice(at + 2);
|
|
2061
|
+
return tool === "*" ? name.startsWith("mcp__" + rest.slice(0, at) + "__") : name === token;
|
|
2062
|
+
}
|
|
2063
|
+
const ork = TOOLS[name] ?? name;
|
|
2064
|
+
const word = token.toLowerCase();
|
|
2065
|
+
const covered = (COVERS[word] ?? [token]).some(each => each.toLowerCase() === ork.toLowerCase());
|
|
2066
|
+
if (!covered && name !== token) return false;
|
|
2067
|
+
if (spec === undefined || spec === "" || spec === "*") return true;
|
|
2068
|
+
if (ork === "Bash" && typeof input?.command === "string") return shellMatches(input.command, spec);
|
|
2069
|
+
return null;
|
|
2070
|
+
};
|
|
2071
|
+
export const decide = (name, input) => {
|
|
2072
|
+
if (RULES.deny.some(rule => matches(rule, name, input) !== false)) return "denied for this node";
|
|
2073
|
+
if (name.startsWith("mcp__ork__") || META.has(name)) return undefined;
|
|
2074
|
+
if (RULES.only !== undefined && !RULES.only.some(rule => matches(rule, name, input) === true)) {
|
|
2075
|
+
return "not one of this node's tools";
|
|
2076
|
+
}
|
|
2077
|
+
if (READS.has(name) && inside(input)) return undefined;
|
|
2078
|
+
if (RULES.allow.some(rule => matches(rule, name, input) === true)) return undefined;
|
|
2079
|
+
return "not allowed for this node";
|
|
2080
|
+
};
|
|
2081
|
+
export default cc => {
|
|
2082
|
+
cc.hooks({
|
|
2083
|
+
beforeToolCall: async call => {
|
|
2084
|
+
const why = decide(call.toolName, call.input);
|
|
2085
|
+
return why === undefined
|
|
2086
|
+
? undefined
|
|
2087
|
+
: { block: true, additionalContext: "Ork: " + call.toolName + " is " + why + ". Do not retry it." };
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
2090
|
+
};
|
|
2091
|
+
`;
|
|
2092
|
+
/**
|
|
2093
|
+
* Writes a session's mod outside the checkout (a fresh folder under the
|
|
2094
|
+
* system's temp folder, 0700, the file 0600); it holds no secret.
|
|
2095
|
+
*/
|
|
2096
|
+
const writeMod = (spec) => {
|
|
2097
|
+
const dir = mkdtempSync(path.join(tmpdir(), "ork-cmd-mod-"));
|
|
2098
|
+
const file = path.join(dir, "ork-rules.mjs");
|
|
2099
|
+
writeFileSync(file, modSource(modRules(spec)), { mode: 384 });
|
|
2100
|
+
return {
|
|
2101
|
+
file,
|
|
2102
|
+
remove: () => {
|
|
2103
|
+
rmSync(dir, {
|
|
2104
|
+
recursive: true,
|
|
2105
|
+
force: true
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
};
|
|
2110
|
+
// ../runtime/src/adapters/command-code.ts
|
|
2111
|
+
/**
|
|
2112
|
+
* The brief, with Ork's instructions before it on a session's first leg:
|
|
2113
|
+
* Command Code has no flag that adds to its system prompt. A resumed leg's
|
|
2114
|
+
* session already holds them.
|
|
2115
|
+
*/
|
|
2116
|
+
const queryOf = (spec) => {
|
|
2117
|
+
const text = spec.resume === void 0 ? `${spec.systemAppend}\n\n${spec.prompt}` : spec.prompt;
|
|
2118
|
+
return text.startsWith("-") ? ` ${text}` : text;
|
|
2119
|
+
};
|
|
2120
|
+
/**
|
|
2121
|
+
* The `cmd` arguments for a session spec. `--dangerously-skip-permissions`: in
|
|
2122
|
+
* `-p` mode only it lifts Command Code's own gate on edits and shell (the
|
|
2123
|
+
* permission mode alone does not), so the session's mod, which refuses what the
|
|
2124
|
+
* node did not allow, is the only gate.
|
|
2125
|
+
*/
|
|
2126
|
+
const commandCodeArgs = (spec) => {
|
|
2127
|
+
const args = [
|
|
2128
|
+
"-p",
|
|
2129
|
+
queryOf(spec),
|
|
2130
|
+
"--output-format",
|
|
2131
|
+
"json",
|
|
2132
|
+
"--permission-mode",
|
|
2133
|
+
"yolo",
|
|
2134
|
+
"--dangerously-skip-permissions",
|
|
2135
|
+
"--skip-onboarding",
|
|
2136
|
+
"--no-auto-update"
|
|
2137
|
+
];
|
|
2138
|
+
if (spec.model !== void 0) args.push("--model", spec.model);
|
|
2139
|
+
if (spec.effort !== void 0) args.push("--effort", spec.effort);
|
|
2140
|
+
if (spec.maxTurns !== void 0) args.push("--max-turns", String(spec.maxTurns));
|
|
2141
|
+
if (spec.resume !== void 0) args.push("--resume", spec.resume);
|
|
2142
|
+
if (spec.resume !== void 0 && spec.fork === true) args.push("--fork-session");
|
|
2143
|
+
return args;
|
|
2144
|
+
};
|
|
2145
|
+
const lease = leaseWiring({});
|
|
2146
|
+
/** `cmd`, as the session runner's strategies. */
|
|
2147
|
+
const commandCodeAdapter = {
|
|
2148
|
+
driver: "command-code",
|
|
2149
|
+
command: {
|
|
2150
|
+
bin: "cmd",
|
|
2151
|
+
args: (spec) => commandCodeArgs(spec)
|
|
2152
|
+
},
|
|
2153
|
+
decode: (line) => commandCodeDecoder().decode(line),
|
|
2154
|
+
legDecoder: () => commandCodeDecoder(),
|
|
2155
|
+
mcp: {
|
|
2156
|
+
kind: "lease",
|
|
2157
|
+
wire: async (spec, endpoint, hooks) => {
|
|
2158
|
+
const wired = await lease.wire(spec, endpoint, hooks);
|
|
2159
|
+
try {
|
|
2160
|
+
const mod = writeMod(spec);
|
|
2161
|
+
return {
|
|
2162
|
+
args: [
|
|
2163
|
+
...wired.args,
|
|
2164
|
+
"--mod",
|
|
2165
|
+
mod.file
|
|
2166
|
+
],
|
|
2167
|
+
env: wired.env,
|
|
2168
|
+
release: async () => {
|
|
2169
|
+
mod.remove();
|
|
2170
|
+
await wired.release();
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
await wired.release();
|
|
2175
|
+
throw error;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
permissions: {
|
|
2180
|
+
kind: "rules",
|
|
2181
|
+
denyByDefault: true
|
|
2182
|
+
},
|
|
2183
|
+
usage: commandCodeUsage,
|
|
2184
|
+
context: {
|
|
2185
|
+
instructions: ["AGENTS.md"],
|
|
2186
|
+
skills: ".commandcode/skills",
|
|
2187
|
+
agents: ".commandcode/agents",
|
|
2188
|
+
env: () => ({})
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
// ../runtime/src/secret-file.ts
|
|
2192
|
+
/**
|
|
2193
|
+
* @file A session's private folder for files that hold a secret, such as a
|
|
2194
|
+
* config carrying Ork's bearer token (RFC-014): a 0700 folder outside the
|
|
2195
|
+
* checkout, under the system's temporary folder, named after the process that
|
|
2196
|
+
* made it, with each file written at mode 0600. The session removes it when
|
|
2197
|
+
* it ends; a folder whose process is gone (a crash) is removed when the next
|
|
2198
|
+
* one is made. Nothing here logs what it writes.
|
|
2199
|
+
*/
|
|
2200
|
+
/** Where sessions' private folders live unless a caller names another place. */
|
|
2201
|
+
const secretRoot = () => path.join(tmpdir(), "ork-sessions");
|
|
2202
|
+
/** Removes the folders under `root` whose process is gone; returns them. */
|
|
2203
|
+
const sweepSecretFolders = (root) => {
|
|
2204
|
+
let names;
|
|
2205
|
+
try {
|
|
2206
|
+
names = readdirSync(root);
|
|
2207
|
+
} catch {
|
|
2208
|
+
return [];
|
|
2209
|
+
}
|
|
2210
|
+
return names.flatMap((name) => {
|
|
2211
|
+
const pid = Number(name.split("-")[0]);
|
|
2212
|
+
if (!Number.isInteger(pid) || alive(pid)) return [];
|
|
2213
|
+
const dir = path.join(root, name);
|
|
2214
|
+
rmSync(dir, {
|
|
2215
|
+
recursive: true,
|
|
2216
|
+
force: true
|
|
2217
|
+
});
|
|
2218
|
+
return [dir];
|
|
2219
|
+
});
|
|
2220
|
+
};
|
|
2221
|
+
/** A new private folder under `root`, after sweeping the stale ones. */
|
|
2222
|
+
const makeSecretFolder = (root = secretRoot()) => {
|
|
2223
|
+
mkdirSync(root, {
|
|
2224
|
+
recursive: true,
|
|
2225
|
+
mode: 448
|
|
2226
|
+
});
|
|
2227
|
+
sweepSecretFolders(root);
|
|
2228
|
+
const dir = mkdtempSync(path.join(root, `${String(process.pid)}-`));
|
|
2229
|
+
return {
|
|
2230
|
+
dir,
|
|
2231
|
+
write: (name, content) => {
|
|
2232
|
+
const file = path.join(dir, name);
|
|
2233
|
+
writeFileSync(file, content, { mode: 384 });
|
|
2234
|
+
return file;
|
|
2235
|
+
},
|
|
2236
|
+
remove: () => {
|
|
2237
|
+
rmSync(dir, {
|
|
2238
|
+
recursive: true,
|
|
2239
|
+
force: true
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
};
|
|
2243
|
+
};
|
|
2244
|
+
/** Claude Code tool names, as OpenCode's permission actions. */
|
|
2245
|
+
const ACTIONS = {
|
|
2246
|
+
Read: "read",
|
|
2247
|
+
Glob: "glob",
|
|
2248
|
+
Grep: "grep",
|
|
2249
|
+
Edit: "edit",
|
|
2250
|
+
Write: "edit",
|
|
2251
|
+
MultiEdit: "edit",
|
|
2252
|
+
NotebookEdit: "edit",
|
|
2253
|
+
Bash: "shell",
|
|
2254
|
+
WebFetch: "webfetch",
|
|
2255
|
+
WebSearch: "websearch",
|
|
2256
|
+
Task: "subagent",
|
|
2257
|
+
Agent: "subagent",
|
|
2258
|
+
Skill: "skill",
|
|
2259
|
+
TodoWrite: "todowrite"
|
|
2260
|
+
};
|
|
2261
|
+
const BUILT_IN = new Set(Object.values(ACTIONS));
|
|
2262
|
+
/**
|
|
2263
|
+
* Actions whose resource is a path. OpenCode matches it relative to the
|
|
2264
|
+
* worktree (an absolute path inside it too), `*` across folders, and asks
|
|
2265
|
+
* `external_directory`, denied first, for any path outside it.
|
|
2266
|
+
*/
|
|
2267
|
+
const PATH_ACTIONS = /* @__PURE__ */ new Set(["read", "edit"]);
|
|
2268
|
+
/** A rule's path as OpenCode matches it: relative to the worktree. */
|
|
2269
|
+
const pathResource = (spec, cwd) => {
|
|
2270
|
+
return (path.isAbsolute(spec) ? path.relative(cwd, spec) : spec).replace(/^\.\//u, "") || "*";
|
|
2271
|
+
};
|
|
2272
|
+
/** OpenCode's name for an MCP server's tool: other characters become `_`. */
|
|
2273
|
+
const mcpAction = (server, tool) => `${server}_${tool}`.replaceAll(/[^A-Za-z0-9_*-]/gu, "_");
|
|
2274
|
+
/** A shell rule's resource: Claude Code's `git status:*` is `git status *`. */
|
|
2275
|
+
const shellResource = (spec) => spec.replace(/:\*$/u, " *");
|
|
2276
|
+
/**
|
|
2277
|
+
* One Claude Code rule (`Bash(git status:*)`, `Edit`, `mcp__github__*`) as an
|
|
2278
|
+
* OpenCode rule, or none for a tool OpenCode has no action for (still denied).
|
|
2279
|
+
*/
|
|
2280
|
+
const ruleOf = (rule, effect, cwd) => {
|
|
2281
|
+
const match = /^([^()]+)(?:\((.*)\))?$/su.exec(rule.trim());
|
|
2282
|
+
if (match === null) return void 0;
|
|
2283
|
+
const [, name = "", spec] = match;
|
|
2284
|
+
const mcp = /^mcp__([^_]+(?:_[^_]+)*)(?:__(.*))?$/u.exec(name);
|
|
2285
|
+
if (mcp !== null) {
|
|
2286
|
+
const [, server = "", tool] = mcp;
|
|
2287
|
+
return {
|
|
2288
|
+
action: mcpAction(server, tool ?? "*"),
|
|
2289
|
+
resource: "*",
|
|
2290
|
+
effect
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
const action = ACTIONS[name];
|
|
2294
|
+
if (action === void 0) return void 0;
|
|
2295
|
+
if (spec !== void 0) return {
|
|
2296
|
+
action,
|
|
2297
|
+
resource: action === "shell" ? shellResource(spec) : PATH_ACTIONS.has(action) ? pathResource(spec, cwd) : spec,
|
|
2298
|
+
effect
|
|
2299
|
+
};
|
|
2300
|
+
return {
|
|
2301
|
+
action,
|
|
2302
|
+
resource: "*",
|
|
2303
|
+
effect
|
|
2304
|
+
};
|
|
2305
|
+
};
|
|
2306
|
+
const rulesOf = (rules, effect, cwd) => rules.flatMap((rule) => ruleOf(rule, effect, cwd) ?? []);
|
|
2307
|
+
/** An action allowed anywhere in the worktree; outside it is denied. */
|
|
2308
|
+
const worktree = (action) => ({
|
|
2309
|
+
action,
|
|
2310
|
+
resource: "*",
|
|
2311
|
+
effect: "allow"
|
|
2312
|
+
});
|
|
2313
|
+
/**
|
|
2314
|
+
* The session's rules, in order: deny everything; read in the worktree (as
|
|
2315
|
+
* Claude Code reads without asking); edits there under `acceptEdits`; the
|
|
2316
|
+
* node's allows (only its `only` tools, if it names them); Ork's tools; then
|
|
2317
|
+
* `.env` reads, plan mode's edits and shell, and the node's denies, refused.
|
|
2318
|
+
*/
|
|
2319
|
+
const permissionRules = (spec) => {
|
|
2320
|
+
const { cwd, tools } = spec;
|
|
2321
|
+
const only = tools.only === void 0 ? void 0 : new Set(rulesOf(tools.only, "allow", cwd).map((r) => r.action));
|
|
2322
|
+
const inOnly = (rule) => only === void 0 || only.has(rule.action) || !BUILT_IN.has(rule.action);
|
|
2323
|
+
return [
|
|
2324
|
+
{
|
|
2325
|
+
action: "*",
|
|
2326
|
+
resource: "*",
|
|
2327
|
+
effect: "deny"
|
|
2328
|
+
},
|
|
2329
|
+
...[
|
|
2330
|
+
worktree("read"),
|
|
2331
|
+
...spec.permissionMode === "acceptEdits" ? [worktree("edit")] : [],
|
|
2332
|
+
...rulesOf(tools.allow, "allow", cwd)
|
|
2333
|
+
].filter((rule) => inOnly(rule)),
|
|
2334
|
+
{
|
|
2335
|
+
action: `ork_*`,
|
|
2336
|
+
resource: "*",
|
|
2337
|
+
effect: "allow"
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
action: "read",
|
|
2341
|
+
resource: "*.env",
|
|
2342
|
+
effect: "deny"
|
|
2343
|
+
},
|
|
2344
|
+
...spec.permissionMode === "plan" ? ["edit", "shell"].map((action) => ({
|
|
2345
|
+
action,
|
|
2346
|
+
resource: "*",
|
|
2347
|
+
effect: "deny"
|
|
2348
|
+
})) : [],
|
|
2349
|
+
...rulesOf(tools.deny, "deny", cwd)
|
|
2350
|
+
];
|
|
2351
|
+
};
|
|
2352
|
+
/**
|
|
2353
|
+
* A `.mcp.json` entry as an OpenCode server: `local` with a command, else
|
|
2354
|
+
* `remote`.
|
|
2355
|
+
*/
|
|
2356
|
+
const serverOf = (entry) => {
|
|
2357
|
+
const { command, args, env, url, headers } = entry;
|
|
2358
|
+
const listed = Array.isArray(args) ? args.map(String) : [];
|
|
2359
|
+
return typeof command === "string" ? {
|
|
2360
|
+
type: "local",
|
|
2361
|
+
command: [command, ...listed],
|
|
2362
|
+
...env === void 0 ? {} : { environment: env },
|
|
2363
|
+
codemode: false
|
|
2364
|
+
} : {
|
|
2365
|
+
type: "remote",
|
|
2366
|
+
url,
|
|
2367
|
+
oauth: false,
|
|
2368
|
+
codemode: false,
|
|
2369
|
+
headers
|
|
2370
|
+
};
|
|
2371
|
+
};
|
|
2372
|
+
/** The whole config file for one session. */
|
|
2373
|
+
const sessionConfig = (spec, endpoint, instructions) => ({
|
|
2374
|
+
instructions: [instructions],
|
|
2375
|
+
mcp: { servers: {
|
|
2376
|
+
...Object.fromEntries(Object.entries(spec.mcpServers ?? {}).map(([name, entry]) => [name, serverOf(entry)])),
|
|
2377
|
+
["ork"]: {
|
|
2378
|
+
type: "remote",
|
|
2379
|
+
url: endpoint.url,
|
|
2380
|
+
oauth: false,
|
|
2381
|
+
codemode: false,
|
|
2382
|
+
headers: { Authorization: `Bearer ${endpoint.token}` }
|
|
2383
|
+
}
|
|
2384
|
+
} },
|
|
2385
|
+
permissions: permissionRules(spec),
|
|
2386
|
+
agents: { ["ork"]: {
|
|
2387
|
+
mode: "primary",
|
|
2388
|
+
...spec.maxTurns === void 0 ? {} : { steps: spec.maxTurns }
|
|
2389
|
+
} }
|
|
2390
|
+
});
|
|
2391
|
+
// ../runtime/src/adapters/open-code-events.ts
|
|
2392
|
+
/**
|
|
2393
|
+
* @file The `open-code` adapter's decoder and usage reader. `opencode run
|
|
2394
|
+
* --format json` prints one JSON object per line, `{type, timestamp,
|
|
2395
|
+
* sessionID, part}` (2.1.0, read from its source): `step_start`, `text`,
|
|
2396
|
+
* `tool_use` (a finished call, `state.status` `completed` or `error`),
|
|
2397
|
+
* `step_finish` (`cost` in dollars, `tokens`) and `error`. There is no result
|
|
2398
|
+
* line: the last `text` is the final text and the process's exit ends the
|
|
2399
|
+
* session. Tool names come back in Claude Code's words (`shell` is `Bash`,
|
|
2400
|
+
* `ork_report` is `mcp__ork__report`), the words a node's rules use.
|
|
2401
|
+
*/
|
|
2402
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2403
|
+
const get = (value, key) => isObject(value) ? value[key] : void 0;
|
|
2404
|
+
const num = (value) => typeof value === "number" ? value : 0;
|
|
2405
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
2406
|
+
/** OpenCode's built-in tools, by Claude Code's names. */
|
|
2407
|
+
const TOOL_NAMES = {
|
|
2408
|
+
shell: "Bash",
|
|
2409
|
+
read: "Read",
|
|
2410
|
+
edit: "Edit",
|
|
2411
|
+
write: "Write",
|
|
2412
|
+
patch: "Edit",
|
|
2413
|
+
glob: "Glob",
|
|
2414
|
+
grep: "Grep",
|
|
2415
|
+
webfetch: "WebFetch",
|
|
2416
|
+
websearch: "WebSearch",
|
|
2417
|
+
subagent: "Task",
|
|
2418
|
+
skill: "Skill",
|
|
2419
|
+
todowrite: "TodoWrite"
|
|
2420
|
+
};
|
|
2421
|
+
const ORK_PREFIX = `ork_`;
|
|
2422
|
+
/** A tool's name as a node's rules and Ork's views say it. */
|
|
2423
|
+
const toolName = (name) => TOOL_NAMES[name] ?? (name.startsWith(ORK_PREFIX) ? toolId(name.slice(ORK_PREFIX.length)) : name);
|
|
2424
|
+
const parseLine = (line) => {
|
|
2425
|
+
try {
|
|
2426
|
+
return JSON.parse(line);
|
|
2427
|
+
} catch {
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
};
|
|
2431
|
+
/** One `step_finish`'s spending, as a whole session's of one turn. */
|
|
2432
|
+
const stepUsage = (part) => {
|
|
2433
|
+
const tokens = get(part, "tokens");
|
|
2434
|
+
const cache = get(tokens, "cache");
|
|
2435
|
+
return {
|
|
2436
|
+
costUsd: num(get(part, "cost")),
|
|
2437
|
+
inputTokens: num(get(tokens, "input")),
|
|
2438
|
+
outputTokens: num(get(tokens, "output")) + num(get(tokens, "reasoning")),
|
|
2439
|
+
cacheReadTokens: num(get(cache, "read")),
|
|
2440
|
+
cacheWriteTokens: num(get(cache, "write")),
|
|
2441
|
+
turns: 1,
|
|
2442
|
+
durationMs: 0
|
|
2443
|
+
};
|
|
2444
|
+
};
|
|
2445
|
+
/** Two spans of one session, as one: OpenCode reports each step's own cost. */
|
|
2446
|
+
const sumUsage = (a, b) => ({
|
|
2447
|
+
costUsd: a.costUsd + b.costUsd,
|
|
2448
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
2449
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
2450
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
2451
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
2452
|
+
turns: a.turns + b.turns,
|
|
2453
|
+
durationMs: a.durationMs + b.durationMs
|
|
2454
|
+
});
|
|
2455
|
+
const openCodeUsage = {
|
|
2456
|
+
dollars: true,
|
|
2457
|
+
combine: sumUsage
|
|
2458
|
+
};
|
|
2459
|
+
const NONE = {
|
|
2460
|
+
costUsd: 0,
|
|
2461
|
+
inputTokens: 0,
|
|
2462
|
+
outputTokens: 0,
|
|
2463
|
+
cacheReadTokens: 0,
|
|
2464
|
+
cacheWriteTokens: 0,
|
|
2465
|
+
turns: 0,
|
|
2466
|
+
durationMs: 0
|
|
2467
|
+
};
|
|
2468
|
+
/** One finished tool call: its call and its result. */
|
|
2469
|
+
const toolEvents = (part) => {
|
|
2470
|
+
const id = str(get(part, "id")) || str(get(part, "partID"));
|
|
2471
|
+
const state = get(part, "state");
|
|
2472
|
+
const input = get(state, "input");
|
|
2473
|
+
return [{
|
|
2474
|
+
type: "tool.called",
|
|
2475
|
+
id,
|
|
2476
|
+
name: toolName(str(get(part, "tool"))),
|
|
2477
|
+
input: isObject(input) ? input : {}
|
|
2478
|
+
}, {
|
|
2479
|
+
type: "tool.result",
|
|
2480
|
+
id,
|
|
2481
|
+
isError: get(state, "status") === "error"
|
|
2482
|
+
}];
|
|
2483
|
+
};
|
|
2484
|
+
/** What one line says by itself: text and finished tool calls. */
|
|
2485
|
+
const decodeOpenCodeLine = (line) => {
|
|
2486
|
+
const message = parseLine(line);
|
|
2487
|
+
const part = get(message, "part");
|
|
2488
|
+
switch (get(message, "type")) {
|
|
2489
|
+
case "text": {
|
|
2490
|
+
const said = str(get(part, "text"));
|
|
2491
|
+
return { events: said.trim() === "" ? [] : [{
|
|
2492
|
+
type: "assistant.text",
|
|
2493
|
+
text: said
|
|
2494
|
+
}] };
|
|
2495
|
+
}
|
|
2496
|
+
case "tool_use": return { events: toolEvents(part) };
|
|
2497
|
+
default: return { events: [] };
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
/** An `error` line's words, for the session's errors. */
|
|
2501
|
+
const errorText = (error) => str(get(get(error, "data"), "message")) || str(get(error, "message")) || str(get(error, "name")) || "error";
|
|
2502
|
+
/** An `error` line's end: its name, status and body, through the classifier. */
|
|
2503
|
+
const errorLineEnd = (error) => {
|
|
2504
|
+
const data = get(error, "data");
|
|
2505
|
+
const status = get(data, "statusCode");
|
|
2506
|
+
const said = [
|
|
2507
|
+
str(get(error, "name")),
|
|
2508
|
+
errorText(error),
|
|
2509
|
+
str(get(data, "responseBody"))
|
|
2510
|
+
].filter((part) => part !== "").join("\n");
|
|
2511
|
+
return errorEnd(said, "unknown", typeof status === "number" ? status : void 0);
|
|
2512
|
+
};
|
|
2513
|
+
/** The exit code OpenCode gives an interrupted session. */
|
|
2514
|
+
const INTERRUPTED = 130;
|
|
2515
|
+
/**
|
|
2516
|
+
* A decoder for one leg of a session: it starts the session at the first line
|
|
2517
|
+
* that names it, and keeps the last text, the steps' usage and the errors for
|
|
2518
|
+
* the end. OpenCode prints nothing at its step cap (`steps`, the node's
|
|
2519
|
+
* `maxTurns`): a session that used every step and handed in no report reached
|
|
2520
|
+
* its turn limit, which the runner settles once it knows the report.
|
|
2521
|
+
*/
|
|
2522
|
+
const openCodeDecoder = (spec) => {
|
|
2523
|
+
const began = Date.now();
|
|
2524
|
+
let sessionId = "";
|
|
2525
|
+
let text = "";
|
|
2526
|
+
let usage = NONE;
|
|
2527
|
+
const errors = [];
|
|
2528
|
+
let failed = null;
|
|
2529
|
+
return {
|
|
2530
|
+
decode: (line) => {
|
|
2531
|
+
const message = parseLine(line);
|
|
2532
|
+
if (!isObject(message)) return { events: [] };
|
|
2533
|
+
const events = [];
|
|
2534
|
+
if (sessionId === "" && str(message["sessionID"]) !== "") {
|
|
2535
|
+
sessionId = str(message["sessionID"]);
|
|
2536
|
+
events.push({
|
|
2537
|
+
type: "session.started",
|
|
2538
|
+
sessionId,
|
|
2539
|
+
model: spec.model ?? "default",
|
|
2540
|
+
cwd: spec.cwd
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
const said = decodeOpenCodeLine(line).events;
|
|
2544
|
+
events.push(...said);
|
|
2545
|
+
const last = said.findLast((event) => event.type === "assistant.text");
|
|
2546
|
+
if (last !== void 0) text = last.text;
|
|
2547
|
+
switch (message["type"]) {
|
|
2548
|
+
case "step_finish":
|
|
2549
|
+
usage = sumUsage(usage, stepUsage(message["part"]));
|
|
2550
|
+
break;
|
|
2551
|
+
case "error":
|
|
2552
|
+
errors.push(errorText(message["error"]));
|
|
2553
|
+
failed ??= errorLineEnd(message["error"]);
|
|
2554
|
+
}
|
|
2555
|
+
return { events };
|
|
2556
|
+
},
|
|
2557
|
+
exit: (code) => {
|
|
2558
|
+
const interrupted = code === INTERRUPTED;
|
|
2559
|
+
if (code !== 0 && errors.length === 0 && !interrupted) return [];
|
|
2560
|
+
const capped = spec.maxTurns !== void 0 && usage.turns >= spec.maxTurns;
|
|
2561
|
+
const end = interrupted ? endFor("interrupted", {}, errors.join("\n")) : failed ?? endFor(capped ? "turn_limit" : "no_report");
|
|
2562
|
+
return [{
|
|
2563
|
+
type: "session.finished",
|
|
2564
|
+
outcome: {
|
|
2565
|
+
subtype: code === 0 ? "success" : "error_during_execution",
|
|
2566
|
+
end,
|
|
2567
|
+
sessionId,
|
|
2568
|
+
report: null,
|
|
2569
|
+
text,
|
|
2570
|
+
usage: {
|
|
2571
|
+
...usage,
|
|
2572
|
+
durationMs: Date.now() - began
|
|
2573
|
+
},
|
|
2574
|
+
errors
|
|
2575
|
+
}
|
|
2576
|
+
}];
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
};
|
|
2580
|
+
// ../runtime/src/adapters/open-code.ts
|
|
2581
|
+
/**
|
|
2582
|
+
* @file The `open-code` adapter: the user's own `opencode run` (2.1.0), on a
|
|
2583
|
+
* private server (`--standalone`; the background service would ignore the
|
|
2584
|
+
* session's environment), started in the node's worktree, which pins its
|
|
2585
|
+
* working folder. Ork's endpoint, the permission rules and the `ork` agent go
|
|
2586
|
+
* in a config file named by `OPENCODE_CONFIG` (the `config-file` wiring),
|
|
2587
|
+
* written at 0600 in a private folder outside the checkout and removed after
|
|
2588
|
+
* the session (`secret-file.ts`). The machine's and the project's OpenCode
|
|
2589
|
+
* config stay out: `XDG_CONFIG_HOME` is an empty folder beside the file, and
|
|
2590
|
+
* the project's config is switched off. The brief and each later message are
|
|
2591
|
+
* the message argument of a leg; a message waits for the next leg, which
|
|
2592
|
+
* continues the session. Never `--auto`: it approves what no rule denies.
|
|
2593
|
+
*/
|
|
2594
|
+
/** The `opencode run` arguments for one leg; the message is the last one. */
|
|
2595
|
+
const openCodeArgs = (spec) => [
|
|
2596
|
+
"run",
|
|
2597
|
+
"--standalone",
|
|
2598
|
+
"--format",
|
|
2599
|
+
"json",
|
|
2600
|
+
"--agent",
|
|
2601
|
+
"ork",
|
|
2602
|
+
...spec.model === void 0 ? [] : ["--model", spec.model],
|
|
2603
|
+
...spec.resume === void 0 ? [] : ["--session", spec.resume],
|
|
2604
|
+
...spec.resume !== void 0 && spec.fork === true ? ["--fork"] : [],
|
|
2605
|
+
spec.prompt.startsWith("-") ? ` ${spec.prompt}` : spec.prompt
|
|
2606
|
+
];
|
|
2607
|
+
/**
|
|
2608
|
+
* What keeps the machine's and the project's own OpenCode setup out, and
|
|
2609
|
+
* OpenCode's reading of `.claude/` and `CLAUDE.md` (RFC-014 open question 3).
|
|
2610
|
+
*/
|
|
2611
|
+
const OPEN_CODE_ENV = {
|
|
2612
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
2613
|
+
OPENCODE_DISABLE_CLAUDE_CODE: "1",
|
|
2614
|
+
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
|
2615
|
+
OPENCODE_CONFIG_DIR: "",
|
|
2616
|
+
OPENCODE_CONFIG_CONTENT: ""
|
|
2617
|
+
};
|
|
2618
|
+
/** The config file, Ork's instructions and an empty config home, per session. */
|
|
2619
|
+
const configFile = (root) => ({
|
|
2620
|
+
kind: "config-file",
|
|
2621
|
+
wire: (spec, endpoint) => {
|
|
2622
|
+
const folder = makeSecretFolder(root);
|
|
2623
|
+
try {
|
|
2624
|
+
const instructions = folder.write("ork.md", spec.systemAppend);
|
|
2625
|
+
const config = folder.write("opencode.json", JSON.stringify(sessionConfig(spec, endpoint, instructions)));
|
|
2626
|
+
const home = path.join(folder.dir, "config");
|
|
2627
|
+
mkdirSync(home, { mode: 448 });
|
|
2628
|
+
return Promise.resolve({
|
|
2629
|
+
args: [],
|
|
2630
|
+
env: {
|
|
2631
|
+
OPENCODE_CONFIG: config,
|
|
2632
|
+
XDG_CONFIG_HOME: home
|
|
2633
|
+
},
|
|
2634
|
+
release: () => {
|
|
2635
|
+
folder.remove();
|
|
2636
|
+
return Promise.resolve();
|
|
2637
|
+
}
|
|
2638
|
+
});
|
|
2639
|
+
} catch (error) {
|
|
2640
|
+
folder.remove();
|
|
2641
|
+
throw error;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
/** `opencode`, as the session runner's strategies. */
|
|
2646
|
+
const createOpenCodeAdapter = (options = {}) => ({
|
|
2647
|
+
driver: "open-code",
|
|
2648
|
+
command: {
|
|
2649
|
+
bin: "opencode",
|
|
2650
|
+
args: openCodeArgs
|
|
2651
|
+
},
|
|
2652
|
+
decode: decodeOpenCodeLine,
|
|
2653
|
+
legDecoder: openCodeDecoder,
|
|
2654
|
+
mcp: configFile(options.secretRoot ?? secretRoot()),
|
|
2655
|
+
permissions: {
|
|
2656
|
+
kind: "rules",
|
|
2657
|
+
denyByDefault: true
|
|
2658
|
+
},
|
|
2659
|
+
usage: openCodeUsage,
|
|
2660
|
+
context: {
|
|
2661
|
+
instructions: ["AGENTS.md"],
|
|
2662
|
+
skills: ".opencode/skills",
|
|
2663
|
+
agents: ".opencode/agents",
|
|
2664
|
+
env: () => OPEN_CODE_ENV
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
const openCodeAdapter = createOpenCodeAdapter();
|
|
2668
|
+
// ../runtime/src/registry.ts
|
|
2669
|
+
/** The message a session on a driver with no adapter yet ends with. */
|
|
2670
|
+
const notBuiltYet = (driver) => `The ${driver} driver is not built yet; use claude-code.`;
|
|
2671
|
+
const notBuilt = (driver) => ({
|
|
2672
|
+
driver,
|
|
2673
|
+
start: () => ({
|
|
2674
|
+
events: (async function* () {
|
|
2675
|
+
yield driverFailure(notBuiltYet(driver), "model_unavailable", { facts: {} });
|
|
2676
|
+
})(),
|
|
2677
|
+
send: () => {},
|
|
2678
|
+
interrupt: () => Promise.resolve()
|
|
2679
|
+
})
|
|
2680
|
+
});
|
|
2681
|
+
const ADAPTERS = {
|
|
2682
|
+
"claude-code": claudeCodeAdapter,
|
|
2683
|
+
"open-code": openCodeAdapter,
|
|
2684
|
+
"command-code": commandCodeAdapter
|
|
2685
|
+
};
|
|
2686
|
+
/**
|
|
2687
|
+
* The runtime for a driver id, with the user's environment and the project's
|
|
2688
|
+
* runs folder (where a Command Code lease is recorded).
|
|
2689
|
+
*/
|
|
2690
|
+
const createRuntime = (driver, env, runs) => {
|
|
2691
|
+
const adapter = ADAPTERS[driver];
|
|
2692
|
+
return adapter === null ? notBuilt(driver) : createSessionRuntime(adapter, {
|
|
2693
|
+
env,
|
|
2694
|
+
...runs === void 0 ? {} : { runs }
|
|
2695
|
+
});
|
|
2696
|
+
};
|
|
2697
|
+
export { cleanStaleLeases, createRuntime, leaseLedger };
|