@krapht/ork 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +5 -6
- package/dist/{__build_info__-DdTVd2p5.mjs → __build_info__-DFF18K8y.mjs} +72 -3
- package/dist/bin.mjs +603 -384
- package/dist/{clean-NnboOuz6.mjs → clean-DVaEH9e7.mjs} +5 -2
- package/dist/{completion-snnYGjgK.mjs → completion-Dv5kf598.mjs} +6 -6
- package/dist/context-CEaKOVmG.mjs +683 -0
- package/dist/{decide-B81wCZQg.mjs → decide-3l9q8UtX.mjs} +3 -3
- package/dist/{find-BKW-uGey.mjs → find-DEjjOlTG.mjs} +1 -1
- package/dist/format-Cb5_m-Qd.mjs +191 -0
- package/dist/{in-a-run-BPXX09Bs.mjs → in-a-run-BS9Nc-JW.mjs} +1 -1
- package/dist/{limits-JCve2QpI.mjs → limits-BSwEjQST.mjs} +1 -1
- package/dist/{list-C7UEgmtd.mjs → list-Qxr6rwsY.mjs} +11 -4
- package/dist/{mcp-CKPyNaKD.mjs → mcp-CslnG5wf.mjs} +6 -6
- package/dist/output-Bqljtmp_.mjs +40 -0
- package/dist/{pause-B_SL_yDz.mjs → pause-D7OVB5Ac.mjs} +4 -4
- package/dist/{relay-o-Luee1c.mjs → relay-EceRIebO.mjs} +2 -2
- package/dist/{remove-DAZ7ZCzt.mjs → remove-0IQZVWmI.mjs} +14 -7
- package/dist/{send-3B64fBNB.mjs → send-C0uwfUYi.mjs} +3 -3
- package/dist/{src-DHvWU4tX.mjs → src-Df6KOIDC.mjs} +5682 -3248
- package/dist/{src-C38BPzit.mjs → src-cmmXKHn1.mjs} +266 -203
- package/dist/{src-BJTegEzY.mjs → src-i5k2Y-kf.mjs} +40 -88
- package/dist/src-oaJYIddp.mjs +2255 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +594 -0
- package/dist/ui/assets/app-C8uhHVYp.css +1 -0
- package/dist/ui/assets/app-qs8Lofv6.js +75 -0
- package/dist/ui/assets/{index-D83F2OKZ.js → index-DV1ZaOk-.js} +3 -3
- package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{ui-CY4xxJUu.mjs → ui-CBkHJdqk.mjs} +4 -4
- package/dist/{usage-CJnytoe4.mjs → usage-Cd8pEYyK.mjs} +5 -5
- package/dist/{watch-BJB82ZJ7.mjs → watch-DW5R2OPO.mjs} +6 -6
- package/dist/{workflows-CiM7S3l0.mjs → workflows-BT1gDRIV.mjs} +4 -3
- package/dist/{worktrees-86ySXxR6.mjs → worktrees-g1MQMGie.mjs} +78 -19
- package/package.json +2 -4
- package/dist/context-DO3sBy9v.mjs +0 -274
- package/dist/format-CmTlTOjT.mjs +0 -427
- package/dist/output-B1V6ZJSx.mjs +0 -950
- package/dist/src-Dyz_Gy1q.mjs +0 -1183
- package/dist/ui/assets/app-C8nJ6NpT.js +0 -67
- package/dist/ui/assets/app-CH7YC3in.css +0 -1
- package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
|
@@ -0,0 +1,2255 @@
|
|
|
1
|
+
import { Ht as FEEDBACK_KINDS, Ut as FEEDBACK_SEVERITIES, Wt as FEEDBACK_TARGETS, cn as MASK, dt as parseReport, mr as AGENT_DRIVERS, un as maskSecrets } from "./src-Df6KOIDC.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
|
+
/** The name Claude Code gives an Ork tool: `mcp__ork__<name>`. */
|
|
30
|
+
const toolId = (name) => `mcp__ork__${name}`;
|
|
31
|
+
const text = (value) => ({ content: [{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: value
|
|
34
|
+
}] });
|
|
35
|
+
const issueAt = (issue) => {
|
|
36
|
+
const where = z.core.toDotPath(issue.path);
|
|
37
|
+
return where === "" ? "" : `${where}: `;
|
|
38
|
+
};
|
|
39
|
+
/** Whether `issue` is about a field of the object `parent` is about. */
|
|
40
|
+
const isFieldOf = (issue, parent) => issue.path.length === parent.path.length + 1 && parent.path.every((key, index) => key === issue.path[index]);
|
|
41
|
+
const expectedField = (issue) => `; the field is \`${String(issue.path.at(-1))}\`${issue.code === "invalid_value" ? `, one of: ${issue.values.join(", ")}` : ""}`;
|
|
42
|
+
/**
|
|
43
|
+
* A failed parse as text a model can act on. A stray key is reported with the
|
|
44
|
+
* fields its object lacks, read from the schema's own issues: a wrong name is
|
|
45
|
+
* otherwise only a "missing" error that hides what was sent.
|
|
46
|
+
*/
|
|
47
|
+
const readableIssues = (issues) => {
|
|
48
|
+
const strays = issues.filter((issue) => issue.code === "unrecognized_keys");
|
|
49
|
+
const lacking = issues.filter((issue) => issue.input === void 0 && strays.some((stray) => isFieldOf(issue, stray)));
|
|
50
|
+
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");
|
|
51
|
+
};
|
|
52
|
+
const defineTool = (definition) => ({
|
|
53
|
+
name: definition.name,
|
|
54
|
+
bind: (context) => {
|
|
55
|
+
if (!(definition.available?.(context) ?? true)) return null;
|
|
56
|
+
const shape = typeof definition.shape === "function" ? definition.shape(context) : definition.shape;
|
|
57
|
+
const schema = z.object(shape);
|
|
58
|
+
return {
|
|
59
|
+
name: definition.name,
|
|
60
|
+
description: typeof definition.description === "function" ? definition.description(context) : definition.description,
|
|
61
|
+
shape,
|
|
62
|
+
schema,
|
|
63
|
+
preapproved: definition.preapproved ?? true,
|
|
64
|
+
call: (args) => {
|
|
65
|
+
const parsed = schema.safeParse(args, { reportInput: true });
|
|
66
|
+
return parsed.success ? definition.run(context, parsed.data) : Promise.resolve({
|
|
67
|
+
...text(readableIssues(parsed.error.issues)),
|
|
68
|
+
isError: true
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// ../runtime/src/mcp-http.ts
|
|
75
|
+
/**
|
|
76
|
+
* @file A minimal MCP server over the streamable HTTP transport, for the
|
|
77
|
+
* `claude` CLI driver. It answers with plain JSON (no SSE stream), which the
|
|
78
|
+
* transport allows, and implements only what Ork needs: `initialize`, `ping`,
|
|
79
|
+
* `tools/list` and `tools/call`. Beside it, `/hold` answers the session's
|
|
80
|
+
* `PreToolUse` HTTP hook, which waits while the node is paused (ADR-041).
|
|
81
|
+
*/
|
|
82
|
+
const PATH = "/mcp";
|
|
83
|
+
const HOLD_PATH = "/hold";
|
|
84
|
+
const MAX_BODY = 1048576;
|
|
85
|
+
const MCP_PROTOCOL_FALLBACK = "2025-06-18";
|
|
86
|
+
const JsonRpcSchema = z.object({
|
|
87
|
+
jsonrpc: z.literal("2.0"),
|
|
88
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
89
|
+
method: z.string(),
|
|
90
|
+
params: z.record(z.string(), z.unknown()).optional()
|
|
91
|
+
});
|
|
92
|
+
/** Handles one JSON-RPC request; `null` for a notification (no reply). */
|
|
93
|
+
const handleRpc = async (tools, message) => {
|
|
94
|
+
const parsed = JsonRpcSchema.safeParse(message);
|
|
95
|
+
if (!parsed.success) return { error: {
|
|
96
|
+
code: -32600,
|
|
97
|
+
message: "invalid request"
|
|
98
|
+
} };
|
|
99
|
+
const { id, method, params } = parsed.data;
|
|
100
|
+
if (id === void 0) return null;
|
|
101
|
+
switch (method) {
|
|
102
|
+
case "initialize": {
|
|
103
|
+
const requested = params?.["protocolVersion"];
|
|
104
|
+
return { result: {
|
|
105
|
+
protocolVersion: typeof requested === "string" ? requested : MCP_PROTOCOL_FALLBACK,
|
|
106
|
+
capabilities: { tools: {} },
|
|
107
|
+
serverInfo: {
|
|
108
|
+
name: "ork",
|
|
109
|
+
version: "0.0.0"
|
|
110
|
+
}
|
|
111
|
+
} };
|
|
112
|
+
}
|
|
113
|
+
case "ping": return { result: {} };
|
|
114
|
+
case "tools/list": return { result: { tools: tools.map((t) => ({
|
|
115
|
+
name: t.name,
|
|
116
|
+
description: t.description,
|
|
117
|
+
inputSchema: z.toJSONSchema(t.schema)
|
|
118
|
+
})) } };
|
|
119
|
+
case "tools/call": {
|
|
120
|
+
const tool = tools.find((t) => t.name === params?.["name"]);
|
|
121
|
+
if (tool === void 0) return { error: {
|
|
122
|
+
code: -32602,
|
|
123
|
+
message: "unknown tool"
|
|
124
|
+
} };
|
|
125
|
+
try {
|
|
126
|
+
return { result: await tool.call(params?.["arguments"] ?? {}) };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { result: {
|
|
129
|
+
content: [{
|
|
130
|
+
type: "text",
|
|
131
|
+
text: error instanceof Error ? error.message : "tool failed"
|
|
132
|
+
}],
|
|
133
|
+
isError: true
|
|
134
|
+
} };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
default: return { error: {
|
|
138
|
+
code: -32601,
|
|
139
|
+
message: `unknown method ${method}`
|
|
140
|
+
} };
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const authorized = (req, token) => {
|
|
144
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
145
|
+
const given = Buffer.from(req.headers.authorization ?? "");
|
|
146
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
147
|
+
};
|
|
148
|
+
const readBody = async (req) => {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
let size = 0;
|
|
151
|
+
for await (const chunk of req) {
|
|
152
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
153
|
+
size += buffer.length;
|
|
154
|
+
if (size > MAX_BODY) return null;
|
|
155
|
+
chunks.push(buffer);
|
|
156
|
+
}
|
|
157
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
158
|
+
};
|
|
159
|
+
const respond = (res, status, body) => {
|
|
160
|
+
if (body === void 0) {
|
|
161
|
+
res.writeHead(status).end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
165
|
+
};
|
|
166
|
+
/** The hook's POST: waits for `hold`; a closed connection gives up. */
|
|
167
|
+
const serveHold = async (req, res, hold) => {
|
|
168
|
+
if (req.method !== "POST") {
|
|
169
|
+
respond(res, 405);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (await readBody(req) === null) {
|
|
173
|
+
respond(res, 413);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const gone = new AbortController();
|
|
177
|
+
res.once("close", () => {
|
|
178
|
+
gone.abort();
|
|
179
|
+
});
|
|
180
|
+
const output = await hold(gone.signal);
|
|
181
|
+
if (!gone.signal.aborted) respond(res, 200, output);
|
|
182
|
+
};
|
|
183
|
+
const serve = async (req, res, tools, token, hold) => {
|
|
184
|
+
if (!authorized(req, token)) {
|
|
185
|
+
respond(res, 401);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.url === HOLD_PATH && hold !== void 0) {
|
|
189
|
+
await serveHold(req, res, hold);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (req.url !== PATH) {
|
|
193
|
+
respond(res, 404);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (req.method !== "POST") {
|
|
197
|
+
respond(res, 405);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const body = await readBody(req);
|
|
201
|
+
if (body === null) {
|
|
202
|
+
respond(res, 413);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let message;
|
|
206
|
+
try {
|
|
207
|
+
message = JSON.parse(body);
|
|
208
|
+
} catch {
|
|
209
|
+
respond(res, 400);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const id = message !== null && typeof message === "object" && "id" in message ? message.id : null;
|
|
213
|
+
const reply = await handleRpc(tools, message);
|
|
214
|
+
if (reply === null) {
|
|
215
|
+
respond(res, 202);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
respond(res, 200, {
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
id,
|
|
221
|
+
...reply
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Serves Ork's tools to one `claude` CLI session: on 127.0.0.1 only, on a
|
|
226
|
+
* random port, behind a per-session bearer token.
|
|
227
|
+
*/
|
|
228
|
+
const startOrkMcpHttp = async (tools, hold) => {
|
|
229
|
+
const token = randomBytes(24).toString("hex");
|
|
230
|
+
const http = createServer((req, res) => {
|
|
231
|
+
serve(req, res, tools, token, hold).catch(() => {
|
|
232
|
+
if (!res.headersSent) respond(res, 500);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
await new Promise((resolve) => {
|
|
236
|
+
http.listen(0, "127.0.0.1", resolve);
|
|
237
|
+
});
|
|
238
|
+
const address = http.address();
|
|
239
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
240
|
+
return {
|
|
241
|
+
url: `http://127.0.0.1:${port}${PATH}`,
|
|
242
|
+
holdUrl: `http://127.0.0.1:${port}${HOLD_PATH}`,
|
|
243
|
+
token,
|
|
244
|
+
close: () => new Promise((resolve) => {
|
|
245
|
+
http.closeAllConnections();
|
|
246
|
+
http.close(() => {
|
|
247
|
+
resolve();
|
|
248
|
+
});
|
|
249
|
+
})
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
// ../runtime/src/session-leg.ts
|
|
253
|
+
/**
|
|
254
|
+
* @file One process (leg) of a session the runner drives: it writes the queued
|
|
255
|
+
* stdin lines, yields the adapter's decoded events, and says how it ended. A
|
|
256
|
+
* session is one leg, or several when a paused node's tool call was deferred
|
|
257
|
+
* or a message waits for a tool that takes none mid-session; a resumed leg
|
|
258
|
+
* carries what the legs before it spent, and its start is not news.
|
|
259
|
+
*/
|
|
260
|
+
const REASON_LIMIT = 500;
|
|
261
|
+
/** Marks a session's start with the driver that runs it. */
|
|
262
|
+
const withDriver = (event, driver) => event.type === "session.started" ? {
|
|
263
|
+
...event,
|
|
264
|
+
driver
|
|
265
|
+
} : event;
|
|
266
|
+
/** A finished event for a session the driver could not run. */
|
|
267
|
+
const driverFailure = (message) => ({
|
|
268
|
+
type: "session.finished",
|
|
269
|
+
outcome: {
|
|
270
|
+
subtype: "error_driver",
|
|
271
|
+
sessionId: "",
|
|
272
|
+
report: null,
|
|
273
|
+
text: "",
|
|
274
|
+
usage: {
|
|
275
|
+
costUsd: 0,
|
|
276
|
+
inputTokens: 0,
|
|
277
|
+
outputTokens: 0,
|
|
278
|
+
cacheReadTokens: 0,
|
|
279
|
+
cacheWriteTokens: 0,
|
|
280
|
+
turns: 0,
|
|
281
|
+
durationMs: 0
|
|
282
|
+
},
|
|
283
|
+
errors: [message]
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
/**
|
|
287
|
+
* What one decoded line yields, and how the leg ends if it does: a result is
|
|
288
|
+
* the session's end, unless the hold hook deferred a call or a message waits; a
|
|
289
|
+
* resumed leg's start is not news, and its end carries what the legs before it
|
|
290
|
+
* spent.
|
|
291
|
+
*/
|
|
292
|
+
const legStep = (decoded, input) => {
|
|
293
|
+
const { adapter, carried } = input;
|
|
294
|
+
const deferred = input.deferred() && decoded.deferred === true;
|
|
295
|
+
const events = [];
|
|
296
|
+
let ended;
|
|
297
|
+
for (const said of decoded.events) {
|
|
298
|
+
const event = said.type === "session.finished" && input.reported !== void 0 ? {
|
|
299
|
+
...said,
|
|
300
|
+
outcome: {
|
|
301
|
+
...said.outcome,
|
|
302
|
+
report: input.reported()
|
|
303
|
+
}
|
|
304
|
+
} : said;
|
|
305
|
+
if (event.type === "session.started" && carried !== void 0) continue;
|
|
306
|
+
if (event.type !== "session.finished") events.push(withDriver(event, adapter.driver));
|
|
307
|
+
else if (deferred) ended = {
|
|
308
|
+
kind: "deferred",
|
|
309
|
+
sessionId: event.outcome.sessionId,
|
|
310
|
+
usage: event.outcome.usage,
|
|
311
|
+
last: event
|
|
312
|
+
};
|
|
313
|
+
else {
|
|
314
|
+
const usage = carried === void 0 ? event.outcome.usage : adapter.usage.combine(carried, event.outcome.usage);
|
|
315
|
+
const last = {
|
|
316
|
+
...event,
|
|
317
|
+
outcome: {
|
|
318
|
+
...event.outcome,
|
|
319
|
+
usage
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
if (input.withhold() && event.outcome.subtype === "success") ended = {
|
|
323
|
+
kind: "continued",
|
|
324
|
+
sessionId: event.outcome.sessionId,
|
|
325
|
+
usage: event.outcome.usage,
|
|
326
|
+
last
|
|
327
|
+
};
|
|
328
|
+
else {
|
|
329
|
+
events.push(last);
|
|
330
|
+
ended = { kind: "finished" };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return ended === void 0 ? { events } : {
|
|
335
|
+
events,
|
|
336
|
+
ended
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
/** Noise a Node crash ends stderr with, or a stack frame: never the reason. */
|
|
340
|
+
const isNoise = (line) => /^Node\.js v\d/u.test(line) || /^\s+at /u.test(line);
|
|
341
|
+
/**
|
|
342
|
+
* Why a process ended without a result: the last block of its stderr as one
|
|
343
|
+
* masked, capped line (the reason reaches the terminal, which is not masked).
|
|
344
|
+
*/
|
|
345
|
+
const failureReason = (name, stderr, code) => {
|
|
346
|
+
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 !== "");
|
|
347
|
+
return block === void 0 ? `${name} exited with code ${String(code)}` : maskSecrets(block).slice(0, REASON_LIMIT);
|
|
348
|
+
};
|
|
349
|
+
/** Runs one process of a session, yielding its events; returns how it ended. */
|
|
350
|
+
const runLeg = async function* (input) {
|
|
351
|
+
const child = spawn(input.bin, input.args, {
|
|
352
|
+
cwd: input.cwd,
|
|
353
|
+
env: { ...input.env },
|
|
354
|
+
stdio: [
|
|
355
|
+
"pipe",
|
|
356
|
+
"pipe",
|
|
357
|
+
"pipe"
|
|
358
|
+
]
|
|
359
|
+
});
|
|
360
|
+
const exited = new Promise((resolve) => {
|
|
361
|
+
child.on("close", (code, signal) => {
|
|
362
|
+
resolve({
|
|
363
|
+
code,
|
|
364
|
+
signal
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
const spawnError = new Promise((resolve) => {
|
|
369
|
+
child.on("error", resolve);
|
|
370
|
+
});
|
|
371
|
+
let stderr = "";
|
|
372
|
+
child.stderr.setEncoding("utf8");
|
|
373
|
+
child.stderr.on("data", (chunk) => {
|
|
374
|
+
stderr = (stderr + chunk).slice(-16384);
|
|
375
|
+
});
|
|
376
|
+
const write = (line) => {
|
|
377
|
+
if (child.stdin.writable) child.stdin.write(line);
|
|
378
|
+
};
|
|
379
|
+
let stopped = false;
|
|
380
|
+
input.attach({
|
|
381
|
+
write,
|
|
382
|
+
stop: () => {
|
|
383
|
+
stopped = true;
|
|
384
|
+
child.kill("SIGINT");
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
for (const line of input.pending.splice(0)) write(line);
|
|
388
|
+
if (input.adapter.command.input === void 0) child.stdin.end();
|
|
389
|
+
let outcome;
|
|
390
|
+
const { decoder } = input;
|
|
391
|
+
const decode = decoder?.decode ?? input.adapter.decoder?.() ?? input.adapter.decode;
|
|
392
|
+
try {
|
|
393
|
+
for await (const line of createInterface({ input: child.stdout })) {
|
|
394
|
+
const { events, ended } = legStep(decode(line), input);
|
|
395
|
+
yield* events;
|
|
396
|
+
if (ended !== void 0) {
|
|
397
|
+
child.stdin.end();
|
|
398
|
+
outcome = ended;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const end = await Promise.race([exited, spawnError.then((error) => error)]);
|
|
402
|
+
if (outcome !== void 0) return outcome;
|
|
403
|
+
if (decoder !== void 0 && !(end instanceof Error) && !stopped && end.signal === null) {
|
|
404
|
+
const { events, ended } = legStep({ events: decoder.exit(end.code) }, input);
|
|
405
|
+
yield* events;
|
|
406
|
+
if (ended !== void 0) return ended;
|
|
407
|
+
}
|
|
408
|
+
if (end instanceof Error) return {
|
|
409
|
+
kind: "failed",
|
|
410
|
+
reason: `could not start ${input.bin}: ${end.message}`
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
kind: "failed",
|
|
414
|
+
reason: stopped || end.signal !== null ? "interrupted" : failureReason(input.adapter.command.bin, stderr, end.code)
|
|
415
|
+
};
|
|
416
|
+
} finally {
|
|
417
|
+
input.attach();
|
|
418
|
+
if (child.exitCode === null) child.kill();
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
// ../runtime/src/tools/ask-user.ts
|
|
422
|
+
const askUserTool = defineTool({
|
|
423
|
+
name: "ask_user",
|
|
424
|
+
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.",
|
|
425
|
+
shape: {
|
|
426
|
+
question: z.string().describe("One clear question."),
|
|
427
|
+
options: z.array(z.object({
|
|
428
|
+
id: z.string().describe("A short id, e.g. 'keep-api'."),
|
|
429
|
+
label: z.string().optional(),
|
|
430
|
+
description: z.string().optional().describe("What choosing it means.")
|
|
431
|
+
})).min(2).max(4),
|
|
432
|
+
recommended: z.string().optional().describe("The id you recommend."),
|
|
433
|
+
context: z.string().optional().describe("What the user needs to know to decide, in a few sentences.")
|
|
434
|
+
},
|
|
435
|
+
run: async ({ nodeId, ports }, args) => {
|
|
436
|
+
let note;
|
|
437
|
+
let owner;
|
|
438
|
+
const question = {
|
|
439
|
+
nodeId,
|
|
440
|
+
onNote: (given) => {
|
|
441
|
+
note = given;
|
|
442
|
+
},
|
|
443
|
+
onOwner: (given) => {
|
|
444
|
+
owner = given;
|
|
445
|
+
},
|
|
446
|
+
question: args.question,
|
|
447
|
+
options: args.options.map((o) => {
|
|
448
|
+
const choice = { id: o.id };
|
|
449
|
+
if (o.label !== void 0) choice.label = o.label;
|
|
450
|
+
if (o.description !== void 0) choice.description = o.description;
|
|
451
|
+
return choice;
|
|
452
|
+
})
|
|
453
|
+
};
|
|
454
|
+
if (args.recommended !== void 0) question.recommended = args.recommended;
|
|
455
|
+
if (args.context !== void 0) question.context = args.context;
|
|
456
|
+
const answer = await ports.human.ask(question);
|
|
457
|
+
const who = owner === void 0 ? "The user" : `Your owner, \`${owner}\`,`;
|
|
458
|
+
return text(note === void 0 ? `${who} chose: ${answer}` : `${who} chose: ${answer}\nThey said: ${note}`);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
// ../runtime/src/tools/claims.ts
|
|
462
|
+
/**
|
|
463
|
+
* @file `claim` and `release`: a session's side of the run's claims. A node
|
|
464
|
+
* claims a file, folder or other resource before changing it, so nodes
|
|
465
|
+
* running at the same time do not change the same thing; a refused claim says
|
|
466
|
+
* who holds it. Only sessions that may write while another does get them.
|
|
467
|
+
*/
|
|
468
|
+
const contended = ({ ports }) => ports.claims?.contended() === true;
|
|
469
|
+
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'.");
|
|
470
|
+
const claimTool = defineTool({
|
|
471
|
+
name: "claim",
|
|
472
|
+
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.",
|
|
473
|
+
shape: { resource },
|
|
474
|
+
available: contended,
|
|
475
|
+
run: ({ ports }, args) => {
|
|
476
|
+
const claimed = ports.claims?.claim(args.resource);
|
|
477
|
+
if (claimed === void 0) return Promise.resolve(text("No claims."));
|
|
478
|
+
if (!claimed.ok) return Promise.resolve(text(`Not claimed: ${claimed.error}.`));
|
|
479
|
+
return Promise.resolve(text(claimed.already ? `You already hold ${claimed.resource}.` : `Claimed ${claimed.resource}.`));
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
const releaseTool = defineTool({
|
|
483
|
+
name: "release",
|
|
484
|
+
description: "Release a claim you are done with, so other nodes can take it. Ork releases your remaining claims when you finish.",
|
|
485
|
+
shape: { resource },
|
|
486
|
+
available: contended,
|
|
487
|
+
run: ({ ports }, args) => {
|
|
488
|
+
const released = ports.claims?.release(args.resource);
|
|
489
|
+
if (released === void 0) return Promise.resolve(text("No claims."));
|
|
490
|
+
return Promise.resolve(text(released.ok ? `Released ${released.resource}.` : `Not released: ${released.error}.`));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
// ../runtime/src/tools/delegate.ts
|
|
494
|
+
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.");
|
|
495
|
+
/** What the leader reads back: each report without the usage details. */
|
|
496
|
+
const reportLine = (report) => {
|
|
497
|
+
const { usage, ...output } = report.output;
|
|
498
|
+
const cost = usage !== null && typeof usage === "object" && "costUsd" in usage ? usage.costUsd : void 0;
|
|
499
|
+
return {
|
|
500
|
+
child: report.child,
|
|
501
|
+
ok: report.ok,
|
|
502
|
+
...output,
|
|
503
|
+
...cost === void 0 ? {} : { costUsd: cost },
|
|
504
|
+
...report.ok || report.error === void 0 || report.error === output["summary"] ? {} : { error: report.error }
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
const delegationText = (delegation) => text(JSON.stringify({
|
|
508
|
+
reports: delegation.reports.map(reportLine),
|
|
509
|
+
...delegation.notes.length > 0 ? { notes: delegation.notes } : {}
|
|
510
|
+
}));
|
|
511
|
+
/** Why a child the leader owns isn't offered, so it doesn't go looking. */
|
|
512
|
+
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.`;
|
|
513
|
+
const delegateTool = defineTool({
|
|
514
|
+
name: "delegate",
|
|
515
|
+
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 ?? [])}`,
|
|
516
|
+
shape: ({ ports }) => {
|
|
517
|
+
const ids = ports.ownership?.children.map((child) => child.id) ?? [];
|
|
518
|
+
return { tasks: z.array(z.strictObject({
|
|
519
|
+
child: z.enum(ids).describe(`The id of a node you own: ${ids.join(", ")}.`),
|
|
520
|
+
brief: BRIEF
|
|
521
|
+
})).min(1) };
|
|
522
|
+
},
|
|
523
|
+
available: ({ ports }) => (ports.ownership?.children.length ?? 0) > 0,
|
|
524
|
+
run: async ({ ports }, args) => {
|
|
525
|
+
if (ports.ownership === void 0) return text("You own no nodes.");
|
|
526
|
+
return delegationText(await ports.ownership.delegate(args.tasks));
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
const spawnWorkerTool = defineTool({
|
|
530
|
+
name: "spawn_worker",
|
|
531
|
+
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.",
|
|
532
|
+
shape: ({ ports }) => {
|
|
533
|
+
const ids = ports.ownership?.templates ?? [];
|
|
534
|
+
return { tasks: z.array(z.strictObject({
|
|
535
|
+
template: z.enum(ids).describe(`The id of a child worker you own: ${ids.join(", ")}.`),
|
|
536
|
+
brief: BRIEF
|
|
537
|
+
})).min(1) };
|
|
538
|
+
},
|
|
539
|
+
available: ({ ports }) => ports.ownership !== void 0 && ports.ownership.templates.length > 0 && ports.ownership.workersLeft() > 0,
|
|
540
|
+
run: async ({ ports }, args) => {
|
|
541
|
+
if (ports.ownership === void 0) return text("You own no workers.");
|
|
542
|
+
return delegationText(await ports.ownership.spawn(args.tasks));
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
// ../runtime/src/tools/feedback.ts
|
|
546
|
+
/**
|
|
547
|
+
* @file `feedback`: a session's way to report what got in its way, kept on the
|
|
548
|
+
* user's machine (specs/feedback.md). Every session inside a run gets it; Ork
|
|
549
|
+
* caps reports per session and drops a run's duplicates.
|
|
550
|
+
*/
|
|
551
|
+
const feedbackTool = defineTool({
|
|
552
|
+
name: "feedback",
|
|
553
|
+
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.",
|
|
554
|
+
shape: {
|
|
555
|
+
kind: z.enum(FEEDBACK_KINDS),
|
|
556
|
+
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."),
|
|
557
|
+
severity: z.enum(FEEDBACK_SEVERITIES).optional(),
|
|
558
|
+
title: z.string().min(1).describe("One line."),
|
|
559
|
+
body: z.string().optional().describe("A few sentences: what happened and what would have helped."),
|
|
560
|
+
evidence: z.array(z.string()).optional().describe("File paths, tool names; never pasted file contents.")
|
|
561
|
+
},
|
|
562
|
+
available: ({ ports }) => ports.feedback !== void 0,
|
|
563
|
+
run: ({ ports }, args) => {
|
|
564
|
+
const draft = {
|
|
565
|
+
kind: args.kind,
|
|
566
|
+
title: args.title
|
|
567
|
+
};
|
|
568
|
+
if (args.target !== void 0) draft.target = args.target;
|
|
569
|
+
if (args.severity !== void 0) draft.severity = args.severity;
|
|
570
|
+
if (args.body !== void 0) draft.body = args.body;
|
|
571
|
+
if (args.evidence !== void 0) draft.evidence = args.evidence;
|
|
572
|
+
const sent = ports.feedback?.report(draft);
|
|
573
|
+
if (sent === void 0) return Promise.resolve(text("No feedback here."));
|
|
574
|
+
return Promise.resolve(text(sent.ok ? "Recorded. Thank you; carry on with your task." : `Not recorded: ${sent.error}.`));
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
// ../runtime/src/tools/input.ts
|
|
578
|
+
/**
|
|
579
|
+
* @file `read_input`: what the workflow sent a node back with, whole. A loop's
|
|
580
|
+
* re-entry note shows the first 8 KiB of it; when that cut it, the session
|
|
581
|
+
* gets this tool and reads the rest a page at a time, so a long review's last
|
|
582
|
+
* findings reach the node.
|
|
583
|
+
*/
|
|
584
|
+
/** The characters one call returns. */
|
|
585
|
+
const INPUT_PAGE = 32e3;
|
|
586
|
+
const readInputTool = defineTool({
|
|
587
|
+
name: "read_input",
|
|
588
|
+
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\`.`,
|
|
589
|
+
shape: { from: z.number().int().nonnegative().optional().describe("The character to start at. Default: 0.") },
|
|
590
|
+
available: ({ ports }) => ports.input !== void 0,
|
|
591
|
+
run: ({ ports }, args) => {
|
|
592
|
+
const whole = ports.input ?? "";
|
|
593
|
+
const from = Math.min(args.from ?? 0, whole.length);
|
|
594
|
+
const end = Math.min(from + INPUT_PAGE, whole.length);
|
|
595
|
+
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]`;
|
|
596
|
+
return Promise.resolve(text(`${whole.slice(from, end)}\n\n${where}`));
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
// ../runtime/src/tools/messages.ts
|
|
600
|
+
/**
|
|
601
|
+
* @file `send_message`, `read_inbox` and `answer`: a session's side of the
|
|
602
|
+
* run's message bus. A message lands in the recipient's inbox and a live
|
|
603
|
+
* session gets a one-line notice; `answer` replies to a question Ork asked an
|
|
604
|
+
* owner about a node it owns. Only sessions inside a run get them.
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* Peers are mutual: a node no one may write to has no inbox to read, unless it
|
|
608
|
+
* may be linked later, since a session's tools are set when it starts.
|
|
609
|
+
*/
|
|
610
|
+
const hasPeers = ({ ports }) => (ports.mailbox?.peers().length ?? 0) > 0 || ports.mailbox?.linkable() === true;
|
|
611
|
+
/** A program or a rule may write to a node with no one to write to itself. */
|
|
612
|
+
const hasInbox = (context) => hasPeers(context) || context.ports.mailbox?.hears() === true;
|
|
613
|
+
const MAX_WAIT_SECONDS = 3600;
|
|
614
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
615
|
+
const inboxText = (unread) => JSON.stringify({ messages: unread.map((m) => ({
|
|
616
|
+
from: m.from,
|
|
617
|
+
...m.via === void 0 ? {} : { via: m.via },
|
|
618
|
+
body: m.body
|
|
619
|
+
})) });
|
|
620
|
+
const sendMessageTool = defineTool({
|
|
621
|
+
name: "send_message",
|
|
622
|
+
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.",
|
|
623
|
+
shape: {
|
|
624
|
+
to: z.string().describe("The node's id, or <run>/<node> for a linked node."),
|
|
625
|
+
body: z.string().min(1).describe("Self-contained: the recipient does not see your work.")
|
|
626
|
+
},
|
|
627
|
+
available: hasPeers,
|
|
628
|
+
run: ({ ports }, args) => {
|
|
629
|
+
const sent = ports.mailbox?.send(args.to, args.body);
|
|
630
|
+
if (sent === void 0) return Promise.resolve(text("No message bus."));
|
|
631
|
+
if (!sent.ok) return Promise.resolve(text(`Not sent: ${sent.error}.`));
|
|
632
|
+
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.`));
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
const readInboxTool = defineTool({
|
|
636
|
+
name: "read_inbox",
|
|
637
|
+
description: "Read your unread messages from other nodes, oldest first. Ork tells you when one arrives.",
|
|
638
|
+
shape: {},
|
|
639
|
+
available: hasInbox,
|
|
640
|
+
run: ({ ports }) => {
|
|
641
|
+
const unread = ports.mailbox?.read() ?? [];
|
|
642
|
+
return Promise.resolve(text(unread.length === 0 ? "No unread messages." : inboxText(unread)));
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
const waitForMessagesTool = defineTool({
|
|
646
|
+
name: "wait_for_messages",
|
|
647
|
+
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.",
|
|
648
|
+
shape: { timeoutSeconds: z.number().int().positive().max(MAX_WAIT_SECONDS).optional().describe(`How long to wait. Default: ${String(DEFAULT_WAIT_SECONDS)}.`) },
|
|
649
|
+
available: hasInbox,
|
|
650
|
+
run: async ({ ports }, args) => {
|
|
651
|
+
const seconds = args.timeoutSeconds ?? DEFAULT_WAIT_SECONDS;
|
|
652
|
+
await ports.mailbox?.wait(seconds * 1e3);
|
|
653
|
+
const unread = ports.mailbox?.read() ?? [];
|
|
654
|
+
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));
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
const answerTool = defineTool({
|
|
658
|
+
name: "answer",
|
|
659
|
+
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.",
|
|
660
|
+
shape: {
|
|
661
|
+
question: z.string().describe("The question's id, e.g. 'q1'."),
|
|
662
|
+
choice: z.string().describe("One of its options."),
|
|
663
|
+
note: z.string().optional().describe("Why, in a sentence or two; it is recorded with your answer.")
|
|
664
|
+
},
|
|
665
|
+
available: ({ ports }) => ports.mailbox !== void 0 && ports.ownership !== void 0,
|
|
666
|
+
run: ({ ports }, args) => {
|
|
667
|
+
const answered = ports.mailbox?.answer(args.question, args.choice, args.note);
|
|
668
|
+
if (answered === void 0) return Promise.resolve(text("No questions."));
|
|
669
|
+
return Promise.resolve(text(answered.ok ? `Answered ${args.question}: ${args.choice}.` : `Not answered: ${answered.error}.`));
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
// ../runtime/src/tools/permission.ts
|
|
673
|
+
/**
|
|
674
|
+
* @file `permission`: the `claude` CLI's `--permission-prompt-tool`, which
|
|
675
|
+
* sends each permission prompt to the run's `HumanChannel`.
|
|
676
|
+
*/
|
|
677
|
+
const permissionTool = defineTool({
|
|
678
|
+
name: "permission",
|
|
679
|
+
description: "Ork's permission prompt for the claude CLI.",
|
|
680
|
+
shape: {
|
|
681
|
+
tool_name: z.string(),
|
|
682
|
+
input: z.record(z.string(), z.unknown()),
|
|
683
|
+
tool_use_id: z.string().optional()
|
|
684
|
+
},
|
|
685
|
+
preapproved: false,
|
|
686
|
+
available: ({ driver }) => driver === "claude-code",
|
|
687
|
+
run: async ({ nodeId, ports }, args) => {
|
|
688
|
+
const decision = await ports.human.permit({
|
|
689
|
+
nodeId,
|
|
690
|
+
toolName: args.tool_name,
|
|
691
|
+
input: args.input
|
|
692
|
+
});
|
|
693
|
+
return text(JSON.stringify(decision.allow ? {
|
|
694
|
+
behavior: "allow",
|
|
695
|
+
updatedInput: args.input
|
|
696
|
+
} : {
|
|
697
|
+
behavior: "deny",
|
|
698
|
+
message: decision.message
|
|
699
|
+
}));
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
// ../runtime/src/tools/report.ts
|
|
703
|
+
/**
|
|
704
|
+
* @file The `report` tool: how a session hands Ork its final report when its
|
|
705
|
+
* driver cannot hold the final answer to a JSON Schema itself (`reportSchema`
|
|
706
|
+
* false, RFC-014). The report is checked against the node's report schema
|
|
707
|
+
* with zod; a report that breaks it comes back to the model as a tool error
|
|
708
|
+
* naming each violation, so it can fix it and call again. One that fits is
|
|
709
|
+
* kept as the session's report, and the session runner ends the session with
|
|
710
|
+
* it instead of what the tool's last line says.
|
|
711
|
+
*/
|
|
712
|
+
const REPORT_TOOL = "report";
|
|
713
|
+
/** Whether the driver holds a session's final answer to its schema itself. */
|
|
714
|
+
const checksItsReport = (driver) => AGENT_DRIVERS.find((d) => d.id === driver)?.capabilities.reportSchema ?? true;
|
|
715
|
+
/**
|
|
716
|
+
* The report schema as zod. shortcut: a schema zod cannot read takes any
|
|
717
|
+
* object, and `parseReport` still demands a status and a summary; upgrade if a
|
|
718
|
+
* node's `output.schema` ever uses JSON Schema zod does not support.
|
|
719
|
+
*/
|
|
720
|
+
const reportValidator = (schema) => {
|
|
721
|
+
try {
|
|
722
|
+
return z.fromJSONSchema(schema);
|
|
723
|
+
} catch {
|
|
724
|
+
return z.record(z.string(), z.unknown());
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
const failed = (why) => ({
|
|
728
|
+
...text(`The report was not accepted; fix it and call ${REPORT_TOOL} again.\n${why}`),
|
|
729
|
+
isError: true
|
|
730
|
+
});
|
|
731
|
+
const bound = (report) => {
|
|
732
|
+
const schema = reportValidator(report.schema);
|
|
733
|
+
return {
|
|
734
|
+
name: REPORT_TOOL,
|
|
735
|
+
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.",
|
|
736
|
+
shape: schema instanceof z.ZodObject ? schema.shape : {},
|
|
737
|
+
schema,
|
|
738
|
+
preapproved: true,
|
|
739
|
+
call: (args) => {
|
|
740
|
+
const parsed = schema.safeParse(args, { reportInput: true });
|
|
741
|
+
if (!parsed.success) return Promise.resolve(failed(readableIssues(parsed.error.issues)));
|
|
742
|
+
const kept = parseReport(parsed.data);
|
|
743
|
+
if (kept === null) return Promise.resolve(failed("A report needs a `status` and a `summary`."));
|
|
744
|
+
report.submit(kept);
|
|
745
|
+
return Promise.resolve(text("Report accepted. End your turn now; there is nothing more to do."));
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
};
|
|
749
|
+
// ../runtime/src/tools/registry.ts
|
|
750
|
+
/**
|
|
751
|
+
* @file The registry of Ork's MCP tools. Both drivers serve what it binds for a
|
|
752
|
+
* session; a new tool is one file here and one line in `ORK_TOOLS`.
|
|
753
|
+
*/
|
|
754
|
+
const ORK_TOOLS = [
|
|
755
|
+
askUserTool,
|
|
756
|
+
permissionTool,
|
|
757
|
+
delegateTool,
|
|
758
|
+
spawnWorkerTool,
|
|
759
|
+
sendMessageTool,
|
|
760
|
+
readInboxTool,
|
|
761
|
+
waitForMessagesTool,
|
|
762
|
+
answerTool,
|
|
763
|
+
readInputTool,
|
|
764
|
+
claimTool,
|
|
765
|
+
releaseTool,
|
|
766
|
+
feedbackTool,
|
|
767
|
+
{
|
|
768
|
+
name: REPORT_TOOL,
|
|
769
|
+
bind: (context) => context.report === void 0 || checksItsReport(context.driver) ? null : bound(context.report)
|
|
770
|
+
}
|
|
771
|
+
];
|
|
772
|
+
/** The tools one session gets. */
|
|
773
|
+
const orkTools = (context) => ORK_TOOLS.flatMap((tool) => tool.bind(context) ?? []);
|
|
774
|
+
/** Their Claude Code names, for the session's allowed tools. */
|
|
775
|
+
const preapprovedTools = (tools) => tools.filter((t) => t.preapproved).map((t) => toolId(t.name));
|
|
776
|
+
// ../runtime/src/session-runner.ts
|
|
777
|
+
/** A spec that resumes `sessionId`; a fork's later legs continue the fork. */
|
|
778
|
+
const resumed = (spec, sessionId) => ({
|
|
779
|
+
...spec,
|
|
780
|
+
resume: sessionId,
|
|
781
|
+
fork: false
|
|
782
|
+
});
|
|
783
|
+
/**
|
|
784
|
+
* Wires a session to Ork's endpoint, yielding a `session.waiting` event each
|
|
785
|
+
* time the wiring says it waits (a lease waits for its checkout).
|
|
786
|
+
*/
|
|
787
|
+
const wireSaying = async function* (adapter, spec, endpoint, signal, options) {
|
|
788
|
+
const waits = [];
|
|
789
|
+
let woke;
|
|
790
|
+
const woken = () => new Promise((resolve) => {
|
|
791
|
+
woke = () => {
|
|
792
|
+
resolve(false);
|
|
793
|
+
};
|
|
794
|
+
});
|
|
795
|
+
const wiring = adapter.mcp.wire(spec, endpoint, {
|
|
796
|
+
waiting: (reason) => {
|
|
797
|
+
waits.push(reason);
|
|
798
|
+
woke?.();
|
|
799
|
+
},
|
|
800
|
+
signal,
|
|
801
|
+
bin: options.bin ?? adapter.command.bin,
|
|
802
|
+
env: options.env ?? process.env,
|
|
803
|
+
...options.runs === void 0 ? {} : { runs: options.runs }
|
|
804
|
+
});
|
|
805
|
+
const done = wiring.then(() => true, () => true);
|
|
806
|
+
for (;;) {
|
|
807
|
+
const finished = waits.length === 0 && await Promise.race([done, woken()]);
|
|
808
|
+
for (const reason of waits.splice(0)) yield {
|
|
809
|
+
type: "session.waiting",
|
|
810
|
+
reason
|
|
811
|
+
};
|
|
812
|
+
if (finished) return wiring;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
/**
|
|
816
|
+
* A runtime that drives `adapter`'s tool, as the user installed and logged it
|
|
817
|
+
* in. Ork never reads or passes its credentials.
|
|
818
|
+
*/
|
|
819
|
+
const createSessionRuntime = (adapter, options = {}) => ({
|
|
820
|
+
driver: adapter.driver,
|
|
821
|
+
start: (spec, ports) => {
|
|
822
|
+
const { input } = adapter.command;
|
|
823
|
+
const pending = input === void 0 ? [] : [input(spec.prompt, false)];
|
|
824
|
+
const queued = [];
|
|
825
|
+
let io;
|
|
826
|
+
const ended = new AbortController();
|
|
827
|
+
/** The hold hook deferred a call in the running process. */
|
|
828
|
+
let deferring = false;
|
|
829
|
+
/** The last report the `report` tool accepted, across legs. */
|
|
830
|
+
let reported = null;
|
|
831
|
+
const { hold } = ports;
|
|
832
|
+
const hook = hold === void 0 ? void 0 : adapter.hold;
|
|
833
|
+
const events = async function* () {
|
|
834
|
+
const tools = orkTools({
|
|
835
|
+
nodeId: spec.nodeId,
|
|
836
|
+
driver: adapter.driver,
|
|
837
|
+
ports,
|
|
838
|
+
report: {
|
|
839
|
+
schema: spec.reportSchema,
|
|
840
|
+
submit: (report) => {
|
|
841
|
+
reported = report;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
const byTool = tools.some((tool) => tool.name === REPORT_TOOL);
|
|
846
|
+
const mcp = await startOrkMcpHttp(tools, hold === void 0 || hook === void 0 ? void 0 : async (gone) => {
|
|
847
|
+
const answer = await holdAtTool(hold, AbortSignal.any([gone, ended.signal]), options.holdWindowMs ?? hook.windowMs);
|
|
848
|
+
if (answer === "defer") deferring = true;
|
|
849
|
+
return hook.answer(answer);
|
|
850
|
+
});
|
|
851
|
+
const endpoint = hook === void 0 ? {
|
|
852
|
+
url: mcp.url,
|
|
853
|
+
token: mcp.token
|
|
854
|
+
} : mcp;
|
|
855
|
+
const allowed = preapprovedTools(tools);
|
|
856
|
+
let wired;
|
|
857
|
+
try {
|
|
858
|
+
try {
|
|
859
|
+
wired = yield* wireSaying(adapter, spec, endpoint, ended.signal, options);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
yield driverFailure(`could not reach Ork's tools: ${error instanceof Error ? error.message : String(error)}`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const env = {
|
|
865
|
+
...options.env ?? process.env,
|
|
866
|
+
...adapter.context.env(spec),
|
|
867
|
+
...wired.env
|
|
868
|
+
};
|
|
869
|
+
const legOf = (legSpec, carried) => ({
|
|
870
|
+
adapter,
|
|
871
|
+
bin: options.bin ?? adapter.command.bin,
|
|
872
|
+
env,
|
|
873
|
+
cwd: spec.cwd,
|
|
874
|
+
args: [...adapter.command.args(legSpec, {
|
|
875
|
+
endpoint,
|
|
876
|
+
allowed
|
|
877
|
+
}), ...wired?.args ?? []],
|
|
878
|
+
carried,
|
|
879
|
+
...adapter.legDecoder === void 0 ? {} : { decoder: adapter.legDecoder(legSpec) },
|
|
880
|
+
pending,
|
|
881
|
+
deferred: () => deferring,
|
|
882
|
+
withhold: () => input === void 0 && queued.length > 0,
|
|
883
|
+
...byTool ? { reported: () => reported } : {},
|
|
884
|
+
attach: (attached) => {
|
|
885
|
+
io = attached;
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
let legSpec = spec;
|
|
889
|
+
let spent;
|
|
890
|
+
for (;;) {
|
|
891
|
+
if (ended.signal.aborted) {
|
|
892
|
+
yield driverFailure("interrupted");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const leg = yield* runLeg(legOf(legSpec, spent));
|
|
896
|
+
if (leg.kind === "failed") {
|
|
897
|
+
yield driverFailure(leg.reason);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (leg.kind === "finished") return;
|
|
901
|
+
deferring = false;
|
|
902
|
+
spent = spent === void 0 ? leg.usage : adapter.usage.combine(spent, leg.usage);
|
|
903
|
+
if (leg.kind === "deferred") await hold?.wait("tool", ended.signal);
|
|
904
|
+
if (ended.signal.aborted) {
|
|
905
|
+
yield leg.last;
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
legSpec = leg.kind === "continued" ? {
|
|
909
|
+
...resumed(spec, leg.sessionId),
|
|
910
|
+
prompt: queued.splice(0).join("\n\n")
|
|
911
|
+
} : resumed(spec, leg.sessionId);
|
|
912
|
+
}
|
|
913
|
+
} finally {
|
|
914
|
+
await wired?.release();
|
|
915
|
+
await mcp.close();
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
return {
|
|
919
|
+
events: events(),
|
|
920
|
+
send: (text) => {
|
|
921
|
+
if (input === void 0) {
|
|
922
|
+
queued.push(text);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const line = input(text, true);
|
|
926
|
+
if (io === void 0) pending.push(line);
|
|
927
|
+
else io.write(line);
|
|
928
|
+
},
|
|
929
|
+
interrupt: () => {
|
|
930
|
+
ended.abort();
|
|
931
|
+
io?.stop();
|
|
932
|
+
return Promise.resolve();
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
// ../runtime/src/adapters/claude-code-events.ts
|
|
938
|
+
/**
|
|
939
|
+
* @file The `claude-code` adapter's `EventDecoder` and `UsageReader`: maps the
|
|
940
|
+
* `claude` CLI's `stream-json` lines to Ork's runtime events, marks the
|
|
941
|
+
* result of a turn the hold hook deferred, and adds up a session's legs.
|
|
942
|
+
*/
|
|
943
|
+
const isObject$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
944
|
+
const get$1 = (value, key) => isObject$2(value) ? value[key] : void 0;
|
|
945
|
+
const num$2 = (value) => typeof value === "number" ? value : 0;
|
|
946
|
+
const str$2 = (value) => typeof value === "string" ? value : "";
|
|
947
|
+
const blocks = (message) => {
|
|
948
|
+
const content = get$1(get$1(message, "message"), "content");
|
|
949
|
+
return Array.isArray(content) ? content.filter((item) => isObject$2(item)) : [];
|
|
950
|
+
};
|
|
951
|
+
const usageOf = (result) => {
|
|
952
|
+
const usage = get$1(result, "usage");
|
|
953
|
+
return {
|
|
954
|
+
costUsd: num$2(result["total_cost_usd"]),
|
|
955
|
+
inputTokens: num$2(get$1(usage, "input_tokens")),
|
|
956
|
+
outputTokens: num$2(get$1(usage, "output_tokens")),
|
|
957
|
+
cacheReadTokens: num$2(get$1(usage, "cache_read_input_tokens")),
|
|
958
|
+
cacheWriteTokens: num$2(get$1(usage, "cache_creation_input_tokens")),
|
|
959
|
+
turns: num$2(result["num_turns"]),
|
|
960
|
+
durationMs: num$2(result["duration_ms"])
|
|
961
|
+
};
|
|
962
|
+
};
|
|
963
|
+
/** The outcome a `result` message describes. */
|
|
964
|
+
const outcomeOf = (result) => {
|
|
965
|
+
const errors = result["errors"];
|
|
966
|
+
return {
|
|
967
|
+
subtype: str$2(result["subtype"]) || "unknown",
|
|
968
|
+
sessionId: str$2(result["session_id"]),
|
|
969
|
+
report: parseReport(result["structured_output"]),
|
|
970
|
+
text: str$2(result["result"]),
|
|
971
|
+
usage: usageOf(result),
|
|
972
|
+
errors: Array.isArray(errors) ? errors.filter((e) => typeof e === "string") : []
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
/** The year 2286: a reset beyond this is not in seconds. */
|
|
976
|
+
const MAX_RESET_SECONDS = 1e10;
|
|
977
|
+
/**
|
|
978
|
+
* What a subscription's limit said (RFC-007). Only the fields Claude Code
|
|
979
|
+
* documents are read, so `utilization`, often absent, plays no part.
|
|
980
|
+
*/
|
|
981
|
+
const planLimitOf = (info) => {
|
|
982
|
+
const status = get$1(info, "status");
|
|
983
|
+
if (status !== "allowed" && status !== "allowed_warning" && status !== "rejected") return [];
|
|
984
|
+
const window = get$1(info, "rateLimitType");
|
|
985
|
+
const resets = get$1(info, "resetsAt");
|
|
986
|
+
const overage = get$1(info, "overageStatus");
|
|
987
|
+
return [{
|
|
988
|
+
type: "plan.limit",
|
|
989
|
+
status: status === "rejected" && !(status === "rejected" && overage !== "allowed" && overage !== "allowed_warning") ? "allowed_warning" : status,
|
|
990
|
+
window: typeof window === "string" ? window : "plan",
|
|
991
|
+
...typeof resets === "number" && resets > 0 && resets < MAX_RESET_SECONDS ? { resetsAt: (/* @__PURE__ */ new Date(resets * 1e3)).toISOString() } : {}
|
|
992
|
+
}];
|
|
993
|
+
};
|
|
994
|
+
/** Maps one driver message to zero or more normalized events. */
|
|
995
|
+
const normalize = (message) => {
|
|
996
|
+
if (!isObject$2(message)) return [];
|
|
997
|
+
switch (message["type"]) {
|
|
998
|
+
case "system": {
|
|
999
|
+
if (message["subtype"] !== "init") return [];
|
|
1000
|
+
const started = {
|
|
1001
|
+
type: "session.started",
|
|
1002
|
+
sessionId: str$2(message["session_id"]),
|
|
1003
|
+
model: str$2(message["model"])
|
|
1004
|
+
};
|
|
1005
|
+
if (str$2(message["cwd"]) !== "") started.cwd = str$2(message["cwd"]);
|
|
1006
|
+
return [started];
|
|
1007
|
+
}
|
|
1008
|
+
case "assistant": return blocks(message).flatMap((block) => {
|
|
1009
|
+
if (block["type"] === "text" && str$2(block["text"]).trim() !== "") return [{
|
|
1010
|
+
type: "assistant.text",
|
|
1011
|
+
text: str$2(block["text"])
|
|
1012
|
+
}];
|
|
1013
|
+
if (block["type"] === "tool_use") {
|
|
1014
|
+
const input = block["input"];
|
|
1015
|
+
return [{
|
|
1016
|
+
type: "tool.called",
|
|
1017
|
+
id: str$2(block["id"]),
|
|
1018
|
+
name: str$2(block["name"]),
|
|
1019
|
+
input: isObject$2(input) ? input : {}
|
|
1020
|
+
}];
|
|
1021
|
+
}
|
|
1022
|
+
return [];
|
|
1023
|
+
});
|
|
1024
|
+
case "user": return blocks(message).filter((block) => block["type"] === "tool_result").map((block) => ({
|
|
1025
|
+
type: "tool.result",
|
|
1026
|
+
id: str$2(block["tool_use_id"]),
|
|
1027
|
+
isError: block["is_error"] === true
|
|
1028
|
+
}));
|
|
1029
|
+
case "rate_limit_event": return planLimitOf(message["rate_limit_info"]);
|
|
1030
|
+
case "result": return [{
|
|
1031
|
+
type: "session.finished",
|
|
1032
|
+
outcome: outcomeOf(message)
|
|
1033
|
+
}];
|
|
1034
|
+
default: return [];
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
/** Whether a stream-json message is the result of a deferred tool call. */
|
|
1038
|
+
const isDeferredResult = (message) => get$1(message, "type") === "result" && get$1(message, "stop_reason") === "tool_deferred";
|
|
1039
|
+
const parseLine$2 = (line) => {
|
|
1040
|
+
try {
|
|
1041
|
+
return JSON.parse(line);
|
|
1042
|
+
} catch {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
/** One `stream-json` line, decoded. */
|
|
1047
|
+
const decodeClaudeLine = (line) => {
|
|
1048
|
+
const message = parseLine$2(line);
|
|
1049
|
+
return {
|
|
1050
|
+
events: normalize(message),
|
|
1051
|
+
deferred: isDeferredResult(message)
|
|
1052
|
+
};
|
|
1053
|
+
};
|
|
1054
|
+
/**
|
|
1055
|
+
* Two legs of one session, as one: the tokens of both, and the later leg's
|
|
1056
|
+
* cost, which is already the session's total (ADR-046).
|
|
1057
|
+
*/
|
|
1058
|
+
const addUsage = (a, b) => ({
|
|
1059
|
+
costUsd: Math.max(a.costUsd, b.costUsd),
|
|
1060
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
1061
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
1062
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
1063
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
1064
|
+
turns: a.turns + b.turns,
|
|
1065
|
+
durationMs: a.durationMs + b.durationMs
|
|
1066
|
+
});
|
|
1067
|
+
/** `claude` reports dollars (a client-side estimate) and tokens. */
|
|
1068
|
+
const claudeUsage = {
|
|
1069
|
+
dollars: true,
|
|
1070
|
+
combine: addUsage
|
|
1071
|
+
};
|
|
1072
|
+
// ../runtime/src/adapters/claude-code.ts
|
|
1073
|
+
/**
|
|
1074
|
+
* Per-request timeout for Ork's MCP server, in ms. The CLI otherwise gives an
|
|
1075
|
+
* HTTP server's request 60 seconds and drops a call silent for 5 minutes, and
|
|
1076
|
+
* `delegate` waits for whole child sessions (the per-server `timeout` raises
|
|
1077
|
+
* both; `MCP_TOOL_TIMEOUT` still caps it).
|
|
1078
|
+
*/
|
|
1079
|
+
const ORK_TOOL_TIMEOUT_MS = 864e5;
|
|
1080
|
+
/** Claude Code's switch for the one-hour prompt cache. */
|
|
1081
|
+
const PROMPT_CACHE_1H = "ENABLE_PROMPT_CACHING_1H";
|
|
1082
|
+
/** Claude Code's switch that turns its auto-memory off. */
|
|
1083
|
+
const NO_AUTO_MEMORY = "CLAUDE_CODE_DISABLE_AUTO_MEMORY";
|
|
1084
|
+
/**
|
|
1085
|
+
* The CLI flag that moves the per-machine sections (working directory, memory
|
|
1086
|
+
* path, git status) out of the system prompt.
|
|
1087
|
+
*/
|
|
1088
|
+
const EXCLUDE_DYNAMIC_SECTIONS = "--exclude-dynamic-system-prompt-sections";
|
|
1089
|
+
/** How long a hook holds a call before it defers it instead. */
|
|
1090
|
+
const HOLD_WINDOW_MS = 54e4;
|
|
1091
|
+
/** The variables a session's prompt cache asks of Claude Code. */
|
|
1092
|
+
const cacheEnv = (spec) => spec.promptCache === "1h" ? { [PROMPT_CACHE_1H]: "1" } : {};
|
|
1093
|
+
/** Whether the session loads no MCP server but Ork's. */
|
|
1094
|
+
const strictMcpOf = (spec) => spec.tools.strictMcp === true || spec.machineClaude !== true;
|
|
1095
|
+
/** The variables that keep the machine's auto-memory out of a session. */
|
|
1096
|
+
const memoryEnv = (spec) => spec.machineClaude === true ? {} : { [NO_AUTO_MEMORY]: "1" };
|
|
1097
|
+
/** Everything a session adds to its driver's environment. */
|
|
1098
|
+
const driverEnv = (spec) => ({
|
|
1099
|
+
...cacheEnv(spec),
|
|
1100
|
+
...memoryEnv(spec)
|
|
1101
|
+
});
|
|
1102
|
+
/** A later message goes in at the next step, even mid-turn. */
|
|
1103
|
+
const userLine = (text, later = false) => `${JSON.stringify({
|
|
1104
|
+
type: "user",
|
|
1105
|
+
message: {
|
|
1106
|
+
role: "user",
|
|
1107
|
+
content: text
|
|
1108
|
+
},
|
|
1109
|
+
parent_tool_use_id: null,
|
|
1110
|
+
...later ? { priority: "next" } : {}
|
|
1111
|
+
})}\n`;
|
|
1112
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1113
|
+
/**
|
|
1114
|
+
* The node's `--settings` with Ork's `PreToolUse` HTTP hook added: before every
|
|
1115
|
+
* tool call, pre-approved ones included, the session asks whether its node may
|
|
1116
|
+
* go on.
|
|
1117
|
+
*/
|
|
1118
|
+
const withHoldHook = (settings, holdUrl, token) => {
|
|
1119
|
+
const hooks = isRecord(settings?.["hooks"]) ? settings["hooks"] : {};
|
|
1120
|
+
const listed = hooks["PreToolUse"];
|
|
1121
|
+
const before = Array.isArray(listed) ? [...listed] : [];
|
|
1122
|
+
const hook = {
|
|
1123
|
+
type: "http",
|
|
1124
|
+
url: holdUrl,
|
|
1125
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1126
|
+
timeout: 600
|
|
1127
|
+
};
|
|
1128
|
+
return {
|
|
1129
|
+
...settings,
|
|
1130
|
+
hooks: {
|
|
1131
|
+
...hooks,
|
|
1132
|
+
PreToolUse: [...before, {
|
|
1133
|
+
matcher: "*",
|
|
1134
|
+
hooks: [hook]
|
|
1135
|
+
}]
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
/** The hook's answer: nothing to say for "go", the deferral otherwise. */
|
|
1140
|
+
const hookOutput = (answer) => answer === "go" ? {} : { hookSpecificOutput: {
|
|
1141
|
+
hookEventName: "PreToolUse",
|
|
1142
|
+
permissionDecision: "defer",
|
|
1143
|
+
permissionDecisionReason: "Ork paused this node; it goes on from this call when it continues."
|
|
1144
|
+
} };
|
|
1145
|
+
const claudeHold = {
|
|
1146
|
+
answer: hookOutput,
|
|
1147
|
+
windowMs: HOLD_WINDOW_MS
|
|
1148
|
+
};
|
|
1149
|
+
/**
|
|
1150
|
+
* The `claude -p` arguments for a session spec. The brief goes on stdin;
|
|
1151
|
+
* `allowed` are the Ork tools that need no permission prompt.
|
|
1152
|
+
*/
|
|
1153
|
+
const buildCliArgs = (spec, mcp, allowed) => {
|
|
1154
|
+
const mcpConfig = { mcpServers: {
|
|
1155
|
+
...spec.mcpServers,
|
|
1156
|
+
["ork"]: {
|
|
1157
|
+
type: "http",
|
|
1158
|
+
url: mcp.url,
|
|
1159
|
+
headers: { Authorization: `Bearer ${mcp.token}` },
|
|
1160
|
+
timeout: ORK_TOOL_TIMEOUT_MS
|
|
1161
|
+
}
|
|
1162
|
+
} };
|
|
1163
|
+
const args = [
|
|
1164
|
+
"-p",
|
|
1165
|
+
"--input-format",
|
|
1166
|
+
"stream-json",
|
|
1167
|
+
"--output-format",
|
|
1168
|
+
"stream-json",
|
|
1169
|
+
"--verbose",
|
|
1170
|
+
"--setting-sources",
|
|
1171
|
+
spec.settingSources.join(","),
|
|
1172
|
+
"--append-system-prompt",
|
|
1173
|
+
spec.systemAppend,
|
|
1174
|
+
EXCLUDE_DYNAMIC_SECTIONS,
|
|
1175
|
+
"--permission-mode",
|
|
1176
|
+
spec.permissionMode,
|
|
1177
|
+
"--json-schema",
|
|
1178
|
+
JSON.stringify(spec.reportSchema),
|
|
1179
|
+
"--mcp-config",
|
|
1180
|
+
JSON.stringify(mcpConfig),
|
|
1181
|
+
"--permission-prompt-tool",
|
|
1182
|
+
toolId("permission"),
|
|
1183
|
+
"--allowedTools",
|
|
1184
|
+
[...spec.tools.allow, ...allowed].join(",")
|
|
1185
|
+
];
|
|
1186
|
+
if (spec.tools.only !== void 0) args.push("--tools", spec.tools.only.join(","));
|
|
1187
|
+
if (strictMcpOf(spec)) args.push("--strict-mcp-config");
|
|
1188
|
+
if (spec.agents !== void 0) args.push("--agents", JSON.stringify(spec.agents));
|
|
1189
|
+
if (spec.tools.deny.length > 0) args.push("--disallowedTools", spec.tools.deny.join(","));
|
|
1190
|
+
if (spec.model !== void 0) args.push("--model", spec.model);
|
|
1191
|
+
if (spec.effort !== void 0) args.push("--effort", spec.effort);
|
|
1192
|
+
if (spec.maxTurns !== void 0) args.push("--max-turns", String(spec.maxTurns));
|
|
1193
|
+
if (spec.budgetUsd !== void 0) args.push("--max-budget-usd", String(spec.budgetUsd));
|
|
1194
|
+
if (spec.resume !== void 0) args.push("--resume", spec.resume);
|
|
1195
|
+
if (spec.resume !== void 0 && spec.fork === true) args.push("--fork-session");
|
|
1196
|
+
for (const dir of spec.plugins ?? []) args.push("--plugin-dir", dir);
|
|
1197
|
+
const settings = mcp.holdUrl === void 0 ? spec.settings : withHoldHook(spec.settings, mcp.holdUrl, mcp.token);
|
|
1198
|
+
if (settings !== void 0) args.push("--settings", JSON.stringify(settings));
|
|
1199
|
+
return args;
|
|
1200
|
+
};
|
|
1201
|
+
/** `claude`, as the session runner's strategies. */
|
|
1202
|
+
const claudeCodeAdapter = {
|
|
1203
|
+
driver: "claude-code",
|
|
1204
|
+
command: {
|
|
1205
|
+
bin: "claude",
|
|
1206
|
+
args: (spec, leg) => buildCliArgs(spec, leg.endpoint, leg.allowed),
|
|
1207
|
+
input: userLine
|
|
1208
|
+
},
|
|
1209
|
+
decode: decodeClaudeLine,
|
|
1210
|
+
mcp: {
|
|
1211
|
+
kind: "flag",
|
|
1212
|
+
wire: () => Promise.resolve({
|
|
1213
|
+
args: [],
|
|
1214
|
+
env: {},
|
|
1215
|
+
release: () => Promise.resolve()
|
|
1216
|
+
})
|
|
1217
|
+
},
|
|
1218
|
+
permissions: {
|
|
1219
|
+
kind: "callback",
|
|
1220
|
+
denyByDefault: false
|
|
1221
|
+
},
|
|
1222
|
+
usage: claudeUsage,
|
|
1223
|
+
context: {
|
|
1224
|
+
instructions: ["CLAUDE.md", "AGENTS.md"],
|
|
1225
|
+
skills: ".claude/skills",
|
|
1226
|
+
agents: ".claude/agents",
|
|
1227
|
+
env: driverEnv
|
|
1228
|
+
},
|
|
1229
|
+
hold: claudeHold
|
|
1230
|
+
};
|
|
1231
|
+
// ../runtime/src/lease-ledger.ts
|
|
1232
|
+
/**
|
|
1233
|
+
* @file The ledger of Command Code leases (RFC-014, `lease.ts`): one record per
|
|
1234
|
+
* checkout in a folder of the project's runs folder, naming the session's
|
|
1235
|
+
* process and its `ork` entry, and the sweep that removes the entry of every
|
|
1236
|
+
* record whose process is gone (the next session's start, and `ork doctor`).
|
|
1237
|
+
* Only the `ork` entry is touched; the user's other entries stay as they
|
|
1238
|
+
* were.
|
|
1239
|
+
*/
|
|
1240
|
+
/** One session's hold on a checkout. `pid` 0: its entry could not be removed. */
|
|
1241
|
+
const LeaseSchema = z.object({
|
|
1242
|
+
id: z.string(),
|
|
1243
|
+
folder: z.string(),
|
|
1244
|
+
pid: z.number(),
|
|
1245
|
+
entry: z.string()
|
|
1246
|
+
});
|
|
1247
|
+
/** A local-scope file: only `mcpServers` is read; the rest is kept as is. */
|
|
1248
|
+
const LocalScopeSchema = z.looseObject({ mcpServers: z.record(z.string(), z.unknown()).optional() });
|
|
1249
|
+
const codeOf = (error) => error instanceof Error && "code" in error ? error.code : void 0;
|
|
1250
|
+
const CMD_TIMEOUT_MS = 3e4;
|
|
1251
|
+
/** Where a project's lease records live, inside its runs folder. */
|
|
1252
|
+
const leaseLedger = (runsDir) => path.join(runsDir, ".leases");
|
|
1253
|
+
const alive = (pid) => {
|
|
1254
|
+
if (pid <= 0) return false;
|
|
1255
|
+
try {
|
|
1256
|
+
process.kill(pid, 0);
|
|
1257
|
+
return true;
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
return codeOf(error) === "EPERM";
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
const readLease = (file) => {
|
|
1263
|
+
try {
|
|
1264
|
+
return LeaseSchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
1265
|
+
} catch {
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
const runCmd = (options, args, cwd) => new Promise((resolve) => {
|
|
1270
|
+
execFile(options.bin, args, {
|
|
1271
|
+
cwd,
|
|
1272
|
+
env: { ...options.env ?? process.env },
|
|
1273
|
+
encoding: "utf8",
|
|
1274
|
+
timeout: CMD_TIMEOUT_MS
|
|
1275
|
+
}, (error, stdout, stderr) => {
|
|
1276
|
+
resolve({
|
|
1277
|
+
ok: error === null,
|
|
1278
|
+
out: `${stdout}${stderr}`.trim()
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
/** Drops the `ork` entry from a local-scope file whose folder is gone. */
|
|
1283
|
+
const dropFromFile = (entry) => {
|
|
1284
|
+
if (!existsSync(entry)) return true;
|
|
1285
|
+
try {
|
|
1286
|
+
const config = LocalScopeSchema.parse(JSON.parse(readFileSync(entry, "utf8")));
|
|
1287
|
+
const { ["ork"]: ours, ...others } = config.mcpServers ?? {};
|
|
1288
|
+
if (ours === void 0) return true;
|
|
1289
|
+
const kept = {
|
|
1290
|
+
...config,
|
|
1291
|
+
mcpServers: others
|
|
1292
|
+
};
|
|
1293
|
+
writeFileSync(entry, `${JSON.stringify(kept, null, 2)}\n`, { mode: 384 });
|
|
1294
|
+
return true;
|
|
1295
|
+
} catch {
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
/** Removes a lease's `ork` entry; true once it is gone. */
|
|
1300
|
+
const removeEntry = async (options, lease) => {
|
|
1301
|
+
if (!existsSync(lease.folder)) return dropFromFile(lease.entry);
|
|
1302
|
+
const removed = await runCmd(options, [
|
|
1303
|
+
"mcp",
|
|
1304
|
+
"remove",
|
|
1305
|
+
"--scope",
|
|
1306
|
+
"local",
|
|
1307
|
+
"ork"
|
|
1308
|
+
], lease.folder);
|
|
1309
|
+
return removed.ok || removed.out.includes("not found");
|
|
1310
|
+
};
|
|
1311
|
+
/** Deletes a record if it is still `lease`'s, not a newer one's. */
|
|
1312
|
+
const dropRecord = (file, lease) => {
|
|
1313
|
+
if (readLease(file)?.id === lease.id) rmSync(file, { force: true });
|
|
1314
|
+
};
|
|
1315
|
+
/**
|
|
1316
|
+
* Deletes a record that exists but can't be read: records are written whole (a
|
|
1317
|
+
* link, a rename), so one that does not parse is corrupt, and stale.
|
|
1318
|
+
*/
|
|
1319
|
+
const dropIfCorrupt = (file) => {
|
|
1320
|
+
let text;
|
|
1321
|
+
try {
|
|
1322
|
+
text = readFileSync(file, "utf8");
|
|
1323
|
+
} catch {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
try {
|
|
1327
|
+
LeaseSchema.parse(JSON.parse(text));
|
|
1328
|
+
} catch {
|
|
1329
|
+
rmSync(file, { force: true });
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
/**
|
|
1333
|
+
* Removes the `ork` entry of every lease whose session is gone (a crash, or a
|
|
1334
|
+
* removal that failed), and its record. Live leases are left alone.
|
|
1335
|
+
*/
|
|
1336
|
+
const cleanStaleLeases = async (options) => {
|
|
1337
|
+
const sweep = {
|
|
1338
|
+
cleaned: [],
|
|
1339
|
+
failed: []
|
|
1340
|
+
};
|
|
1341
|
+
if (!existsSync(options.ledger)) return sweep;
|
|
1342
|
+
for (const name of readdirSync(options.ledger)) {
|
|
1343
|
+
if (!name.endsWith(".json")) continue;
|
|
1344
|
+
const file = path.join(options.ledger, name);
|
|
1345
|
+
const lease = readLease(file);
|
|
1346
|
+
if (lease === void 0) dropIfCorrupt(file);
|
|
1347
|
+
if (lease === void 0 || alive(lease.pid)) continue;
|
|
1348
|
+
if (await removeEntry(options, lease)) {
|
|
1349
|
+
dropRecord(file, lease);
|
|
1350
|
+
sweep.cleaned.push(lease.folder);
|
|
1351
|
+
} else sweep.failed.push(lease.folder);
|
|
1352
|
+
}
|
|
1353
|
+
return sweep;
|
|
1354
|
+
};
|
|
1355
|
+
// ../runtime/src/lease.ts
|
|
1356
|
+
/**
|
|
1357
|
+
* @file The `lease` kind of `McpWiring` (RFC-014): Command Code reads MCP
|
|
1358
|
+
* servers from a per-folder local scope (`~/.commandcode/projects/<slug>/`),
|
|
1359
|
+
* so a session gets Ork's endpoint through an `ork` entry added there with
|
|
1360
|
+
* `cmd mcp add --scope local` and removed with `cmd mcp remove` after it.
|
|
1361
|
+
* Because the entry is per folder, a checkout holds one session at a time: a
|
|
1362
|
+
* record in the ledger (`lease-ledger.ts`) is the lock, and a second session
|
|
1363
|
+
* waits for it, and says so.
|
|
1364
|
+
*/
|
|
1365
|
+
/**
|
|
1366
|
+
* Command Code's folder name for a checkout, as measured on 1.88.5: its real
|
|
1367
|
+
* path with every character but letters and digits made `-`, the leading one
|
|
1368
|
+
* dropped (`/private/tmp/a b` -> `private-tmp-a-b`). Leases key on it, so two
|
|
1369
|
+
* folders Command Code would share also share one lease.
|
|
1370
|
+
*/
|
|
1371
|
+
const slugOf = (folder) => realpathSync(folder).replaceAll(/[^a-zA-Z0-9]/gu, "-").replace(/^-+/u, "");
|
|
1372
|
+
const entryFileOf = (env, slug) => path.join(env["HOME"] ?? homedir(), ".commandcode", "projects", slug, "mcp.json");
|
|
1373
|
+
/** Takes the record for `slug`, or returns the live lease that holds it. */
|
|
1374
|
+
const tryTake = (file, lease) => {
|
|
1375
|
+
const draft = `${file}.${lease.id}`;
|
|
1376
|
+
writeFileSync(draft, JSON.stringify(lease), { mode: 384 });
|
|
1377
|
+
try {
|
|
1378
|
+
linkSync(draft, file);
|
|
1379
|
+
return;
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
if (codeOf(error) !== "EEXIST") throw error;
|
|
1382
|
+
const holder = readLease(file);
|
|
1383
|
+
if (holder !== void 0) return holder;
|
|
1384
|
+
dropIfCorrupt(file);
|
|
1385
|
+
return {
|
|
1386
|
+
...lease,
|
|
1387
|
+
pid: 0
|
|
1388
|
+
};
|
|
1389
|
+
} finally {
|
|
1390
|
+
rmSync(draft, { force: true });
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
const masked = (text, endpoint) => maskSecrets(text.replaceAll(endpoint.token, MASK));
|
|
1394
|
+
/**
|
|
1395
|
+
* The lease's options: those given, else the runner's program, environment and
|
|
1396
|
+
* the project's runs folder (the ledger lives there, where `ork doctor`
|
|
1397
|
+
* looks).
|
|
1398
|
+
*/
|
|
1399
|
+
const optionsOf = (given, hooks) => {
|
|
1400
|
+
const ledger = given.ledger ?? (hooks.runs === void 0 ? void 0 : leaseLedger(hooks.runs));
|
|
1401
|
+
if (ledger === void 0) throw new Error("a Command Code session needs the project's runs folder for its lease");
|
|
1402
|
+
const env = given.env ?? hooks.env;
|
|
1403
|
+
return {
|
|
1404
|
+
...given,
|
|
1405
|
+
bin: given.bin ?? hooks.bin ?? "cmd",
|
|
1406
|
+
ledger,
|
|
1407
|
+
...env === void 0 ? {} : { env }
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
/**
|
|
1411
|
+
* Ork's endpoint for a Command Code session, through an `ork` entry in the
|
|
1412
|
+
* checkout's local scope, held for the session. A second session in the same
|
|
1413
|
+
* checkout waits for the first, and says so.
|
|
1414
|
+
*/
|
|
1415
|
+
const leaseWiring = (given) => ({
|
|
1416
|
+
kind: "lease",
|
|
1417
|
+
wire: async (spec, endpoint, hooks) => {
|
|
1418
|
+
const options = optionsOf(given, hooks);
|
|
1419
|
+
const env = options.env ?? process.env;
|
|
1420
|
+
const slug = slugOf(spec.cwd);
|
|
1421
|
+
const file = path.join(options.ledger, `${slug}.json`);
|
|
1422
|
+
const lease = {
|
|
1423
|
+
id: randomUUID(),
|
|
1424
|
+
folder: realpathSync(spec.cwd),
|
|
1425
|
+
pid: process.pid,
|
|
1426
|
+
entry: entryFileOf(env, slug)
|
|
1427
|
+
};
|
|
1428
|
+
const fail = (message) => {
|
|
1429
|
+
dropRecord(file, lease);
|
|
1430
|
+
throw new Error(masked(message, endpoint));
|
|
1431
|
+
};
|
|
1432
|
+
mkdirSync(options.ledger, {
|
|
1433
|
+
recursive: true,
|
|
1434
|
+
mode: 448
|
|
1435
|
+
});
|
|
1436
|
+
await cleanStaleLeases(options);
|
|
1437
|
+
let told = false;
|
|
1438
|
+
for (;;) {
|
|
1439
|
+
const holder = tryTake(file, lease);
|
|
1440
|
+
if (holder === void 0) break;
|
|
1441
|
+
if (alive(holder.pid)) {
|
|
1442
|
+
if (!told) {
|
|
1443
|
+
hooks.waiting(`waiting for ${lease.folder}: Command Code runs one session per checkout`);
|
|
1444
|
+
told = true;
|
|
1445
|
+
}
|
|
1446
|
+
await setTimeout$1(options.pollMs ?? 1e3, void 0, { signal: hooks.signal });
|
|
1447
|
+
} else {
|
|
1448
|
+
const { failed } = await cleanStaleLeases(options);
|
|
1449
|
+
if (failed.includes(holder.folder)) fail(`could not remove the \`ork\` MCP entry a stopped session left in ${holder.folder}; run \`ork doctor\``);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const existing = await runCmd(options, [
|
|
1453
|
+
"mcp",
|
|
1454
|
+
"get",
|
|
1455
|
+
"ork"
|
|
1456
|
+
], lease.folder);
|
|
1457
|
+
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`);
|
|
1458
|
+
const added = await runCmd(options, [
|
|
1459
|
+
"mcp",
|
|
1460
|
+
"add",
|
|
1461
|
+
"--transport",
|
|
1462
|
+
"http",
|
|
1463
|
+
"--scope",
|
|
1464
|
+
"local",
|
|
1465
|
+
"ork",
|
|
1466
|
+
endpoint.url,
|
|
1467
|
+
"--header",
|
|
1468
|
+
`Authorization: Bearer ${endpoint.token}`
|
|
1469
|
+
], lease.folder);
|
|
1470
|
+
if (!added.ok) fail(`cmd mcp add failed: ${added.out}`);
|
|
1471
|
+
return {
|
|
1472
|
+
args: [],
|
|
1473
|
+
env: {},
|
|
1474
|
+
release: async () => {
|
|
1475
|
+
if (await removeEntry(options, lease)) {
|
|
1476
|
+
dropRecord(file, lease);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const draft = `${file}.${lease.id}`;
|
|
1480
|
+
writeFileSync(draft, JSON.stringify({
|
|
1481
|
+
...lease,
|
|
1482
|
+
pid: 0
|
|
1483
|
+
}), { mode: 384 });
|
|
1484
|
+
renameSync(draft, file);
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
// ../runtime/src/adapters/command-code-events.ts
|
|
1490
|
+
const isObject$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1491
|
+
const num$1 = (value) => typeof value === "number" ? value : 0;
|
|
1492
|
+
const str$1 = (value) => typeof value === "string" ? value : "";
|
|
1493
|
+
/** Command Code's own tool names, as Ork (and Claude Code) names them. */
|
|
1494
|
+
const COMMAND_CODE_TOOLS = {
|
|
1495
|
+
shell: "Bash",
|
|
1496
|
+
shell_command: "Bash",
|
|
1497
|
+
read_file: "Read",
|
|
1498
|
+
read_multiple_files: "Read",
|
|
1499
|
+
read_directory: "Read",
|
|
1500
|
+
glob: "Glob",
|
|
1501
|
+
grep: "Grep",
|
|
1502
|
+
edit_file: "Edit",
|
|
1503
|
+
write_file: "Write",
|
|
1504
|
+
web_fetch: "WebFetch",
|
|
1505
|
+
web_search: "WebSearch",
|
|
1506
|
+
agent: "Task"
|
|
1507
|
+
};
|
|
1508
|
+
/** A tool's name as Ork shows it; MCP and unknown names stay as they are. */
|
|
1509
|
+
const orkToolName = (name) => COMMAND_CODE_TOOLS[name] ?? name;
|
|
1510
|
+
/** The result line's subtypes, as Ork's outcome names them. */
|
|
1511
|
+
const SUBTYPES = {
|
|
1512
|
+
success: "success",
|
|
1513
|
+
max_turns: "error_max_turns",
|
|
1514
|
+
error: "error_during_execution"
|
|
1515
|
+
};
|
|
1516
|
+
/** The text blocks of a `message_end` frame's content. */
|
|
1517
|
+
const textOf = (content) => (Array.isArray(content) ? content : []).filter((item) => isObject$1(item)).filter((block) => block["type"] === "text").map((block) => str$1(block["text"])).join("");
|
|
1518
|
+
/** The frames that end a tool call as an error: failed, denied, or blocked. */
|
|
1519
|
+
const REFUSED = /* @__PURE__ */ new Set([
|
|
1520
|
+
"tool_errored",
|
|
1521
|
+
"tool_denied",
|
|
1522
|
+
"tool_hook_blocked"
|
|
1523
|
+
]);
|
|
1524
|
+
const parseLine$1 = (line) => {
|
|
1525
|
+
try {
|
|
1526
|
+
return JSON.parse(line);
|
|
1527
|
+
} catch {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
/** A decoder for one leg of a Command Code session. */
|
|
1532
|
+
const commandCodeDecoder = () => {
|
|
1533
|
+
let sessionId = "";
|
|
1534
|
+
let turns = 0;
|
|
1535
|
+
const frame = (event) => {
|
|
1536
|
+
const type = event["type"];
|
|
1537
|
+
const id = str$1(event["toolCallId"]);
|
|
1538
|
+
const name = orkToolName(str$1(event["toolName"]));
|
|
1539
|
+
if (type === "run_start") {
|
|
1540
|
+
sessionId = str$1(event["sessionId"]);
|
|
1541
|
+
return [{
|
|
1542
|
+
type: "session.started",
|
|
1543
|
+
sessionId,
|
|
1544
|
+
model: ""
|
|
1545
|
+
}];
|
|
1546
|
+
}
|
|
1547
|
+
if (type === "turn_start") turns += 1;
|
|
1548
|
+
if (type === "message_end") {
|
|
1549
|
+
const text = textOf(event["content"]);
|
|
1550
|
+
return text.trim() === "" ? [] : [{
|
|
1551
|
+
type: "assistant.text",
|
|
1552
|
+
text
|
|
1553
|
+
}];
|
|
1554
|
+
}
|
|
1555
|
+
if (type === "tool_queued") {
|
|
1556
|
+
const input = event["input"];
|
|
1557
|
+
return [{
|
|
1558
|
+
type: "tool.called",
|
|
1559
|
+
id,
|
|
1560
|
+
name,
|
|
1561
|
+
input: isObject$1(input) ? input : {}
|
|
1562
|
+
}];
|
|
1563
|
+
}
|
|
1564
|
+
if (type === "tool_completed") return [{
|
|
1565
|
+
type: "tool.result",
|
|
1566
|
+
id,
|
|
1567
|
+
isError: false
|
|
1568
|
+
}];
|
|
1569
|
+
if (typeof type === "string" && REFUSED.has(type)) return [{
|
|
1570
|
+
type: "tool.result",
|
|
1571
|
+
id,
|
|
1572
|
+
isError: true
|
|
1573
|
+
}];
|
|
1574
|
+
return [];
|
|
1575
|
+
};
|
|
1576
|
+
const result = (line) => {
|
|
1577
|
+
const usage = isObject$1(line["usage"]) ? line["usage"] : {};
|
|
1578
|
+
const error = str$1(line["error"]);
|
|
1579
|
+
return {
|
|
1580
|
+
type: "session.finished",
|
|
1581
|
+
outcome: {
|
|
1582
|
+
subtype: SUBTYPES[str$1(line["subtype"])] ?? "error_during_execution",
|
|
1583
|
+
sessionId: str$1(line["sessionId"]) || sessionId,
|
|
1584
|
+
report: null,
|
|
1585
|
+
text: str$1(line["finalText"]),
|
|
1586
|
+
usage: {
|
|
1587
|
+
costUsd: 0,
|
|
1588
|
+
inputTokens: num$1(usage["inputTokens"]),
|
|
1589
|
+
outputTokens: num$1(usage["outputTokens"]),
|
|
1590
|
+
cacheReadTokens: num$1(usage["cacheReadTokens"]),
|
|
1591
|
+
cacheWriteTokens: num$1(usage["cacheWriteTokens"]),
|
|
1592
|
+
turns,
|
|
1593
|
+
durationMs: num$1(line["durationMs"])
|
|
1594
|
+
},
|
|
1595
|
+
errors: error === "" ? [] : [error]
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
};
|
|
1599
|
+
return (line) => {
|
|
1600
|
+
const message = parseLine$1(line);
|
|
1601
|
+
if (!isObject$1(message)) return { events: [] };
|
|
1602
|
+
if (message["type"] === "result") return { events: [result(message)] };
|
|
1603
|
+
const event = message["event"];
|
|
1604
|
+
return { events: message["type"] === "event" && isObject$1(event) ? frame(event) : [] };
|
|
1605
|
+
};
|
|
1606
|
+
};
|
|
1607
|
+
/** Command Code reports tokens, never dollars. */
|
|
1608
|
+
const commandCodeUsage = {
|
|
1609
|
+
dollars: false,
|
|
1610
|
+
combine: (a, b) => addUsage(a, b)
|
|
1611
|
+
};
|
|
1612
|
+
// ../runtime/src/adapters/command-code-mod.ts
|
|
1613
|
+
/**
|
|
1614
|
+
* @file The Command Code mod that holds a session to its node's tool rules
|
|
1615
|
+
* (RFC-014, deny by default). Command Code takes permission rules only from
|
|
1616
|
+
* settings files (the user's, and two in the checkout), so Ork runs it with
|
|
1617
|
+
* `--permission-mode yolo` and loads, with `--mod`, a mod written for the
|
|
1618
|
+
* session outside the checkout: its `beforeToolCall` hook blocks every call
|
|
1619
|
+
* the node did not allow. Ork's own tools (`mcp__ork__*`) always go through.
|
|
1620
|
+
* The rules are the node's, in Claude Code's words (`Bash(git:*)`, `Read`,
|
|
1621
|
+
* `mcp__github`); a specifier Ork does not read never allows a call and
|
|
1622
|
+
* always denies one.
|
|
1623
|
+
*/
|
|
1624
|
+
/** The rules of a session: an `acceptEdits` node may edit without a rule. */
|
|
1625
|
+
const modRules = (spec) => ({
|
|
1626
|
+
allow: [...spec.tools.allow, ...spec.permissionMode === "acceptEdits" ? ["Edit"] : []],
|
|
1627
|
+
deny: spec.tools.deny,
|
|
1628
|
+
...spec.tools.only === void 0 ? {} : { only: spec.tools.only }
|
|
1629
|
+
});
|
|
1630
|
+
/**
|
|
1631
|
+
* The mod's source. It is plain JavaScript that Command Code loads (a default
|
|
1632
|
+
* export taking the mod API), so it carries its own matcher.
|
|
1633
|
+
*/
|
|
1634
|
+
const modSource = (rules) => `// Ork's rules for one session; written by Ork, removed after it.
|
|
1635
|
+
const RULES = ${JSON.stringify(rules)};
|
|
1636
|
+
const TOOLS = ${JSON.stringify(COMMAND_CODE_TOOLS)};
|
|
1637
|
+
// A rule's word, as Command Code reads it, covers these of Ork's tool names.
|
|
1638
|
+
const COVERS = { read: ["Read", "Glob", "Grep"], edit: ["Edit", "Write"] };
|
|
1639
|
+
const norm = text => text.trim().replace(/\\s+/g, " ");
|
|
1640
|
+
const split = rule => {
|
|
1641
|
+
const at = rule.indexOf("(");
|
|
1642
|
+
return at < 0 || !rule.endsWith(")")
|
|
1643
|
+
? { token: rule.trim() }
|
|
1644
|
+
: { token: rule.slice(0, at).trim(), spec: rule.slice(at + 1, -1) };
|
|
1645
|
+
};
|
|
1646
|
+
const shellMatches = (command, pattern) => {
|
|
1647
|
+
let p = norm(pattern);
|
|
1648
|
+
const c = norm(command);
|
|
1649
|
+
if (p.endsWith(":*")) p = p.slice(0, -2) + " *";
|
|
1650
|
+
if (!p.endsWith(" *")) return c === p;
|
|
1651
|
+
const head = p.slice(0, -2);
|
|
1652
|
+
return c === head || c.startsWith(head + " ");
|
|
1653
|
+
};
|
|
1654
|
+
// true: the rule covers the call; false: it does not; null: Ork can't tell.
|
|
1655
|
+
const matches = (rule, name, input) => {
|
|
1656
|
+
const { token, spec } = split(rule);
|
|
1657
|
+
if (token.startsWith("mcp__")) {
|
|
1658
|
+
const rest = token.slice(5);
|
|
1659
|
+
const at = rest.indexOf("__");
|
|
1660
|
+
if (at < 0) return name.startsWith("mcp__" + rest + "__");
|
|
1661
|
+
const tool = rest.slice(at + 2);
|
|
1662
|
+
return tool === "*" ? name.startsWith("mcp__" + rest.slice(0, at) + "__") : name === token;
|
|
1663
|
+
}
|
|
1664
|
+
const ork = TOOLS[name] ?? name;
|
|
1665
|
+
const word = token.toLowerCase();
|
|
1666
|
+
const covered = (COVERS[word] ?? [token]).some(each => each.toLowerCase() === ork.toLowerCase());
|
|
1667
|
+
if (!covered && name !== token) return false;
|
|
1668
|
+
if (spec === undefined || spec === "" || spec === "*") return true;
|
|
1669
|
+
if (ork === "Bash" && typeof input?.command === "string") return shellMatches(input.command, spec);
|
|
1670
|
+
return null;
|
|
1671
|
+
};
|
|
1672
|
+
export const decide = (name, input) => {
|
|
1673
|
+
if (RULES.deny.some(rule => matches(rule, name, input) !== false)) return "denied for this node";
|
|
1674
|
+
if (name.startsWith("mcp__ork__")) return undefined;
|
|
1675
|
+
if (RULES.only !== undefined && !RULES.only.some(rule => matches(rule, name, input) === true)) {
|
|
1676
|
+
return "not one of this node's tools";
|
|
1677
|
+
}
|
|
1678
|
+
if (RULES.allow.some(rule => matches(rule, name, input) === true)) return undefined;
|
|
1679
|
+
return "not allowed for this node";
|
|
1680
|
+
};
|
|
1681
|
+
export default cc => {
|
|
1682
|
+
cc.hooks({
|
|
1683
|
+
beforeToolCall: async call => {
|
|
1684
|
+
const why = decide(call.toolName, call.input);
|
|
1685
|
+
return why === undefined
|
|
1686
|
+
? undefined
|
|
1687
|
+
: { block: true, additionalContext: "Ork: " + call.toolName + " is " + why + ". Do not retry it." };
|
|
1688
|
+
},
|
|
1689
|
+
});
|
|
1690
|
+
};
|
|
1691
|
+
`;
|
|
1692
|
+
/**
|
|
1693
|
+
* Writes a session's mod outside the checkout (a fresh folder under the
|
|
1694
|
+
* system's temp folder, 0700, the file 0600); it holds no secret.
|
|
1695
|
+
*/
|
|
1696
|
+
const writeMod = (spec) => {
|
|
1697
|
+
const dir = mkdtempSync(path.join(tmpdir(), "ork-cmd-mod-"));
|
|
1698
|
+
const file = path.join(dir, "ork-rules.mjs");
|
|
1699
|
+
writeFileSync(file, modSource(modRules(spec)), { mode: 384 });
|
|
1700
|
+
return {
|
|
1701
|
+
file,
|
|
1702
|
+
remove: () => {
|
|
1703
|
+
rmSync(dir, {
|
|
1704
|
+
recursive: true,
|
|
1705
|
+
force: true
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
};
|
|
1710
|
+
// ../runtime/src/adapters/command-code.ts
|
|
1711
|
+
/**
|
|
1712
|
+
* The brief, with Ork's instructions before it on a session's first leg:
|
|
1713
|
+
* Command Code has no flag that adds to its system prompt. A resumed leg's
|
|
1714
|
+
* session already holds them.
|
|
1715
|
+
*/
|
|
1716
|
+
const queryOf = (spec) => {
|
|
1717
|
+
const text = spec.resume === void 0 ? `${spec.systemAppend}\n\n${spec.prompt}` : spec.prompt;
|
|
1718
|
+
return text.startsWith("-") ? ` ${text}` : text;
|
|
1719
|
+
};
|
|
1720
|
+
/**
|
|
1721
|
+
* The `cmd` arguments for a session spec. `yolo`: Command Code prompts for
|
|
1722
|
+
* nothing, and the session's mod refuses what the node did not allow.
|
|
1723
|
+
*/
|
|
1724
|
+
const commandCodeArgs = (spec) => {
|
|
1725
|
+
const args = [
|
|
1726
|
+
"-p",
|
|
1727
|
+
queryOf(spec),
|
|
1728
|
+
"--output-format",
|
|
1729
|
+
"json",
|
|
1730
|
+
"--permission-mode",
|
|
1731
|
+
"yolo",
|
|
1732
|
+
"--skip-onboarding",
|
|
1733
|
+
"--no-auto-update"
|
|
1734
|
+
];
|
|
1735
|
+
if (spec.model !== void 0) args.push("--model", spec.model);
|
|
1736
|
+
if (spec.effort !== void 0) args.push("--effort", spec.effort);
|
|
1737
|
+
if (spec.maxTurns !== void 0) args.push("--max-turns", String(spec.maxTurns));
|
|
1738
|
+
if (spec.resume !== void 0) args.push("--resume", spec.resume);
|
|
1739
|
+
if (spec.resume !== void 0 && spec.fork === true) args.push("--fork-session");
|
|
1740
|
+
return args;
|
|
1741
|
+
};
|
|
1742
|
+
const lease = leaseWiring({});
|
|
1743
|
+
/** `cmd`, as the session runner's strategies. */
|
|
1744
|
+
const commandCodeAdapter = {
|
|
1745
|
+
driver: "command-code",
|
|
1746
|
+
command: {
|
|
1747
|
+
bin: "cmd",
|
|
1748
|
+
args: (spec) => commandCodeArgs(spec)
|
|
1749
|
+
},
|
|
1750
|
+
decode: (line) => commandCodeDecoder()(line),
|
|
1751
|
+
decoder: commandCodeDecoder,
|
|
1752
|
+
mcp: {
|
|
1753
|
+
kind: "lease",
|
|
1754
|
+
wire: async (spec, endpoint, hooks) => {
|
|
1755
|
+
const wired = await lease.wire(spec, endpoint, hooks);
|
|
1756
|
+
try {
|
|
1757
|
+
const mod = writeMod(spec);
|
|
1758
|
+
return {
|
|
1759
|
+
args: [
|
|
1760
|
+
...wired.args,
|
|
1761
|
+
"--mod",
|
|
1762
|
+
mod.file
|
|
1763
|
+
],
|
|
1764
|
+
env: wired.env,
|
|
1765
|
+
release: async () => {
|
|
1766
|
+
mod.remove();
|
|
1767
|
+
await wired.release();
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
await wired.release();
|
|
1772
|
+
throw error;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
},
|
|
1776
|
+
permissions: {
|
|
1777
|
+
kind: "rules",
|
|
1778
|
+
denyByDefault: true
|
|
1779
|
+
},
|
|
1780
|
+
usage: commandCodeUsage,
|
|
1781
|
+
context: {
|
|
1782
|
+
instructions: ["AGENTS.md"],
|
|
1783
|
+
skills: ".commandcode/skills",
|
|
1784
|
+
agents: ".commandcode/agents",
|
|
1785
|
+
env: () => ({})
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
// ../runtime/src/secret-file.ts
|
|
1789
|
+
/**
|
|
1790
|
+
* @file A session's private folder for files that hold a secret, such as a
|
|
1791
|
+
* config carrying Ork's bearer token (RFC-014): a 0700 folder outside the
|
|
1792
|
+
* checkout, under the system's temporary folder, named after the process that
|
|
1793
|
+
* made it, with each file written at mode 0600. The session removes it when
|
|
1794
|
+
* it ends; a folder whose process is gone (a crash) is removed when the next
|
|
1795
|
+
* one is made. Nothing here logs what it writes.
|
|
1796
|
+
*/
|
|
1797
|
+
/** Where sessions' private folders live unless a caller names another place. */
|
|
1798
|
+
const secretRoot = () => path.join(tmpdir(), "ork-sessions");
|
|
1799
|
+
/** Removes the folders under `root` whose process is gone; returns them. */
|
|
1800
|
+
const sweepSecretFolders = (root) => {
|
|
1801
|
+
let names;
|
|
1802
|
+
try {
|
|
1803
|
+
names = readdirSync(root);
|
|
1804
|
+
} catch {
|
|
1805
|
+
return [];
|
|
1806
|
+
}
|
|
1807
|
+
return names.flatMap((name) => {
|
|
1808
|
+
const pid = Number(name.split("-")[0]);
|
|
1809
|
+
if (!Number.isInteger(pid) || alive(pid)) return [];
|
|
1810
|
+
const dir = path.join(root, name);
|
|
1811
|
+
rmSync(dir, {
|
|
1812
|
+
recursive: true,
|
|
1813
|
+
force: true
|
|
1814
|
+
});
|
|
1815
|
+
return [dir];
|
|
1816
|
+
});
|
|
1817
|
+
};
|
|
1818
|
+
/** A new private folder under `root`, after sweeping the stale ones. */
|
|
1819
|
+
const makeSecretFolder = (root = secretRoot()) => {
|
|
1820
|
+
mkdirSync(root, {
|
|
1821
|
+
recursive: true,
|
|
1822
|
+
mode: 448
|
|
1823
|
+
});
|
|
1824
|
+
sweepSecretFolders(root);
|
|
1825
|
+
const dir = mkdtempSync(path.join(root, `${String(process.pid)}-`));
|
|
1826
|
+
return {
|
|
1827
|
+
dir,
|
|
1828
|
+
write: (name, content) => {
|
|
1829
|
+
const file = path.join(dir, name);
|
|
1830
|
+
writeFileSync(file, content, { mode: 384 });
|
|
1831
|
+
return file;
|
|
1832
|
+
},
|
|
1833
|
+
remove: () => {
|
|
1834
|
+
rmSync(dir, {
|
|
1835
|
+
recursive: true,
|
|
1836
|
+
force: true
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
};
|
|
1841
|
+
/** Claude Code tool names, as OpenCode's permission actions. */
|
|
1842
|
+
const ACTIONS = {
|
|
1843
|
+
Read: "read",
|
|
1844
|
+
Glob: "glob",
|
|
1845
|
+
Grep: "grep",
|
|
1846
|
+
Edit: "edit",
|
|
1847
|
+
Write: "edit",
|
|
1848
|
+
MultiEdit: "edit",
|
|
1849
|
+
NotebookEdit: "edit",
|
|
1850
|
+
Bash: "shell",
|
|
1851
|
+
WebFetch: "webfetch",
|
|
1852
|
+
WebSearch: "websearch",
|
|
1853
|
+
Task: "subagent",
|
|
1854
|
+
Agent: "subagent",
|
|
1855
|
+
Skill: "skill",
|
|
1856
|
+
TodoWrite: "todowrite"
|
|
1857
|
+
};
|
|
1858
|
+
const BUILT_IN = new Set(Object.values(ACTIONS));
|
|
1859
|
+
/** Actions whose resource is a path: a bare allow stays in the worktree. */
|
|
1860
|
+
const PATH_ACTIONS = /* @__PURE__ */ new Set(["read", "edit"]);
|
|
1861
|
+
/** OpenCode's name for an MCP server's tool: other characters become `_`. */
|
|
1862
|
+
const mcpAction = (server, tool) => `${server}_${tool}`.replaceAll(/[^A-Za-z0-9_*-]/gu, "_");
|
|
1863
|
+
/** A shell rule's resource: Claude Code's `git status:*` is `git status *`. */
|
|
1864
|
+
const shellResource = (spec) => spec.replace(/:\*$/u, " *");
|
|
1865
|
+
/**
|
|
1866
|
+
* One Claude Code rule (`Bash(git status:*)`, `Edit`, `mcp__github__*`) as an
|
|
1867
|
+
* OpenCode rule, or none for a tool OpenCode has no action for (still denied).
|
|
1868
|
+
*/
|
|
1869
|
+
const ruleOf = (rule, effect, cwd) => {
|
|
1870
|
+
const match = /^([^()]+)(?:\((.*)\))?$/su.exec(rule.trim());
|
|
1871
|
+
if (match === null) return void 0;
|
|
1872
|
+
const [, name = "", spec] = match;
|
|
1873
|
+
const mcp = /^mcp__([^_]+(?:_[^_]+)*)(?:__(.*))?$/u.exec(name);
|
|
1874
|
+
if (mcp !== null) {
|
|
1875
|
+
const [, server = "", tool] = mcp;
|
|
1876
|
+
return {
|
|
1877
|
+
action: mcpAction(server, tool ?? "*"),
|
|
1878
|
+
resource: "*",
|
|
1879
|
+
effect
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
const action = ACTIONS[name];
|
|
1883
|
+
if (action === void 0) return void 0;
|
|
1884
|
+
if (spec !== void 0) return {
|
|
1885
|
+
action,
|
|
1886
|
+
resource: action === "shell" ? shellResource(spec) : PATH_ACTIONS.has(action) ? path.resolve(cwd, spec) : spec,
|
|
1887
|
+
effect
|
|
1888
|
+
};
|
|
1889
|
+
return {
|
|
1890
|
+
action,
|
|
1891
|
+
resource: effect === "allow" && PATH_ACTIONS.has(action) ? `${cwd}/*` : "*",
|
|
1892
|
+
effect
|
|
1893
|
+
};
|
|
1894
|
+
};
|
|
1895
|
+
const rulesOf = (rules, effect, cwd) => rules.flatMap((rule) => ruleOf(rule, effect, cwd) ?? []);
|
|
1896
|
+
/**
|
|
1897
|
+
* The session's rules, in order: deny everything; read in the worktree (as
|
|
1898
|
+
* Claude Code reads without asking); edits there under `acceptEdits`; the
|
|
1899
|
+
* node's allows (only its `only` tools, if it names them); Ork's tools; then
|
|
1900
|
+
* `.env` reads, plan mode's edits and shell, and the node's denies, refused.
|
|
1901
|
+
*/
|
|
1902
|
+
const permissionRules = (spec) => {
|
|
1903
|
+
const { cwd, tools } = spec;
|
|
1904
|
+
const only = tools.only === void 0 ? void 0 : new Set(rulesOf(tools.only, "allow", cwd).map((r) => r.action));
|
|
1905
|
+
const inOnly = (rule) => only === void 0 || only.has(rule.action) || !BUILT_IN.has(rule.action);
|
|
1906
|
+
const worktree = (action) => ({
|
|
1907
|
+
action,
|
|
1908
|
+
resource: `${cwd}/*`,
|
|
1909
|
+
effect: "allow"
|
|
1910
|
+
});
|
|
1911
|
+
return [
|
|
1912
|
+
{
|
|
1913
|
+
action: "*",
|
|
1914
|
+
resource: "*",
|
|
1915
|
+
effect: "deny"
|
|
1916
|
+
},
|
|
1917
|
+
...[
|
|
1918
|
+
worktree("read"),
|
|
1919
|
+
...spec.permissionMode === "acceptEdits" ? [worktree("edit")] : [],
|
|
1920
|
+
...rulesOf(tools.allow, "allow", cwd)
|
|
1921
|
+
].filter((rule) => inOnly(rule)),
|
|
1922
|
+
{
|
|
1923
|
+
action: `ork_*`,
|
|
1924
|
+
resource: "*",
|
|
1925
|
+
effect: "allow"
|
|
1926
|
+
},
|
|
1927
|
+
{
|
|
1928
|
+
action: "read",
|
|
1929
|
+
resource: "*.env",
|
|
1930
|
+
effect: "deny"
|
|
1931
|
+
},
|
|
1932
|
+
...spec.permissionMode === "plan" ? ["edit", "shell"].map((action) => ({
|
|
1933
|
+
action,
|
|
1934
|
+
resource: "*",
|
|
1935
|
+
effect: "deny"
|
|
1936
|
+
})) : [],
|
|
1937
|
+
...rulesOf(tools.deny, "deny", cwd)
|
|
1938
|
+
];
|
|
1939
|
+
};
|
|
1940
|
+
/**
|
|
1941
|
+
* A `.mcp.json` entry as an OpenCode server: `local` with a command, else
|
|
1942
|
+
* `remote`.
|
|
1943
|
+
*/
|
|
1944
|
+
const serverOf = (entry) => {
|
|
1945
|
+
const { command, args, env, url, headers } = entry;
|
|
1946
|
+
const listed = Array.isArray(args) ? args.map(String) : [];
|
|
1947
|
+
return typeof command === "string" ? {
|
|
1948
|
+
type: "local",
|
|
1949
|
+
command: [command, ...listed],
|
|
1950
|
+
...env === void 0 ? {} : { environment: env },
|
|
1951
|
+
codemode: false
|
|
1952
|
+
} : {
|
|
1953
|
+
type: "remote",
|
|
1954
|
+
url,
|
|
1955
|
+
oauth: false,
|
|
1956
|
+
codemode: false,
|
|
1957
|
+
headers
|
|
1958
|
+
};
|
|
1959
|
+
};
|
|
1960
|
+
/** The whole config file for one session. */
|
|
1961
|
+
const sessionConfig = (spec, endpoint, instructions) => ({
|
|
1962
|
+
instructions: [instructions],
|
|
1963
|
+
mcp: { servers: {
|
|
1964
|
+
...Object.fromEntries(Object.entries(spec.mcpServers ?? {}).map(([name, entry]) => [name, serverOf(entry)])),
|
|
1965
|
+
["ork"]: {
|
|
1966
|
+
type: "remote",
|
|
1967
|
+
url: endpoint.url,
|
|
1968
|
+
oauth: false,
|
|
1969
|
+
codemode: false,
|
|
1970
|
+
headers: { Authorization: `Bearer ${endpoint.token}` }
|
|
1971
|
+
}
|
|
1972
|
+
} },
|
|
1973
|
+
permissions: permissionRules(spec),
|
|
1974
|
+
agents: { ["ork"]: {
|
|
1975
|
+
mode: "primary",
|
|
1976
|
+
...spec.maxTurns === void 0 ? {} : { steps: spec.maxTurns }
|
|
1977
|
+
} }
|
|
1978
|
+
});
|
|
1979
|
+
// ../runtime/src/adapters/open-code-events.ts
|
|
1980
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1981
|
+
const get = (value, key) => isObject(value) ? value[key] : void 0;
|
|
1982
|
+
const num = (value) => typeof value === "number" ? value : 0;
|
|
1983
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
1984
|
+
/** OpenCode's built-in tools, by Claude Code's names. */
|
|
1985
|
+
const TOOL_NAMES = {
|
|
1986
|
+
shell: "Bash",
|
|
1987
|
+
read: "Read",
|
|
1988
|
+
edit: "Edit",
|
|
1989
|
+
write: "Write",
|
|
1990
|
+
patch: "Edit",
|
|
1991
|
+
glob: "Glob",
|
|
1992
|
+
grep: "Grep",
|
|
1993
|
+
webfetch: "WebFetch",
|
|
1994
|
+
websearch: "WebSearch",
|
|
1995
|
+
subagent: "Task",
|
|
1996
|
+
skill: "Skill",
|
|
1997
|
+
todowrite: "TodoWrite"
|
|
1998
|
+
};
|
|
1999
|
+
const ORK_PREFIX = `ork_`;
|
|
2000
|
+
/** A tool's name as a node's rules and Ork's views say it. */
|
|
2001
|
+
const toolName = (name) => TOOL_NAMES[name] ?? (name.startsWith(ORK_PREFIX) ? toolId(name.slice(ORK_PREFIX.length)) : name);
|
|
2002
|
+
const parseLine = (line) => {
|
|
2003
|
+
try {
|
|
2004
|
+
return JSON.parse(line);
|
|
2005
|
+
} catch {
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
};
|
|
2009
|
+
/** One `step_finish`'s spending, as a whole session's of one turn. */
|
|
2010
|
+
const stepUsage = (part) => {
|
|
2011
|
+
const tokens = get(part, "tokens");
|
|
2012
|
+
const cache = get(tokens, "cache");
|
|
2013
|
+
return {
|
|
2014
|
+
costUsd: num(get(part, "cost")),
|
|
2015
|
+
inputTokens: num(get(tokens, "input")),
|
|
2016
|
+
outputTokens: num(get(tokens, "output")) + num(get(tokens, "reasoning")),
|
|
2017
|
+
cacheReadTokens: num(get(cache, "read")),
|
|
2018
|
+
cacheWriteTokens: num(get(cache, "write")),
|
|
2019
|
+
turns: 1,
|
|
2020
|
+
durationMs: 0
|
|
2021
|
+
};
|
|
2022
|
+
};
|
|
2023
|
+
/** Two spans of one session, as one: OpenCode reports each step's own cost. */
|
|
2024
|
+
const sumUsage = (a, b) => ({
|
|
2025
|
+
costUsd: a.costUsd + b.costUsd,
|
|
2026
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
2027
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
2028
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
2029
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
2030
|
+
turns: a.turns + b.turns,
|
|
2031
|
+
durationMs: a.durationMs + b.durationMs
|
|
2032
|
+
});
|
|
2033
|
+
const openCodeUsage = {
|
|
2034
|
+
dollars: true,
|
|
2035
|
+
combine: sumUsage
|
|
2036
|
+
};
|
|
2037
|
+
const NONE = {
|
|
2038
|
+
costUsd: 0,
|
|
2039
|
+
inputTokens: 0,
|
|
2040
|
+
outputTokens: 0,
|
|
2041
|
+
cacheReadTokens: 0,
|
|
2042
|
+
cacheWriteTokens: 0,
|
|
2043
|
+
turns: 0,
|
|
2044
|
+
durationMs: 0
|
|
2045
|
+
};
|
|
2046
|
+
/** One finished tool call: its call and its result. */
|
|
2047
|
+
const toolEvents = (part) => {
|
|
2048
|
+
const id = str(get(part, "id")) || str(get(part, "partID"));
|
|
2049
|
+
const state = get(part, "state");
|
|
2050
|
+
const input = get(state, "input");
|
|
2051
|
+
return [{
|
|
2052
|
+
type: "tool.called",
|
|
2053
|
+
id,
|
|
2054
|
+
name: toolName(str(get(part, "tool"))),
|
|
2055
|
+
input: isObject(input) ? input : {}
|
|
2056
|
+
}, {
|
|
2057
|
+
type: "tool.result",
|
|
2058
|
+
id,
|
|
2059
|
+
isError: get(state, "status") === "error"
|
|
2060
|
+
}];
|
|
2061
|
+
};
|
|
2062
|
+
/** What one line says by itself: text and finished tool calls. */
|
|
2063
|
+
const decodeOpenCodeLine = (line) => {
|
|
2064
|
+
const message = parseLine(line);
|
|
2065
|
+
const part = get(message, "part");
|
|
2066
|
+
switch (get(message, "type")) {
|
|
2067
|
+
case "text": {
|
|
2068
|
+
const said = str(get(part, "text"));
|
|
2069
|
+
return { events: said.trim() === "" ? [] : [{
|
|
2070
|
+
type: "assistant.text",
|
|
2071
|
+
text: said
|
|
2072
|
+
}] };
|
|
2073
|
+
}
|
|
2074
|
+
case "tool_use": return { events: toolEvents(part) };
|
|
2075
|
+
default: return { events: [] };
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
/**
|
|
2079
|
+
* A decoder for one leg of a session: it starts the session at the first line
|
|
2080
|
+
* that names it, and keeps the last text, the steps' usage and the errors for
|
|
2081
|
+
* the end.
|
|
2082
|
+
*/
|
|
2083
|
+
const openCodeDecoder = (spec) => {
|
|
2084
|
+
const began = Date.now();
|
|
2085
|
+
let sessionId = "";
|
|
2086
|
+
let text = "";
|
|
2087
|
+
let usage = NONE;
|
|
2088
|
+
const errors = [];
|
|
2089
|
+
return {
|
|
2090
|
+
decode: (line) => {
|
|
2091
|
+
const message = parseLine(line);
|
|
2092
|
+
if (!isObject(message)) return { events: [] };
|
|
2093
|
+
const events = [];
|
|
2094
|
+
if (sessionId === "" && str(message["sessionID"]) !== "") {
|
|
2095
|
+
sessionId = str(message["sessionID"]);
|
|
2096
|
+
events.push({
|
|
2097
|
+
type: "session.started",
|
|
2098
|
+
sessionId,
|
|
2099
|
+
model: spec.model ?? "default",
|
|
2100
|
+
cwd: spec.cwd
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
const said = decodeOpenCodeLine(line).events;
|
|
2104
|
+
events.push(...said);
|
|
2105
|
+
const last = said.findLast((event) => event.type === "assistant.text");
|
|
2106
|
+
if (last !== void 0) text = last.text;
|
|
2107
|
+
switch (message["type"]) {
|
|
2108
|
+
case "step_finish":
|
|
2109
|
+
usage = sumUsage(usage, stepUsage(message["part"]));
|
|
2110
|
+
break;
|
|
2111
|
+
case "error": {
|
|
2112
|
+
const error = message["error"];
|
|
2113
|
+
errors.push(str(get(get(error, "data"), "message")) || str(get(error, "message")) || str(get(error, "name")) || "error");
|
|
2114
|
+
break;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return { events };
|
|
2118
|
+
},
|
|
2119
|
+
exit: (code) => {
|
|
2120
|
+
if (code !== 0 && errors.length === 0) return [];
|
|
2121
|
+
return [{
|
|
2122
|
+
type: "session.finished",
|
|
2123
|
+
outcome: {
|
|
2124
|
+
subtype: code === 0 ? "success" : "error_during_execution",
|
|
2125
|
+
sessionId,
|
|
2126
|
+
report: null,
|
|
2127
|
+
text,
|
|
2128
|
+
usage: {
|
|
2129
|
+
...usage,
|
|
2130
|
+
durationMs: Date.now() - began
|
|
2131
|
+
},
|
|
2132
|
+
errors
|
|
2133
|
+
}
|
|
2134
|
+
}];
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
};
|
|
2138
|
+
// ../runtime/src/adapters/open-code.ts
|
|
2139
|
+
/**
|
|
2140
|
+
* @file The `open-code` adapter: the user's own `opencode run` (2.1.0), on a
|
|
2141
|
+
* private server (`--standalone`; the background service would ignore the
|
|
2142
|
+
* session's environment), started in the node's worktree, which pins its
|
|
2143
|
+
* working folder. Ork's endpoint, the permission rules and the `ork` agent go
|
|
2144
|
+
* in a config file named by `OPENCODE_CONFIG` (the `config-file` wiring),
|
|
2145
|
+
* written at 0600 in a private folder outside the checkout and removed after
|
|
2146
|
+
* the session (`secret-file.ts`). The machine's and the project's OpenCode
|
|
2147
|
+
* config stay out: `XDG_CONFIG_HOME` is an empty folder beside the file, and
|
|
2148
|
+
* the project's config is switched off. The brief and each later message are
|
|
2149
|
+
* the message argument of a leg; a message waits for the next leg, which
|
|
2150
|
+
* continues the session. Never `--auto`: it approves what no rule denies.
|
|
2151
|
+
*/
|
|
2152
|
+
/** The `opencode run` arguments for one leg; the message is the last one. */
|
|
2153
|
+
const openCodeArgs = (spec) => [
|
|
2154
|
+
"run",
|
|
2155
|
+
"--standalone",
|
|
2156
|
+
"--format",
|
|
2157
|
+
"json",
|
|
2158
|
+
"--agent",
|
|
2159
|
+
"ork",
|
|
2160
|
+
...spec.model === void 0 ? [] : ["--model", spec.model],
|
|
2161
|
+
...spec.resume === void 0 ? [] : ["--session", spec.resume],
|
|
2162
|
+
...spec.resume !== void 0 && spec.fork === true ? ["--fork"] : [],
|
|
2163
|
+
spec.prompt.startsWith("-") ? ` ${spec.prompt}` : spec.prompt
|
|
2164
|
+
];
|
|
2165
|
+
/**
|
|
2166
|
+
* What keeps the machine's and the project's own OpenCode setup out, and
|
|
2167
|
+
* OpenCode's reading of `.claude/` and `CLAUDE.md` (RFC-014 open question 3).
|
|
2168
|
+
*/
|
|
2169
|
+
const OPEN_CODE_ENV = {
|
|
2170
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
2171
|
+
OPENCODE_DISABLE_CLAUDE_CODE: "1",
|
|
2172
|
+
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
|
2173
|
+
OPENCODE_CONFIG_DIR: "",
|
|
2174
|
+
OPENCODE_CONFIG_CONTENT: ""
|
|
2175
|
+
};
|
|
2176
|
+
/** The config file, Ork's instructions and an empty config home, per session. */
|
|
2177
|
+
const configFile = (root) => ({
|
|
2178
|
+
kind: "config-file",
|
|
2179
|
+
wire: (spec, endpoint) => {
|
|
2180
|
+
const folder = makeSecretFolder(root);
|
|
2181
|
+
try {
|
|
2182
|
+
const instructions = folder.write("ork.md", spec.systemAppend);
|
|
2183
|
+
const config = folder.write("opencode.json", JSON.stringify(sessionConfig(spec, endpoint, instructions)));
|
|
2184
|
+
const home = path.join(folder.dir, "config");
|
|
2185
|
+
mkdirSync(home, { mode: 448 });
|
|
2186
|
+
return Promise.resolve({
|
|
2187
|
+
args: [],
|
|
2188
|
+
env: {
|
|
2189
|
+
OPENCODE_CONFIG: config,
|
|
2190
|
+
XDG_CONFIG_HOME: home
|
|
2191
|
+
},
|
|
2192
|
+
release: () => {
|
|
2193
|
+
folder.remove();
|
|
2194
|
+
return Promise.resolve();
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
folder.remove();
|
|
2199
|
+
throw error;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
/** `opencode`, as the session runner's strategies. */
|
|
2204
|
+
const createOpenCodeAdapter = (options = {}) => ({
|
|
2205
|
+
driver: "open-code",
|
|
2206
|
+
command: {
|
|
2207
|
+
bin: "opencode",
|
|
2208
|
+
args: openCodeArgs
|
|
2209
|
+
},
|
|
2210
|
+
decode: decodeOpenCodeLine,
|
|
2211
|
+
legDecoder: openCodeDecoder,
|
|
2212
|
+
mcp: configFile(options.secretRoot ?? secretRoot()),
|
|
2213
|
+
permissions: {
|
|
2214
|
+
kind: "rules",
|
|
2215
|
+
denyByDefault: true
|
|
2216
|
+
},
|
|
2217
|
+
usage: openCodeUsage,
|
|
2218
|
+
context: {
|
|
2219
|
+
instructions: ["AGENTS.md"],
|
|
2220
|
+
skills: ".opencode/skills",
|
|
2221
|
+
agents: ".opencode/agents",
|
|
2222
|
+
env: () => OPEN_CODE_ENV
|
|
2223
|
+
}
|
|
2224
|
+
});
|
|
2225
|
+
const openCodeAdapter = createOpenCodeAdapter();
|
|
2226
|
+
// ../runtime/src/registry.ts
|
|
2227
|
+
/** The message a session on a driver with no adapter yet ends with. */
|
|
2228
|
+
const notBuiltYet = (driver) => `The ${driver} driver is not built yet; use claude-code.`;
|
|
2229
|
+
const notBuilt = (driver) => ({
|
|
2230
|
+
driver,
|
|
2231
|
+
start: () => ({
|
|
2232
|
+
events: (async function* () {
|
|
2233
|
+
yield driverFailure(notBuiltYet(driver));
|
|
2234
|
+
})(),
|
|
2235
|
+
send: () => {},
|
|
2236
|
+
interrupt: () => Promise.resolve()
|
|
2237
|
+
})
|
|
2238
|
+
});
|
|
2239
|
+
const ADAPTERS = {
|
|
2240
|
+
"claude-code": claudeCodeAdapter,
|
|
2241
|
+
"open-code": openCodeAdapter,
|
|
2242
|
+
"command-code": commandCodeAdapter
|
|
2243
|
+
};
|
|
2244
|
+
/**
|
|
2245
|
+
* The runtime for a driver id, with the user's environment and the project's
|
|
2246
|
+
* runs folder (where a Command Code lease is recorded).
|
|
2247
|
+
*/
|
|
2248
|
+
const createRuntime = (driver, env, runs) => {
|
|
2249
|
+
const adapter = ADAPTERS[driver];
|
|
2250
|
+
return adapter === null ? notBuilt(driver) : createSessionRuntime(adapter, {
|
|
2251
|
+
env,
|
|
2252
|
+
...runs === void 0 ? {} : { runs }
|
|
2253
|
+
});
|
|
2254
|
+
};
|
|
2255
|
+
export { cleanStaleLeases, createRuntime, leaseLedger };
|