crew-runner 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/acp.ts ADDED
@@ -0,0 +1,391 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { mkdtemp, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { Readable, Writable } from "node:stream";
8
+ import { fileURLToPath } from "node:url";
9
+ import * as acp from "@agentclientprotocol/sdk";
10
+ import type { Claimed, RunEvent } from "./api.ts";
11
+ import { promptFrom, type Outcome } from "./claude.ts";
12
+ import { CLAUDE_DISALLOWED, CLAUDE_ENV, CLAUDE_PERMISSIONS, CLAUDE_TOOLS } from "./computer.ts";
13
+ import { DISABLED_FEATURES } from "./codex.ts";
14
+ import { claudeExecutable, killTree } from "./spawn.ts";
15
+ import { assertGrant, openStudio, type StudioGrant } from "./studio.ts";
16
+ import { MEDIA_TOOLS } from "./studio-mcp.ts";
17
+
18
+ const STUDIO_NAME = "crew_studio";
19
+ function studioServer(grant: StudioGrant): acp.McpServerStdio {
20
+ assertGrant(grant);
21
+ return { name: STUDIO_NAME, command: process.execPath, args: [fileURLToPath(new URL("../bin/crew-studio.mjs", import.meta.url)), "stdio", "--project", grant.root, "--expires", String(grant.expiresAt), "--allow-local-media"], env: [] };
22
+ }
23
+ export function mediaPermission(agent: AgentName, p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>, grant?: StudioGrant): boolean {
24
+ if (!grant) return false;
25
+ try { assertGrant(grant); } catch { return false; }
26
+ if (agent === "claude") return MEDIA_TOOLS.some((tool) => p.toolCall.title === `mcp__${STUDIO_NAME}__${tool}`);
27
+ if (field(p._meta, "is_mcp_tool_approval") !== true || (p.toolCall.kind && p.toolCall.kind !== "execute")) return false;
28
+ const seen = calls.get(p.toolCall.toolCallId);
29
+ const raw = seen ? seen.rawInput : p.toolCall.rawInput;
30
+ const server = field(raw, seen ? "server" : "serverName");
31
+ return server === STUDIO_NAME && MEDIA_TOOLS.some((tool) => field(raw, "tool") === tool);
32
+ }
33
+ export function studioCodexConfig(pkg: RunPkg, memberServers: readonly string[], grant: StudioGrant): Record<string, unknown> {
34
+ // Codex recursively merges member settings. Do not re-enable an existing entry:
35
+ // inherited env/env_vars/cwd could execute member code in our owned stdio bridge.
36
+ if (memberServers.includes(STUDIO_NAME)) throw new Error("Rename the member crew_studio MCP server before enabling studio");
37
+ const cfg = codexConfig(pkg, memberServers);
38
+ const bridge = studioServer(grant);
39
+ return { ...cfg, mcp_servers: { ...(cfg.mcp_servers as Record<string, unknown>), [STUDIO_NAME]: { enabled: true, command: bridge.command, args: bridge.args } } };
40
+ }
41
+
42
+ // One Agent Client Protocol client for both vendors. We spawn a small adapter (claude-agent-acp or codex-acp) that
43
+ // wraps the member's own signed-in app. The adapter holds the login. We never read the vendors' files, never call
44
+ // `authenticate` (which could start a browser login) and never see a credential: if the app is signed out the
45
+ // session simply fails to open and we report that.
46
+ const MAX_TEXT = 100_000;
47
+ const MAX_EVENT_TEXT = 20_000;
48
+ const FLUSH_MS = 300;
49
+ const CANCEL_GRACE_MS = 5_000;
50
+ const MCP_NAME = "crew";
51
+ const CLAUDE_TOOL_PREFIX = `mcp__${MCP_NAME}__`;
52
+
53
+ export type AgentName = "claude" | "codex";
54
+
55
+ type Launch = { command: string; args: string[]; env?: Record<string, string> };
56
+
57
+ // Finds an installed adapter by walking up node_modules. require.resolve cannot be used: both packages only
58
+ // declare an "import" export condition.
59
+ function adapterFile(pkgName: string, file: string): string {
60
+ let dir = dirname(fileURLToPath(import.meta.url));
61
+ for (;;) {
62
+ const p = join(dir, "node_modules", pkgName, file);
63
+ if (existsSync(p)) return p;
64
+ const up = dirname(dir);
65
+ if (up === dir) throw new Error(`${pkgName} is not installed`);
66
+ dir = up;
67
+ }
68
+ }
69
+
70
+ type RunPkg = Pick<Claimed, "instructions" | "mcp_url" | "token">;
71
+
72
+ export type AgentSpec = {
73
+ label: string;
74
+ launch: (pkg: RunPkg, opts: { bin?: string }) => Launch;
75
+ // false = the launch config already carries our MCP server, so session/new sends none.
76
+ acpMcp: boolean;
77
+ // Fields added to session/new for this adapter.
78
+ sessionMeta: (pkg: Pick<Claimed, "instructions">) => Record<string, unknown> | undefined;
79
+ // Idle chat mode to force after session/new, or null.
80
+ mode: string | null;
81
+ allowTool: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) => boolean;
82
+ };
83
+
84
+ // Tool calls the agent announced in this session, by id. Some approvals carry only the id.
85
+ export type SeenCall = { title: string; rawInput: unknown };
86
+
87
+ const field = (v: unknown, key: string): unknown => (v && typeof v === "object" ? (v as Record<string, unknown>)[key] : undefined);
88
+
89
+ // Claude names every MCP tool mcp__<server>__<tool> in the permission title. Anything else is a built-in tool.
90
+ export function allowClaudeTool(p: acp.RequestPermissionRequest): boolean {
91
+ return (p.toolCall.title ?? "").startsWith(CLAUDE_TOOL_PREFIX);
92
+ }
93
+
94
+ // codex-acp marks MCP tool approvals with _meta.is_mcp_tool_approval. The approval usually carries only the id of a
95
+ // tool call announced earlier with rawInput { server, tool }; a standalone one carries rawInput { serverName }.
96
+ // Only our server passes. Commands, edits and anything else are refused.
97
+ export function allowCodexTool(p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>): boolean {
98
+ if (field(p._meta, "is_mcp_tool_approval") !== true) return false;
99
+ if (p.toolCall.kind && p.toolCall.kind !== "execute") return false;
100
+ const seen = calls.get(p.toolCall.toolCallId);
101
+ const server = seen ? field(seen.rawInput, "server") : field(p.toolCall.rawInput, "serverName");
102
+ return server === MCP_NAME;
103
+ }
104
+
105
+ // Codex also loads the member's own MCP servers from their config, and those tools run without asking us. We ask
106
+ // the bundled Codex for the server names (never the files or values) and switch each one off for this session.
107
+ // Ours goes in the same mcp_servers table: the adapter would replace the table if it added ours from session/new.
108
+ // If the list fails the run fails: no lockdown, no run.
109
+ const LIST_TIMEOUT_MS = 20_000;
110
+
111
+ export function memberMcpNames(listJson: string): string[] {
112
+ const list: unknown = JSON.parse(listJson);
113
+ if (!Array.isArray(list)) throw new Error("codex mcp list returned no list");
114
+ // Already-off servers stay off, except our reserved name: even a disabled
115
+ // entry can carry inherited stdio settings and must be detected as a collision.
116
+ return list
117
+ .filter((s) => field(s, "enabled") !== false || field(s, "name") === STUDIO_NAME)
118
+ .map((s) => field(s, "name"))
119
+ .filter((n): n is string => typeof n === "string" && n !== MCP_NAME);
120
+ }
121
+
122
+ function listCodexMcp(): string[] {
123
+ const codex = createRequire(realpathSync(adapterFile("@agentclientprotocol/codex-acp", "dist/index.js"))).resolve("@openai/codex/bin/codex.js");
124
+ const r = spawnSync(process.execPath, [codex, "mcp", "list", "--json"], { cwd: tmpdir(), encoding: "utf8", timeout: LIST_TIMEOUT_MS, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
125
+ if (r.status !== 0) throw new Error("could not list Codex MCP servers");
126
+ return memberMcpNames(r.stdout);
127
+ }
128
+
129
+ export const CODEX_TOKEN_ENV = "CREW_MCP_TOKEN";
130
+
131
+ export function codexConfig(pkg: RunPkg, memberServers: readonly string[]): Record<string, unknown> {
132
+ return {
133
+ features: Object.fromEntries(DISABLED_FEATURES.map((f) => [f, false])),
134
+ web_search: "disabled",
135
+ sandbox_mode: "read-only",
136
+ approval_policy: "on-request",
137
+ developer_instructions: pkg.instructions,
138
+ mcp_servers: {
139
+ ...Object.fromEntries(memberServers.map((n) => [n, { enabled: false }])),
140
+ [MCP_NAME]: { url: pkg.mcp_url, bearer_token_env_var: CODEX_TOKEN_ENV },
141
+ },
142
+ };
143
+ }
144
+
145
+ export const AGENTS: Record<AgentName, AgentSpec> = {
146
+ claude: {
147
+ label: "Claude Code",
148
+ launch: (_pkg, opts) => ({
149
+ command: process.execPath,
150
+ args: [adapterFile("@zed-industries/claude-agent-acp", "dist/index.js")],
151
+ // On Windows a .cmd shim cannot be started by the adapter, so it gets the real file the shim points at.
152
+ env: opts.bin ? { CLAUDE_CODE_EXECUTABLE: claudeExecutable(opts.bin) } : undefined,
153
+ }),
154
+ sessionMeta: (pkg) => ({
155
+ systemPrompt: pkg.instructions,
156
+ // No built-in tools, no member settings or project files, only the MCP server we pass.
157
+ claudeCode: { options: { tools: [], settingSources: [], strictMcpConfig: true } },
158
+ }),
159
+ // The member's own default mode could be "bypass permissions"; force the asking mode so our handler decides.
160
+ mode: "default",
161
+ acpMcp: true,
162
+ allowTool: allowClaudeTool,
163
+ },
164
+ codex: {
165
+ label: "Codex",
166
+ // The adapter takes Codex config as one JSON object. NO_BROWSER hides the browser login method, so a signed-out
167
+ // app fails instead of opening a login page.
168
+ launch: (pkg) => ({
169
+ command: process.execPath,
170
+ args: [adapterFile("@agentclientprotocol/codex-acp", "dist/index.js")],
171
+ env: {
172
+ CODEX_CONFIG: JSON.stringify(codexConfig(pkg, listCodexMcp())),
173
+ [CODEX_TOKEN_ENV]: pkg.token,
174
+ INITIAL_AGENT_MODE: "read-only",
175
+ NO_BROWSER: "1",
176
+ },
177
+ }),
178
+ sessionMeta: () => undefined,
179
+ mode: null,
180
+ acpMcp: false,
181
+ allowTool: allowCodexTool,
182
+ },
183
+ };
184
+
185
+ type Option = acp.RequestPermissionRequest["options"][number];
186
+
187
+ // Pure policy: the option id to select, or null to cancel the request.
188
+ export function decide(
189
+ spec: Pick<AgentSpec, "allowTool">,
190
+ p: acp.RequestPermissionRequest,
191
+ calls: ReadonlyMap<string, SeenCall> = new Map(),
192
+ ): { allowed: boolean; optionId: string | null } {
193
+ return optionFor(p, spec.allowTool(p, calls));
194
+ }
195
+
196
+ export function optionFor(p: acp.RequestPermissionRequest, allowed: boolean): { allowed: boolean; optionId: string | null } {
197
+ const pick = (kinds: string[], ids: string[]): Option | undefined =>
198
+ p.options.find((o) => kinds.includes(o.kind)) ?? p.options.find((o) => ids.includes(o.optionId));
199
+ // Always the one-shot option, never "always": the next call is judged on its own.
200
+ const o = allowed ? pick(["allow_once"], ["approved", "allow", "allow_once"]) : pick(["reject_once", "reject_always"], ["cancel", "reject", "denied", "abort"]);
201
+ return { allowed, optionId: o?.optionId ?? null };
202
+ }
203
+
204
+ export type RunAcpOptions = {
205
+ timeoutMs: number;
206
+ // Override how the adapter is started (tests). Defaults to the registry.
207
+ launch?: Launch;
208
+ // The member's own CLI path (CREW_CLAUDE_BIN), handed to the claude adapter.
209
+ bin?: string;
210
+ // Abort = run cancelled: session/cancel first, kill after a short grace.
211
+ signal?: AbortSignal;
212
+ // Explicit host-selected project only. Never read this authority from a cloud task/model.
213
+ studio?: StudioGrant;
214
+ // Standalone member-authored local session: no cloud tools or events.
215
+ studioOnly?: boolean;
216
+ // Computer mode (@mac, Claude Code only): Claude Code's own tools, from a scratch folder the caller owns, every tool request
217
+ // judged by `decide` (which may wait for the owner's card). No CrewCortex MCP server: the reply is the only output.
218
+ computer?: ComputerSession;
219
+ };
220
+
221
+ export type ComputerSession = {
222
+ cwd: string;
223
+ additionalDirectories: string[];
224
+ systemPrompt: string;
225
+ decide: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) => Promise<boolean>;
226
+ };
227
+
228
+ type Sink = (events: RunEvent[]) => Promise<void>;
229
+
230
+ const failed = (error: string, inputTokens = 0, outputTokens = 0): Outcome => ({ status: "failed", text: "", inputTokens, outputTokens, error });
231
+
232
+ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: RunAcpOptions): Promise<Outcome> {
233
+ const originalSpec = AGENTS[agent];
234
+ const grant = opts.studio ? Object.freeze({ ...opts.studio }) : undefined;
235
+ const spec = grant ? { ...originalSpec, allowTool: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) =>
236
+ (!opts.studioOnly && originalSpec.allowTool(p, calls)) || mediaPermission(agent, p, calls, grant) } : originalSpec;
237
+ if (opts.computer && (agent !== "claude" || grant)) return failed("runner_failed");
238
+ const computer = opts.computer;
239
+ // Empty temp directory: even if a built-in tool were somehow on, there is nothing of the member's to touch.
240
+ // Computer mode works in the caller's scratch folder instead, which the caller removes.
241
+ const cwd = computer ? computer.cwd : await mkdtemp(join(tmpdir(), "crew-run-"));
242
+ let child: ReturnType<typeof spawn> | undefined;
243
+ let timer: NodeJS.Timeout | undefined;
244
+ let flushTimer: NodeJS.Timeout | undefined;
245
+ let graceTimer: NodeJS.Timeout | undefined;
246
+ let onAbort: (() => void) | undefined;
247
+ try {
248
+ if (grant) await openStudio(grant);
249
+ if (opts.studioOnly && !grant) throw new Error("Local studio consent required");
250
+ const launch = opts.launch ?? spec.launch(pkg, { bin: opts.bin });
251
+ if (agent === "codex" && grant) {
252
+ const config = studioCodexConfig(pkg, listCodexMcp(), grant);
253
+ // Absence would merge the member's enabled `crew` back into Codex settings.
254
+ if (opts.studioOnly) (config.mcp_servers as Record<string, unknown>)[MCP_NAME] = { enabled: false };
255
+ launch.env = { ...launch.env, CODEX_CONFIG: JSON.stringify(config) };
256
+ }
257
+ // POSIX: detached makes the adapter lead a process group, so killTree ends it and the app it started. Windows uses taskkill.
258
+ child = spawn(launch.command, launch.args, { cwd, stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32", windowsHide: true, env: { ...process.env, ...launch.env } });
259
+ const proc = child;
260
+ const exited = new Promise<never>((_, reject) => {
261
+ proc.on("error", () => reject(new Error("runner_failed")));
262
+ proc.on("close", () => reject(new Error("runner_failed")));
263
+ });
264
+ exited.catch(() => undefined);
265
+ const timedOut = new Promise<never>((_, reject) => {
266
+ timer = setTimeout(() => reject(new Error("runner_timeout")), opts.timeoutMs);
267
+ });
268
+ timedOut.catch(() => undefined);
269
+
270
+ // Serialised sink, text coalesced so a token stream is not one HTTP call per chunk.
271
+ let chain: Promise<void> = Promise.resolve();
272
+ const send = (events: RunEvent[]) => {
273
+ if (events.length) chain = chain.then(() => sink(events).catch(() => undefined));
274
+ };
275
+ const calls = new Map<string, SeenCall>();
276
+ let buffered = "";
277
+ let final = "";
278
+ const flush = () => {
279
+ clearTimeout(flushTimer);
280
+ flushTimer = undefined;
281
+ while (buffered) {
282
+ send([{ type: "text", text: buffered.slice(0, MAX_EVENT_TEXT) }]);
283
+ buffered = buffered.slice(MAX_EVENT_TEXT);
284
+ }
285
+ };
286
+ const onText = (t: string) => {
287
+ buffered += t;
288
+ final += t;
289
+ if (buffered.length >= MAX_EVENT_TEXT) flush();
290
+ else flushTimer ??= setTimeout(flush, FLUSH_MS);
291
+ };
292
+
293
+ const handleUpdate = (u: acp.SessionUpdate) => {
294
+ switch (u.sessionUpdate) {
295
+ case "agent_message_chunk":
296
+ if (u.content.type === "text") onText(u.content.text);
297
+ break;
298
+ case "tool_call":
299
+ calls.set(u.toolCallId, { title: u.title, rawInput: u.rawInput });
300
+ flush();
301
+ final = ""; // the answer is the text after the last tool call
302
+ send([{ type: "step", kind: "tool", name: u.title.slice(0, 200), input: u.rawInput }]);
303
+ break;
304
+ case "tool_call_update":
305
+ if (u.status === "completed" || u.status === "failed") {
306
+ flush();
307
+ send([{ type: "step", kind: "tool", output: u.rawOutput ?? u.content }]);
308
+ }
309
+ break;
310
+ case "plan":
311
+ send([{ type: "step", kind: "model", name: "plan", output: u.entries.map((e) => e.content) }]);
312
+ break;
313
+ default:
314
+ break;
315
+ }
316
+ };
317
+
318
+ const stream = acp.ndJsonStream(Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>, Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>);
319
+
320
+ const session = acp
321
+ .client({ name: "crew-runner" })
322
+ .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
323
+ const d = computer ? optionFor(ctx.params, await computer.decide(ctx.params, calls).catch(() => false)) : decide(spec, ctx.params, calls);
324
+ return { outcome: d.optionId ? { outcome: "selected" as const, optionId: d.optionId } : { outcome: "cancelled" as const } };
325
+ })
326
+ .connectWith(stream, async (ctx) => {
327
+ await ctx.request(acp.methods.agent.initialize, {
328
+ protocolVersion: acp.PROTOCOL_VERSION,
329
+ // No file system or terminal for the agent: it can only use the MCP tools we hand it.
330
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
331
+ });
332
+ const request: acp.NewSessionRequest = {
333
+ cwd,
334
+ mcpServers: computer ? [] : spec.acpMcp ? [
335
+ ...(!opts.studioOnly ? [{ type: "http" as const, name: MCP_NAME, url: pkg.mcp_url, headers: [{ name: "Authorization", value: `Bearer ${pkg.token}` }] }] : []),
336
+ ...(grant ? [studioServer(grant)] : []),
337
+ ] : [],
338
+ _meta: computer
339
+ ? { systemPrompt: computer.systemPrompt, claudeCode: { options: {
340
+ settingSources: [], strictMcpConfig: true, additionalDirectories: computer.additionalDirectories,
341
+ settings: { permissions: CLAUDE_PERMISSIONS }, tools: CLAUDE_TOOLS, disallowedTools: CLAUDE_DISALLOWED, env: CLAUDE_ENV,
342
+ } } }
343
+ : spec.sessionMeta(pkg),
344
+ };
345
+ const active = await ctx.buildSession(request).start();
346
+ try {
347
+ if (spec.mode) await ctx.request(acp.methods.agent.session.setMode, { sessionId: active.sessionId, modeId: spec.mode });
348
+ onAbort = () => {
349
+ void ctx.notify(acp.methods.agent.session.cancel, { sessionId: active.sessionId }).catch(() => undefined);
350
+ graceTimer = setTimeout(() => killTree(proc), CANCEL_GRACE_MS);
351
+ };
352
+ if (opts.signal?.aborted) onAbort();
353
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
354
+ void active.prompt(promptFrom(pkg.messages)).catch(() => undefined);
355
+ for (;;) {
356
+ const m = await active.nextUpdate();
357
+ if (m.kind === "stop") return m.response;
358
+ handleUpdate(m.update);
359
+ }
360
+ } finally {
361
+ active.dispose();
362
+ }
363
+ });
364
+ session.catch(() => undefined);
365
+
366
+ let response: acp.PromptResponse;
367
+ try {
368
+ response = await Promise.race([session, exited, timedOut]);
369
+ } catch (e) {
370
+ flush();
371
+ await chain;
372
+ return failed(e instanceof Error && e.message === "runner_timeout" ? "runner_timeout" : opts.signal?.aborted ? "runner_cancelled" : "runner_failed");
373
+ }
374
+ flush();
375
+ await chain;
376
+ const inputTokens = response.usage?.inputTokens ?? 0;
377
+ const outputTokens = response.usage?.outputTokens ?? 0;
378
+ if (response.stopReason === "cancelled") return failed("runner_cancelled", inputTokens, outputTokens);
379
+ if (response.stopReason !== "end_turn" && response.stopReason !== "max_tokens") return failed("runner_failed", inputTokens, outputTokens);
380
+ return { status: "completed", text: final.slice(0, MAX_TEXT), inputTokens, outputTokens };
381
+ } catch {
382
+ return failed("runner_failed");
383
+ } finally {
384
+ clearTimeout(timer);
385
+ clearTimeout(flushTimer);
386
+ clearTimeout(graceTimer);
387
+ if (onAbort) opts.signal?.removeEventListener("abort", onAbort);
388
+ if (child) killTree(child);
389
+ if (!computer) await rm(cwd, { recursive: true, force: true });
390
+ }
391
+ }
package/src/api.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { CARD_DETAIL_MAX } from "./computer.ts";
2
+ import type { Stored } from "./store.ts";
3
+
4
+ export type Claimed = {
5
+ run_id: string;
6
+ instructions: string;
7
+ messages: { role: string; content: unknown }[];
8
+ mcp_url: string;
9
+ token: string;
10
+ // Only on a computer-mode claim (crew-runner start --computer): the workspace whose folder the run uses.
11
+ computer?: { workspace_slug: string };
12
+ };
13
+
14
+ export type ClaimMode = "default" | "computer";
15
+ export type CardAnswer = "pending" | "approved" | "rejected" | "expired";
16
+
17
+ export type RunEvent =
18
+ | { type: "text"; text: string }
19
+ | { type: "step"; kind: "model" | "tool"; name?: string; input?: unknown; output?: unknown };
20
+
21
+ export type Complete = {
22
+ run_id: string;
23
+ status: "completed" | "failed";
24
+ text: string;
25
+ input_tokens?: number;
26
+ output_tokens?: number;
27
+ error?: string;
28
+ };
29
+
30
+ export function createApi(cfg: Stored, fetchImpl: typeof fetch = fetch) {
31
+ const call = (path: string, body: unknown, signal?: AbortSignal) =>
32
+ fetchImpl(`${cfg.appUrl}/api/runner/${path}`, {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.token}` },
35
+ body: JSON.stringify(body),
36
+ signal,
37
+ });
38
+ return {
39
+ // true = this device token is accepted by the server.
40
+ async ping(): Promise<boolean> {
41
+ return (await call("ping", {})).ok;
42
+ },
43
+ // null = no work (204). Throws on auth or server errors so the loop can back off.
44
+ async claim(signal?: AbortSignal, mode: ClaimMode = "default"): Promise<Claimed | null> {
45
+ const res = await call("claim", mode === "computer" ? { mode } : {}, signal);
46
+ if (res.status === 204) return null;
47
+ if (!res.ok) throw new Error(`claim failed (${res.status})`);
48
+ return (await res.json()) as Claimed;
49
+ },
50
+ // Throws when the server did not take them (computer mode then refuses the step it could not record).
51
+ async events(runId: string, events: RunEvent[]): Promise<void> {
52
+ if (events.length === 0) return;
53
+ const res = await call("events", { run_id: runId, events });
54
+ if (!res.ok) throw new Error(`events failed (${res.status})`);
55
+ },
56
+ async complete(body: Complete): Promise<void> {
57
+ await call("complete", body);
58
+ },
59
+ // Computer mode: a confirmation card for the owner. Null when it could not be asked (the step is then refused).
60
+ async askOwner(runId: string, category: string, detail: string): Promise<string | null> {
61
+ // The policy refuses anything longer than a card shows; never cut what the owner approves.
62
+ if (detail.length > CARD_DETAIL_MAX) return null;
63
+ const res = await call("approval", { run_id: runId, category, detail });
64
+ if (!res.ok) return null;
65
+ const body = (await res.json()) as { approval_id?: unknown };
66
+ return typeof body.approval_id === "string" ? body.approval_id : null;
67
+ },
68
+ async ownerAnswer(runId: string, approvalId: string): Promise<CardAnswer | null> {
69
+ const res = await call("approval/status", { run_id: runId, approval_id: approvalId });
70
+ if (!res.ok) return null;
71
+ const body = (await res.json()) as { status?: unknown };
72
+ return body.status === "pending" || body.status === "approved" || body.status === "rejected" || body.status === "expired" ? body.status : null;
73
+ },
74
+ // Computer mode: is this run still running? false only when the server says so (the owner's off switch); null when
75
+ // it could not tell (network, an older server), which never stops a run.
76
+ async alive(runId: string): Promise<boolean | null> {
77
+ const res = await call("alive", { run_id: runId });
78
+ if (!res.ok) return null;
79
+ const body = (await res.json()) as { running?: unknown };
80
+ return typeof body.running === "boolean" ? body.running : null;
81
+ },
82
+ async revoke(): Promise<boolean> {
83
+ return (await call("revoke", {})).ok;
84
+ },
85
+ };
86
+ }
87
+ export type RunnerApi = ReturnType<typeof createApi>;
package/src/claude.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import type { Claimed, RunEvent } from "./api.ts";
7
+ import { invocation, spawnBin } from "./spawn.ts";
8
+
9
+ // Drives the member's own `claude` app in print mode. We only run its public commands (--version, auth status)
10
+ // and read its exit code. We never open its files or environment: whatever login it has stays inside it.
11
+ const SUPPORTED_MAJOR = 2;
12
+ const MIN_MINOR = 1;
13
+ const MAX_TEXT = 100_000;
14
+
15
+ export type Check = { ok: true; version: string } | { ok: false; message: string };
16
+
17
+ // On Windows `bin` may be a .cmd shim; invocation() starts it through cmd.exe, never through a general shell.
18
+ export const run = (bin: string, args: string[]): Promise<{ code: number; out: string }> =>
19
+ new Promise((resolve) => {
20
+ let inv;
21
+ try {
22
+ inv = invocation(bin, args);
23
+ } catch {
24
+ resolve({ code: 1, out: "" });
25
+ return;
26
+ }
27
+ execFile(inv.command, inv.args, { timeout: 20_000, windowsHide: true, windowsVerbatimArguments: inv.windowsVerbatimArguments }, (err, stdout) => {
28
+ const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
29
+ resolve({ code, out: String(stdout ?? "") });
30
+ });
31
+ });
32
+
33
+ export function parseVersion(out: string): { major: number; minor: number; text: string } | null {
34
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
35
+ if (!m) return null;
36
+ return { major: Number(m[1]), minor: Number(m[2]), text: `${m[1]}.${m[2]}.${m[3]}` };
37
+ }
38
+
39
+ export async function checkClaude(bin = "claude"): Promise<Check> {
40
+ const v = await run(bin, ["--version"]);
41
+ if (v.code !== 0) return { ok: false, message: "Claude Code is not installed. Install it, sign in, then run this again." };
42
+ const ver = parseVersion(v.out);
43
+ if (!ver) return { ok: false, message: "Could not read the Claude Code version." };
44
+ if (ver.major !== SUPPORTED_MAJOR || ver.minor < MIN_MINOR) {
45
+ return { ok: false, message: `Claude Code ${ver.text} is outside the supported range (${SUPPORTED_MAJOR}.${MIN_MINOR}.x and later ${SUPPORTED_MAJOR}.x). Update it.` };
46
+ }
47
+ const s = await run(bin, ["auth", "status"]);
48
+ let signedIn = s.code === 0;
49
+ try {
50
+ const json = JSON.parse(s.out) as { loggedIn?: unknown };
51
+ if (json.loggedIn === false) signedIn = false;
52
+ } catch {
53
+ // Not JSON: trust the exit code.
54
+ }
55
+ if (!signedIn) return { ok: false, message: "Claude Code is not signed in. Open it, sign in, then run this again." };
56
+ return { ok: true, version: ver.text };
57
+ }
58
+
59
+ // Only our MCP server is configured and every built-in file and shell tool is off, so the only tools are ours.
60
+ export function buildArgs(pkg: Pick<Claimed, "instructions" | "mcp_url" | "token">): string[] {
61
+ const mcp = JSON.stringify({ mcpServers: { crew: { type: "http", url: pkg.mcp_url, headers: { Authorization: `Bearer ${pkg.token}` } } } });
62
+ return [
63
+ "-p",
64
+ "--output-format", "stream-json",
65
+ "--verbose",
66
+ "--mcp-config", mcp,
67
+ "--strict-mcp-config",
68
+ "--tools", "",
69
+ "--permission-mode", "dontAsk",
70
+ "--allowedTools", "mcp__crew",
71
+ "--system-prompt", pkg.instructions,
72
+ ];
73
+ }
74
+
75
+ export function promptFrom(messages: Claimed["messages"]): string {
76
+ return messages.map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`).join("\n\n");
77
+ }
78
+
79
+ export type Outcome = { status: "completed" | "failed"; text: string; inputTokens: number; outputTokens: number; costMicroUsd?: number; error?: string };
80
+
81
+ type Block = { type?: string; text?: string; name?: string; input?: unknown; content?: unknown };
82
+ type Line = {
83
+ type?: string;
84
+ message?: { content?: Block[] };
85
+ result?: string;
86
+ is_error?: boolean;
87
+ total_cost_usd?: number;
88
+ usage?: { input_tokens?: number; output_tokens?: number };
89
+ };
90
+
91
+ // One stream-json line -> zero or more events for the server.
92
+ export function eventsFromLine(line: Line): RunEvent[] {
93
+ const out: RunEvent[] = [];
94
+ const blocks = line.message?.content;
95
+ if (!Array.isArray(blocks)) return out;
96
+ for (const b of blocks) {
97
+ if (line.type === "assistant" && b.type === "tool_use") out.push({ type: "step", kind: "tool", name: b.name, input: b.input });
98
+ if (line.type === "user" && b.type === "tool_result") out.push({ type: "step", kind: "tool", output: b.content });
99
+ }
100
+ return out;
101
+ }
102
+
103
+ export async function runClaude(
104
+ pkg: Claimed,
105
+ sink: (events: RunEvent[]) => Promise<void>,
106
+ opts: { bin?: string; timeoutMs: number },
107
+ ): Promise<Outcome> {
108
+ // Empty temp directory: even if a built-in tool were somehow on, there is nothing of the member's to touch.
109
+ const cwd = await mkdtemp(join(tmpdir(), "crew-run-"));
110
+ let child;
111
+ try {
112
+ child = spawnBin(opts.bin ?? "claude", buildArgs(pkg), { cwd, stdio: ["pipe", "pipe", "ignore"] });
113
+ } catch {
114
+ // Windows .cmd shim given an argument it cannot carry (a line break in the instructions).
115
+ await rm(cwd, { recursive: true, force: true });
116
+ return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
117
+ }
118
+ const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
119
+ child.stdin?.on("error", () => undefined);
120
+ child.stdin?.end(promptFrom(pkg.messages));
121
+
122
+ let result: Line | null = null;
123
+ const pending: Promise<void>[] = [];
124
+ const rl = createInterface({ input: child.stdout! });
125
+ rl.on("line", (raw) => {
126
+ let line: Line;
127
+ try {
128
+ line = JSON.parse(raw) as Line;
129
+ } catch {
130
+ return;
131
+ }
132
+ if (line.type === "result") result = line;
133
+ const events = eventsFromLine(line);
134
+ if (events.length) pending.push(sink(events).catch(() => undefined));
135
+ });
136
+ const code = await new Promise<number>((resolve) => {
137
+ child.on("error", () => resolve(127));
138
+ child.on("close", (c) => resolve(c ?? 1));
139
+ });
140
+ clearTimeout(timer);
141
+ await Promise.all(pending);
142
+ await rm(cwd, { recursive: true, force: true });
143
+
144
+ const r = result as Line | null;
145
+ if (!r || r.is_error || code !== 0) {
146
+ return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
147
+ }
148
+ return {
149
+ status: "completed",
150
+ text: (r.result ?? "").slice(0, MAX_TEXT),
151
+ inputTokens: r.usage?.input_tokens ?? 0,
152
+ outputTokens: r.usage?.output_tokens ?? 0,
153
+ costMicroUsd: undefined,
154
+ };
155
+ }