@tablation/crew 0.26.0 → 0.28.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/dist/cli.js CHANGED
@@ -7,10 +7,412 @@ import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
8
8
 
9
9
  // src/config.ts
10
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
11
- import { resolve, dirname, isAbsolute, join as join2, basename as basename2 } from "path";
12
- import { homedir, tmpdir } from "os";
10
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
11
+ import { resolve, dirname as dirname2, isAbsolute, join as join4, basename as basename2 } from "path";
12
+ import { homedir as homedir2, tmpdir } from "os";
13
13
  import { parse as parse2 } from "yaml";
14
+
15
+ // src/events.ts
16
+ import {
17
+ appendFileSync,
18
+ mkdirSync,
19
+ existsSync,
20
+ statSync,
21
+ renameSync,
22
+ openSync,
23
+ closeSync,
24
+ rmSync
25
+ } from "fs";
26
+ import { join, dirname } from "path";
27
+ function render(e) {
28
+ const scope = e.role ? `${e.step}[${e.role}]` : e.step;
29
+ const ticket = e.ticket ? ` ${e.ticket}` : "";
30
+ const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
31
+ return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
32
+ }
33
+ var DEFAULT_EVENTS_ROTATE_BYTES = 64 * 1024 * 1024;
34
+ var ROTATED_EVENTS_RE = /^events\.\d{8}T\d{6}Z(?:-\d+)?\.jsonl$/;
35
+ var ROTATE_LOCK_STALE_MS = 6e4;
36
+ function rotateEventsFile(file, maxBytes, now = /* @__PURE__ */ new Date()) {
37
+ try {
38
+ if (!existsSync(file) || statSync(file).size <= maxBytes) return void 0;
39
+ } catch {
40
+ return void 0;
41
+ }
42
+ const lock = `${file}.rotate.lock`;
43
+ try {
44
+ try {
45
+ closeSync(openSync(lock, "wx"));
46
+ } catch {
47
+ try {
48
+ if (Date.now() - statSync(lock).mtimeMs < ROTATE_LOCK_STALE_MS) return void 0;
49
+ rmSync(lock, { force: true });
50
+ closeSync(openSync(lock, "wx"));
51
+ } catch {
52
+ return void 0;
53
+ }
54
+ }
55
+ try {
56
+ if (statSync(file).size <= maxBytes) return void 0;
57
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
58
+ let dest = join(dirname(file), `events.${stamp}.jsonl`);
59
+ for (let n = 1; existsSync(dest); n++) dest = join(dirname(file), `events.${stamp}-${n}.jsonl`);
60
+ renameSync(file, dest);
61
+ return dest;
62
+ } finally {
63
+ rmSync(lock, { force: true });
64
+ }
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ }
69
+ var Emitter = class _Emitter {
70
+ cycle;
71
+ opts;
72
+ now;
73
+ step = "poll";
74
+ role;
75
+ constructor(opts) {
76
+ this.opts = opts;
77
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
78
+ this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
79
+ for (const f of [opts.eventFile, opts.logFile]) {
80
+ if (!f) continue;
81
+ try {
82
+ mkdirSync(dirname(f), { recursive: true });
83
+ } catch {
84
+ }
85
+ }
86
+ }
87
+ /** Everything emitted after this belongs to `step`, until the next one. */
88
+ enter(step, role) {
89
+ this.step = step;
90
+ this.role = role;
91
+ }
92
+ emit(message, extra = {}) {
93
+ const e = {
94
+ at: this.now().toISOString(),
95
+ cycle: this.cycle,
96
+ route: this.opts.route,
97
+ step: extra.step ?? this.step,
98
+ level: extra.level ?? "info",
99
+ message,
100
+ ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
101
+ ...extra.ticket ? { ticket: extra.ticket } : {},
102
+ ...extra.data ? { data: extra.data } : {}
103
+ };
104
+ const line = render(e);
105
+ try {
106
+ if (this.opts.eventFile && this.opts.rotateBytes) rotateEventsFile(this.opts.eventFile, this.opts.rotateBytes, this.now());
107
+ } catch {
108
+ }
109
+ try {
110
+ if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
111
+ `);
112
+ } catch {
113
+ }
114
+ try {
115
+ if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
116
+ `);
117
+ } catch {
118
+ }
119
+ try {
120
+ (this.opts.console ?? ((l) => process.stderr.write(`${l}
121
+ `)))(line);
122
+ } catch {
123
+ }
124
+ return e;
125
+ }
126
+ warn(message, extra = {}) {
127
+ return this.emit(message, { ...extra, level: "warn" });
128
+ }
129
+ error(message, extra = {}) {
130
+ return this.emit(message, { ...extra, level: "error" });
131
+ }
132
+ /**
133
+ * A view of this emitter labelling every event with a different route,
134
+ * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
135
+ * Emitter is stamped with just one route (see cli.ts) — a phase that
136
+ * actually knows which route it's working (release, per-repo poll) uses
137
+ * this instead, so its events aren't all attributed to `routes[0]`.
138
+ */
139
+ forRoute(route) {
140
+ return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
141
+ }
142
+ /** Times a step and emits its outcome, so a view can show duration. */
143
+ async timed(step, message, fn, role) {
144
+ this.enter(step, role);
145
+ const started = this.now().getTime();
146
+ try {
147
+ const result = await fn();
148
+ this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
149
+ return result;
150
+ } catch (err) {
151
+ this.error(`${message} \u2014 ${err.message}`, {
152
+ data: { ms: this.now().getTime() - started, ok: false }
153
+ });
154
+ throw err;
155
+ }
156
+ }
157
+ };
158
+ var eventFileFor = (stateDir) => join(stateDir, "events.jsonl");
159
+
160
+ // src/stream.ts
161
+ function toolTarget(name, input) {
162
+ if (!input) return void 0;
163
+ for (const key of ["file_path", "path", "notebook_path", "url", "pattern"]) {
164
+ const v = input[key];
165
+ if (typeof v === "string" && v) return v;
166
+ }
167
+ if (name === "Bash" && typeof input.command === "string") {
168
+ return input.command.trim().split(/\s+/)[0];
169
+ }
170
+ return void 0;
171
+ }
172
+ function mapStreamLine(line) {
173
+ const obj = line;
174
+ if (!obj || obj.type !== "assistant") return [];
175
+ const content = obj.message?.content;
176
+ if (!Array.isArray(content)) return [];
177
+ const out = [];
178
+ for (const block of content) {
179
+ if (block.type === "thinking") out.push({ kind: "thought", text: block.thinking });
180
+ else if (block.type === "text") out.push({ kind: "text", text: block.text });
181
+ else if (block.type === "tool_use") {
182
+ out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input) });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ function extractTurnTokens(line) {
188
+ const obj = line;
189
+ if (!obj || obj.type !== "assistant") return void 0;
190
+ const usage2 = obj.message?.usage;
191
+ if (!usage2) return void 0;
192
+ return (usage2.input_tokens ?? 0) + (usage2.output_tokens ?? 0) + (usage2.cache_read_input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
193
+ }
194
+ function extractResult(line) {
195
+ const obj = line;
196
+ if (!obj || obj.type !== "result") return void 0;
197
+ return {
198
+ sessionId: obj.session_id,
199
+ numTurns: obj.num_turns,
200
+ totalCostUsd: obj.total_cost_usd,
201
+ inputTokens: obj.usage?.input_tokens,
202
+ outputTokens: obj.usage?.output_tokens,
203
+ cacheReadTokens: obj.usage?.cache_read_input_tokens,
204
+ cacheCreationTokens: obj.usage?.cache_creation_input_tokens
205
+ };
206
+ }
207
+
208
+ // src/claude-mcp.ts
209
+ import { spawnSync } from "child_process";
210
+ import { readFileSync as readFileSync2 } from "fs";
211
+ import { homedir } from "os";
212
+ import { join as join2 } from "path";
213
+ var MCP_SERVER_NAME = "tablation";
214
+ var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
215
+ function mcpUrlFor(baseUrl) {
216
+ return `${baseUrl.replace(/\/+$/, "")}/api/mcp`;
217
+ }
218
+ function runClaude(bin, args) {
219
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 3e4 });
220
+ const missing = r.error?.code === "ENOENT";
221
+ return { status: r.status, missing, stderr: r.stderr ?? "" };
222
+ }
223
+ function userScopeEntry(home) {
224
+ try {
225
+ const j = JSON.parse(readFileSync2(join2(home, ".claude.json"), "utf8"));
226
+ return j.mcpServers?.[MCP_SERVER_NAME];
227
+ } catch {
228
+ return void 0;
229
+ }
230
+ }
231
+ function inspectClaudeMcp(o) {
232
+ const got = runClaude(o.bin, ["mcp", "get", MCP_SERVER_NAME]);
233
+ if (got.missing) return { state: "no-claude" };
234
+ if (got.status !== 0) return { state: "missing" };
235
+ const entry = userScopeEntry(o.home ?? homedir());
236
+ if (!entry) {
237
+ return { state: "different", detail: `a "${MCP_SERVER_NAME}" server exists, but not in user scope (~/.claude.json) \u2014 a project or local registration` };
238
+ }
239
+ if (entry.url !== o.url) return { state: "different", detail: `it points at ${entry.url ?? "(no url)"}, not ${o.url}` };
240
+ const auth = Object.entries(entry.headers ?? {}).find(([k]) => k.toLowerCase() === "authorization")?.[1];
241
+ if (auth !== `Bearer ${o.key}`) return { state: "different", detail: `it is registered with a different key than this route's` };
242
+ return { state: "registered", url: o.url };
243
+ }
244
+ function ensureClaudeMcp(o) {
245
+ const manual = `claude mcp add --transport http ${MCP_SERVER_NAME} ${o.url} --header "Authorization: Bearer <key>" --scope user`;
246
+ const st = inspectClaudeMcp(o);
247
+ if (st.state === "no-claude") return `mcp: \`${o.bin}\` not found, so Claude Code was not configured. To do it by hand: ${manual}`;
248
+ if (st.state === "registered") return `mcp: ${MCP_SERVER_NAME} already registered with Claude Code \u2192 ${st.url}`;
249
+ if (st.state === "different" && !o.replace) {
250
+ return `mcp: left Claude Code's "${MCP_SERVER_NAME}" server alone \u2014 ${st.detail}. Re-run with --mcp replace to overwrite it.`;
251
+ }
252
+ if (o.dryRun) return `(dry run) would register the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url}`;
253
+ if (st.state === "different") {
254
+ runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
255
+ }
256
+ const add = runClaude(o.bin, [
257
+ "mcp",
258
+ "add",
259
+ "--transport",
260
+ "http",
261
+ MCP_SERVER_NAME,
262
+ o.url,
263
+ "--header",
264
+ `Authorization: Bearer ${o.key}`,
265
+ "--scope",
266
+ "user"
267
+ ]);
268
+ if (add.status !== 0) {
269
+ return `mcp: \`claude mcp add\` failed (${redact(add.stderr.trim(), o.key) || `exit ${add.status}`}). To do it by hand: ${manual}`;
270
+ }
271
+ return `mcp: registered the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url} (user scope, ~/.claude.json)`;
272
+ }
273
+
274
+ // src/capability.ts
275
+ function profileFor(role) {
276
+ if (role === "triage") return { level: "read", designTools: false };
277
+ if (role === "qa") return { level: "write", designTools: false };
278
+ return { level: "edit", designTools: role === "design" };
279
+ }
280
+
281
+ // src/adapters/claude.ts
282
+ var BILLING_VARS_TO_UNSET = [
283
+ "ANTHROPIC_API_KEY",
284
+ "ANTHROPIC_AUTH_TOKEN",
285
+ "CLAUDE_CODE_USE_VERTEX",
286
+ "ANTHROPIC_VERTEX_PROJECT_ID",
287
+ "ANTHROPIC_VERTEX_REGION",
288
+ "CLOUD_ML_REGION",
289
+ "ANTHROPIC_MODEL"
290
+ ];
291
+ var DISALLOWED_TOOLS = [
292
+ "Bash(rm -rf:*)",
293
+ "Bash(rm -fr:*)",
294
+ "Bash(git push --force*)",
295
+ "Bash(git push -f*)",
296
+ "Bash(curl*| sh)",
297
+ "Bash(curl*|sh)",
298
+ "Bash(curl*| bash)",
299
+ "Bash(curl*|bash)",
300
+ "Bash(sudo:*)"
301
+ ];
302
+ function claudeToolsFor(p) {
303
+ const tablation = "mcp__tablation__*";
304
+ if (p.level === "read") return ["Bash", "Read", tablation];
305
+ if (p.level === "write") return ["Bash", "Read", "Write", "Grep", "Glob", tablation];
306
+ const base = ["Bash", "Read", "Edit", "Write", "Grep", "Glob", tablation];
307
+ return p.designTools ? [...base, "Skill", "Artifact"] : base;
308
+ }
309
+ function allowedTools(role) {
310
+ return claudeToolsFor(profileFor(role));
311
+ }
312
+ var claudeAdapter = {
313
+ harness: "claude",
314
+ displayName: "Claude Code",
315
+ defaultBin: "claude",
316
+ defaultModel: "claude-sonnet-5",
317
+ defaultTiers: {
318
+ light: { model: "claude-haiku-4-5-20251001" },
319
+ standard: { model: "claude-sonnet-5-5" },
320
+ deep: { model: "claude-opus-5-5" }
321
+ },
322
+ limitations: [
323
+ "Bash is not sandboxed: the deny list refuses known destructive patterns, but a one-liner reaching the same end another way is not caught",
324
+ "the write profile (QA) withholds the Edit tool, not file writes \u2014 Bash can still write files",
325
+ "git push is not blocked, only forced pushes"
326
+ ],
327
+ invocation(spec, o) {
328
+ const args = [
329
+ "-p",
330
+ "--allowedTools",
331
+ ...allowedTools(o.role),
332
+ "--disallowedTools",
333
+ ...DISALLOWED_TOOLS,
334
+ "--model",
335
+ o.model,
336
+ "--output-format",
337
+ "stream-json",
338
+ "--verbose",
339
+ // Registers the tracker's own MCP tool surface for this run only —
340
+ // `${CREW_API_KEY}` is interpolated by the CLI from the environment,
341
+ // so the resolved key is never written to an argv string or a config
342
+ // file on disk. Skipped when there's no key to send: a route `crew
343
+ // connect` hasn't resolved yet has nothing to authenticate with.
344
+ ...o.mcp ? ["--mcp-config", JSON.stringify({
345
+ mcpServers: {
346
+ tablation: {
347
+ type: "http",
348
+ url: o.mcp.url,
349
+ headers: { Authorization: `Bearer \${${o.mcp.keyVar}}` }
350
+ }
351
+ }
352
+ })] : [],
353
+ ...spec.args
354
+ ];
355
+ return spec.launcher ? { bin: spec.launcher, args: [spec.bin, ...args] } : { bin: spec.bin, args };
356
+ },
357
+ runEnv(spec) {
358
+ return {
359
+ // Extended thinking is what makes the CLI emit `thinking` stream
360
+ // blocks at all — without it, Agent Log Cycles reporting has nothing
361
+ // to report (ISSUE-558).
362
+ ...spec.maxThinkingTokens > 0 ? { MAX_THINKING_TOKENS: String(spec.maxThinkingTokens) } : {},
363
+ ...spec.env
364
+ };
365
+ },
366
+ unsetEnv: BILLING_VARS_TO_UNSET,
367
+ mapStreamLine,
368
+ extractResult,
369
+ extractTurnTokens,
370
+ inspectMcp(o) {
371
+ const s = inspectClaudeMcp(o);
372
+ return s.state === "no-claude" ? { state: "no-binary" } : s;
373
+ },
374
+ ensureMcp: ensureClaudeMcp
375
+ };
376
+
377
+ // src/toolchain.ts
378
+ var TIERS = ["light", "standard", "deep"];
379
+ var ADAPTERS = {
380
+ [claudeAdapter.harness]: claudeAdapter
381
+ };
382
+ var HARNESS_NAMES = Object.keys(ADAPTERS);
383
+ function isHarness(name) {
384
+ return Object.hasOwn(ADAPTERS, name);
385
+ }
386
+ function adapterFor(harness) {
387
+ const a = Object.hasOwn(ADAPTERS, harness) ? ADAPTERS[harness] : void 0;
388
+ if (!a) throw new Error(`unknown harness "${harness}" (have: ${HARNESS_NAMES.join(", ")})`);
389
+ return a;
390
+ }
391
+ var LEGACY_TOOLCHAIN = "claude";
392
+ function shipToolchains(ship) {
393
+ const explicit = ship.toolchains ?? [];
394
+ if (explicit.some((t) => t.name === LEGACY_TOOLCHAIN)) return explicit;
395
+ const a = claudeAdapter;
396
+ const legacy = {
397
+ name: LEGACY_TOOLCHAIN,
398
+ harness: a.harness,
399
+ bin: ship.agent.bin,
400
+ model: ship.agent.model,
401
+ tiers: a.defaultTiers,
402
+ args: [],
403
+ env: {},
404
+ maxThinkingTokens: ship.agent.maxThinkingTokens
405
+ };
406
+ return [legacy, ...explicit];
407
+ }
408
+ function resolveToolchain(ship, route) {
409
+ const name = route?.toolchain ?? ship.toolchain ?? LEGACY_TOOLCHAIN;
410
+ const found = shipToolchains(ship).find((t) => t.name === name);
411
+ if (!found) throw new Error(`toolchain "${name}" is not defined (have: ${shipToolchains(ship).map((t) => t.name).join(", ")})`);
412
+ return found;
413
+ }
414
+
415
+ // src/config.ts
14
416
  import { getSessionStore, hostFromUrl } from "@tablation/client";
15
417
 
16
418
  // src/platform.ts
@@ -50,8 +452,8 @@ function explain(ship, required) {
50
452
  }
51
453
 
52
454
  // src/repo-config.ts
53
- import { readFileSync, existsSync } from "fs";
54
- import { join, basename } from "path";
455
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
456
+ import { join as join3, basename } from "path";
55
457
  import { parse } from "yaml";
56
458
  var SPEC_VERSION = 1;
57
459
  var REPO_CONFIG_FILENAMES = [".crew.yaml", ".crew.yml", ".crew.json"];
@@ -124,8 +526,8 @@ var MODES = ["local", "integrate", "ci_manual", "ci_auto", "external"];
124
526
  var PROVIDERS = ["github", "buildkite", "other", "none"];
125
527
  function findRepoConfig(dir) {
126
528
  for (const name of REPO_CONFIG_FILENAMES) {
127
- const p = join(dir, name);
128
- if (existsSync(p)) return p;
529
+ const p = join3(dir, name);
530
+ if (existsSync2(p)) return p;
129
531
  }
130
532
  return null;
131
533
  }
@@ -355,7 +757,7 @@ function parseRepoConfig(text, file) {
355
757
  function loadRepoConfig(dir) {
356
758
  const file = findRepoConfig(dir);
357
759
  if (!file) return null;
358
- return parseRepoConfig(readFileSync(file, "utf8"), file);
760
+ return parseRepoConfig(readFileSync3(file, "utf8"), file);
359
761
  }
360
762
  function hookLabel(cfg, hook2) {
361
763
  return cfg.labels[hook2] ?? cfg.hooks[hook2] ?? hook2;
@@ -620,6 +1022,8 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
620
1022
  "name",
621
1023
  "platform",
622
1024
  "agent",
1025
+ "toolchains",
1026
+ "toolchain",
623
1027
  "shell",
624
1028
  "extraPath",
625
1029
  "useNvm",
@@ -631,6 +1035,7 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
631
1035
  "apiKey",
632
1036
  "maxConcurrentAgents",
633
1037
  "streamRetentionDays",
1038
+ "eventsRotateBytes",
634
1039
  "reposBasePath",
635
1040
  "relayHost",
636
1041
  "relayPort",
@@ -658,7 +1063,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
658
1063
  "contract",
659
1064
  "resolved",
660
1065
  "promptSet",
661
- "hostPassengers"
1066
+ "hostPassengers",
1067
+ "toolchain"
662
1068
  ]);
663
1069
  function parseWorktreeName(v, where, file) {
664
1070
  const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
@@ -762,16 +1168,16 @@ function mergeRouteRelease(route, override) {
762
1168
  };
763
1169
  }
764
1170
  function expand(p, base) {
765
- let out = p.startsWith("~") ? p.replace(/^~/, homedir()) : p;
766
- out = out.replace(/\$\{?HOME\}?/g, homedir());
1171
+ let out = p.startsWith("~") ? p.replace(/^~/, homedir2()) : p;
1172
+ out = out.replace(/\$\{?HOME\}?/g, homedir2());
767
1173
  return isAbsolute(out) ? out : resolve(base, out);
768
1174
  }
769
1175
  function resolvePromptsDir(promptSet, crewHome, base) {
770
- if (!promptSet) return join2(crewHome, "prompts", "default");
1176
+ if (!promptSet) return join4(crewHome, "prompts", "default");
771
1177
  if (promptSet.includes("/") || promptSet.startsWith(".") || promptSet.startsWith("~")) {
772
1178
  return expand(promptSet, base);
773
1179
  }
774
- return join2(crewHome, "prompts", promptSet);
1180
+ return join4(crewHome, "prompts", promptSet);
775
1181
  }
776
1182
  var Missing = class {
777
1183
  paths = [];
@@ -795,7 +1201,7 @@ var Missing = class {
795
1201
  };
796
1202
  function crewVersion(crewHome) {
797
1203
  try {
798
- const pkg = JSON.parse(readFileSync2(join2(crewHome, "package.json"), "utf8"));
1204
+ const pkg = JSON.parse(readFileSync4(join4(crewHome, "package.json"), "utf8"));
799
1205
  return pkg.version ?? "0.0.0";
800
1206
  } catch {
801
1207
  return "0.0.0";
@@ -805,26 +1211,26 @@ function configSearchPath(crewHome) {
805
1211
  if (process.env.CREW_CONFIG) return [resolve(process.env.CREW_CONFIG)];
806
1212
  const xdg = process.env.XDG_CONFIG_HOME;
807
1213
  return [
808
- ...xdg ? [join2(xdg, "crew", "crew.yaml")] : [],
809
- join2(homedir(), ".config", "crew", "crew.yaml"),
1214
+ ...xdg ? [join4(xdg, "crew", "crew.yaml")] : [],
1215
+ join4(homedir2(), ".config", "crew", "crew.yaml"),
810
1216
  resolve(crewHome, "crew.yaml")
811
1217
  ];
812
1218
  }
813
1219
  function findConfigFile(crewHome) {
814
1220
  const candidates = configSearchPath(crewHome);
815
- return candidates.find((c) => existsSync2(c)) ?? candidates[candidates.length - 1];
1221
+ return candidates.find((c) => existsSync3(c)) ?? candidates[candidates.length - 1];
816
1222
  }
817
- var defaultConfigPath = () => join2(process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"), "crew", "crew.yaml");
1223
+ var defaultConfigPath = () => join4(process.env.XDG_CONFIG_HOME ?? join4(homedir2(), ".config"), "crew", "crew.yaml");
818
1224
  function routeSlug(route) {
819
1225
  return route.replace(/\//g, "-");
820
1226
  }
821
1227
  function resolvedPathFor(stateDir, route) {
822
1228
  const [workspace, project] = splitRoute(route);
823
- return join2(stateDir, "resolved", workspace, `${project}.json`);
1229
+ return join4(stateDir, "resolved", workspace, `${project}.json`);
824
1230
  }
825
1231
  function apiKeyPathFor(stateDir, route) {
826
1232
  const [workspace, project] = splitRoute(route);
827
- return join2(stateDir, "keys", workspace, `${project}.env`);
1233
+ return join4(stateDir, "keys", workspace, `${project}.env`);
828
1234
  }
829
1235
  function splitRoute(route) {
830
1236
  const parts = route.split("/");
@@ -835,9 +1241,9 @@ function splitRoute(route) {
835
1241
  }
836
1242
  function readResolvedFile(stateDir, route) {
837
1243
  const path = resolvedPathFor(stateDir, route);
838
- if (!existsSync2(path)) return void 0;
1244
+ if (!existsSync3(path)) return void 0;
839
1245
  try {
840
- return JSON.parse(readFileSync2(path, "utf8"));
1246
+ return JSON.parse(readFileSync4(path, "utf8"));
841
1247
  } catch (e) {
842
1248
  throw new ConfigError(`${path}: not valid JSON \u2014 ${e.message}`);
843
1249
  }
@@ -893,23 +1299,23 @@ function parseResolved(raw, m, where) {
893
1299
  }
894
1300
  function loadConfig(crewHome, configFile, opts = {}) {
895
1301
  const file = configFile ?? findConfigFile(crewHome);
896
- if (opts.text === void 0 && !existsSync2(file)) {
1302
+ if (opts.text === void 0 && !existsSync3(file)) {
897
1303
  throw new ConfigError(
898
1304
  `no config found. Looked in:
899
1305
  ` + configSearchPath(crewHome).map((c) => ` - ${c}`).join("\n") + `
900
1306
 
901
1307
  Create the first of those:
902
- mkdir -p ${dirname(defaultConfigPath())} && cp ${join2(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
1308
+ mkdir -p ${dirname2(defaultConfigPath())} && cp ${join4(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
903
1309
  );
904
1310
  }
905
1311
  let raw;
906
1312
  try {
907
- raw = parse2(opts.text ?? readFileSync2(file, "utf8"));
1313
+ raw = parse2(opts.text ?? readFileSync4(file, "utf8"));
908
1314
  } catch (e) {
909
1315
  throw new ConfigError(`${file}: not valid YAML \u2014 ${e.message}`);
910
1316
  }
911
1317
  if (!raw || typeof raw !== "object") throw new ConfigError(`${file} is empty or not a mapping`);
912
- const base = dirname(file);
1318
+ const base = dirname2(file);
913
1319
  const missing = new Missing();
914
1320
  rejectUnknownKeys(raw, /* @__PURE__ */ new Set(["ship", "routes"]), "top-level", file);
915
1321
  const shipRaw = raw.ship ?? {};
@@ -926,10 +1332,20 @@ Create the first of those:
926
1332
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
927
1333
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
928
1334
  }
1335
+ const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
1336
+ if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
1337
+ missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
1338
+ }
929
1339
  const maxThinkingTokens = shipRaw.agent?.maxThinkingTokens === void 0 ? DEFAULT_MAX_THINKING_TOKENS : Number(shipRaw.agent.maxThinkingTokens);
930
1340
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
931
1341
  missing.add(`ship.agent.maxThinkingTokens must be a non-negative integer (got "${shipRaw.agent?.maxThinkingTokens}")`);
932
1342
  }
1343
+ const toolchains = parseToolchains(shipRaw.toolchains, base, missing, file);
1344
+ const toolchainNames = /* @__PURE__ */ new Set([LEGACY_TOOLCHAIN, ...toolchains.map((x) => x.name)]);
1345
+ const shipToolchain = shipRaw.toolchain === void 0 ? void 0 : String(shipRaw.toolchain);
1346
+ if (shipToolchain !== void 0 && !toolchainNames.has(shipToolchain)) {
1347
+ missing.add(`ship.toolchain "${shipToolchain}" is not defined (have: ${[...toolchainNames].join(", ")})`);
1348
+ }
933
1349
  const stateDir = expand(shipRaw.stateDir ?? "state", base);
934
1350
  const routesRaw = raw.routes;
935
1351
  if (!opts.allowNoRoutes && (!Array.isArray(routesRaw) || routesRaw.length === 0)) {
@@ -940,7 +1356,7 @@ Create the first of those:
940
1356
  const where = `routes[${i}]`;
941
1357
  const routeMissing = new Missing();
942
1358
  try {
943
- return [parseOneRoute(c, where, file, base, stateDir, raw, routeMissing, crewHome)];
1359
+ return [parseOneRoute(c, where, file, base, stateDir, raw, routeMissing, crewHome, toolchainNames)];
944
1360
  } catch (e) {
945
1361
  const label = typeof c?.route === "string" && c.route ? `"${c.route}"` : where;
946
1362
  routeWarnings.push(`route ${label} dropped \u2014 ${e.message}`);
@@ -963,15 +1379,18 @@ Create the first of those:
963
1379
  model: shipRaw.agent?.model ?? "claude-sonnet-5",
964
1380
  maxThinkingTokens
965
1381
  },
1382
+ ...toolchains.length > 0 ? { toolchains } : {},
1383
+ ...shipToolchain !== void 0 ? { toolchain: shipToolchain } : {},
966
1384
  shell: shipRaw.shell,
967
1385
  extraPath: shipRaw.extraPath,
968
1386
  useNvm: shipRaw.useNvm !== false,
969
1387
  nvmSh: shipRaw.nvmSh,
970
1388
  stateDir,
971
- logFile: expand(shipRaw.logFile ?? join2(tmpdir(), "crew.log"), base),
1389
+ logFile: expand(shipRaw.logFile ?? join4(tmpdir(), "crew.log"), base),
972
1390
  userAgent: shipRaw.userAgent ?? `Mozilla/5.0 CrewAgent/${crewVersion(crewHome)}`,
973
1391
  maxConcurrentAgents,
974
1392
  streamRetentionDays,
1393
+ eventsRotateBytes,
975
1394
  relayHost: shipRaw.relayHost ? String(shipRaw.relayHost) : void 0,
976
1395
  // 2222 matches the relay's own RELAY_SSH_PORT default (crew-relay README).
977
1396
  relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222,
@@ -985,8 +1404,70 @@ Create the first of those:
985
1404
  warnings: routeWarnings
986
1405
  };
987
1406
  }
988
- function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
1407
+ function expandCommand(cmd, base) {
1408
+ return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
1409
+ }
1410
+ var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "model", "tiers", "args", "env", "maxThinkingTokens"]);
1411
+ function parseToolchains(raw, base, missing, file) {
1412
+ if (raw === void 0 || raw === null) return [];
1413
+ if (typeof raw !== "object" || Array.isArray(raw)) {
1414
+ missing.add("ship.toolchains must be a mapping of name \u2192 toolchain");
1415
+ return [];
1416
+ }
1417
+ const out = [];
1418
+ for (const [name, entryRaw] of Object.entries(raw)) {
1419
+ const where = `ship.toolchains.${name}`;
1420
+ if (!entryRaw || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
1421
+ missing.add(`${where} must be a mapping`);
1422
+ continue;
1423
+ }
1424
+ rejectUnknownKeys(entryRaw, TOOLCHAIN_KEYS, where, file);
1425
+ const harness = String(entryRaw.harness ?? "");
1426
+ if (!isHarness(harness)) {
1427
+ missing.add(`${where}.harness must be one of ${HARNESS_NAMES.join("|")} (got "${harness}")`);
1428
+ continue;
1429
+ }
1430
+ const adapter = adapterFor(harness);
1431
+ const tiers = { ...adapter.defaultTiers };
1432
+ for (const [tier, v] of Object.entries(entryRaw.tiers ?? {})) {
1433
+ if (!TIERS.includes(tier)) {
1434
+ missing.add(`${where}.tiers.${tier} is not a tier (have: ${TIERS.join(", ")})`);
1435
+ continue;
1436
+ }
1437
+ if (!v || typeof v.model !== "string" || !v.model) {
1438
+ missing.add(`${where}.tiers.${tier}.model is required`);
1439
+ continue;
1440
+ }
1441
+ tiers[tier] = { model: v.model, ...v.reasoning !== void 0 ? { reasoning: String(v.reasoning) } : {} };
1442
+ }
1443
+ const args = entryRaw.args ?? [];
1444
+ if (!Array.isArray(args)) missing.add(`${where}.args must be a list of strings`);
1445
+ const env = entryRaw.env ?? {};
1446
+ if (typeof env !== "object" || Array.isArray(env)) missing.add(`${where}.env must be a mapping of name \u2192 value`);
1447
+ const maxThinkingTokens = entryRaw.maxThinkingTokens === void 0 ? DEFAULT_MAX_THINKING_TOKENS : Number(entryRaw.maxThinkingTokens);
1448
+ if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
1449
+ missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
1450
+ }
1451
+ out.push({
1452
+ name,
1453
+ harness,
1454
+ bin: expandCommand(String(entryRaw.bin ?? adapter.defaultBin), base),
1455
+ ...entryRaw.launcher ? { launcher: expandCommand(String(entryRaw.launcher), base) } : {},
1456
+ model: String(entryRaw.model ?? tiers.standard?.model ?? adapter.defaultModel),
1457
+ tiers,
1458
+ args: Array.isArray(args) ? args.map(String) : [],
1459
+ env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
1460
+ maxThinkingTokens
1461
+ });
1462
+ }
1463
+ return out;
1464
+ }
1465
+ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
989
1466
  rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
1467
+ const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
1468
+ if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
1469
+ missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
1470
+ }
990
1471
  const route = missing.req(c?.route, `${where}.route`);
991
1472
  const routeParts = route ? route.split("/") : [];
992
1473
  const routeWellFormed = routeParts.length === 2 && routeParts.every(Boolean);
@@ -1035,6 +1516,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
1035
1516
  reposBasePath: expand(String(c?.reposBasePath ?? raw.ship?.reposBasePath ?? DEFAULT_REPOS_BASE_PATH), base),
1036
1517
  repoOverrides,
1037
1518
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
1519
+ ...toolchain !== void 0 ? { toolchain } : {},
1038
1520
  promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
1039
1521
  weight,
1040
1522
  baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -1102,8 +1584,8 @@ function repoIdForName(r, name) {
1102
1584
  }
1103
1585
  function resolveApiKey(r) {
1104
1586
  if (r.apiKey) return r.apiKey;
1105
- if (r.apiKeyFile && r.apiKeyVar && existsSync2(r.apiKeyFile)) {
1106
- for (const line of readFileSync2(r.apiKeyFile, "utf8").split("\n")) {
1587
+ if (r.apiKeyFile && r.apiKeyVar && existsSync3(r.apiKeyFile)) {
1588
+ for (const line of readFileSync4(r.apiKeyFile, "utf8").split("\n")) {
1107
1589
  const m = line.match(new RegExp(`^${r.apiKeyVar}=(.*)$`));
1108
1590
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1109
1591
  }
@@ -1166,7 +1648,7 @@ function configuredMembers(rt) {
1166
1648
  return out;
1167
1649
  }
1168
1650
  function defaultRepoDir(basePath, workspace, repoName) {
1169
- return join2(basePath, workspace, repoName);
1651
+ return join4(basePath, workspace, repoName);
1170
1652
  }
1171
1653
  function reposOf(r) {
1172
1654
  const named = Object.entries(r.repos);
@@ -1190,7 +1672,7 @@ function passengerRepoTargets(r) {
1190
1672
  return reposOf(r).filter((t) => r.repoOverrides[t.name]?.hostPassengers !== false);
1191
1673
  }
1192
1674
  function passengerCheckoutDir(basePath, workspace, repoName) {
1193
- return join2(basePath, workspace, ".passenger-checkouts", repoName);
1675
+ return join4(basePath, workspace, ".passenger-checkouts", repoName);
1194
1676
  }
1195
1677
  function passengerCheckoutTargets(r) {
1196
1678
  if (!r.hostPassengers) return [];
@@ -1322,17 +1804,17 @@ function rosterMarkdown(roster, meId) {
1322
1804
  }
1323
1805
 
1324
1806
  // src/state.ts
1325
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
1326
- import { join as join3 } from "path";
1807
+ import { readFileSync as readFileSync5, writeFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
1808
+ import { join as join5 } from "path";
1327
1809
  var EPOCH = "1970-01-01T00:00:00.000Z";
1328
1810
  var State = class _State {
1329
1811
  dir;
1330
1812
  constructor(dir) {
1331
1813
  this.dir = dir;
1332
- mkdirSync(dir, { recursive: true });
1814
+ mkdirSync2(dir, { recursive: true });
1333
1815
  }
1334
1816
  path(...parts) {
1335
- return join3(this.dir, ...parts);
1817
+ return join5(this.dir, ...parts);
1336
1818
  }
1337
1819
  /**
1338
1820
  * The timestamp every "new since last poll" check is measured against.
@@ -1343,7 +1825,7 @@ var State = class _State {
1343
1825
  */
1344
1826
  watermark() {
1345
1827
  try {
1346
- const v = readFileSync3(this.path(".poll-watermark"), "utf8").trim();
1828
+ const v = readFileSync5(this.path(".poll-watermark"), "utf8").trim();
1347
1829
  return v || EPOCH;
1348
1830
  } catch {
1349
1831
  return EPOCH;
@@ -1368,10 +1850,10 @@ var State = class _State {
1368
1850
  }
1369
1851
  /** Whole-crew pause, and the per-role sentinels one level down. */
1370
1852
  isPaused() {
1371
- return existsSync3(this.path(".crew-paused"));
1853
+ return existsSync4(this.path(".crew-paused"));
1372
1854
  }
1373
1855
  isRolePaused(role) {
1374
- return existsSync3(this.path(`.role-paused-${role}`));
1856
+ return existsSync4(this.path(`.role-paused-${role}`));
1375
1857
  }
1376
1858
  pausedRoles(roles) {
1377
1859
  return new Set(roles.filter((r) => this.isRolePaused(r)));
@@ -1380,7 +1862,7 @@ var State = class _State {
1380
1862
  writeFileSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
1381
1863
  }
1382
1864
  resume(role) {
1383
- rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1865
+ rmSync2(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1384
1866
  }
1385
1867
  /**
1386
1868
  * The release phase (merge, deploy, release) has its own sentinel, one
@@ -1388,11 +1870,11 @@ var State = class _State {
1388
1870
  * while agents keep working.
1389
1871
  */
1390
1872
  isReleasePaused() {
1391
- return existsSync3(this.path(".release-paused"));
1873
+ return existsSync4(this.path(".release-paused"));
1392
1874
  }
1393
1875
  setReleasePaused(paused) {
1394
1876
  if (paused) writeFileSync(this.path(".release-paused"), "");
1395
- else rmSync(this.path(".release-paused"), { force: true });
1877
+ else rmSync2(this.path(".release-paused"), { force: true });
1396
1878
  }
1397
1879
  /**
1398
1880
  * Why a release cycle must not run right now, or null when it may. Every
@@ -1429,7 +1911,7 @@ var State = class _State {
1429
1911
  const name = _State.safe(rawName);
1430
1912
  const file = this.path(`.${name}.lock`);
1431
1913
  try {
1432
- const existing = Number.parseInt(readFileSync3(file, "utf8").trim(), 10);
1914
+ const existing = Number.parseInt(readFileSync5(file, "utf8").trim(), 10);
1433
1915
  if (Number.isFinite(existing) && existing > 0) {
1434
1916
  try {
1435
1917
  process.kill(existing, 0);
@@ -1445,7 +1927,7 @@ var State = class _State {
1445
1927
  ok: true,
1446
1928
  release: () => {
1447
1929
  try {
1448
- rmSync(file, { force: true });
1930
+ rmSync2(file, { force: true });
1449
1931
  } catch {
1450
1932
  }
1451
1933
  }
@@ -1523,7 +2005,7 @@ var State = class _State {
1523
2005
  const heldReported = `.release-held-${suffix}`;
1524
2006
  const read = (f) => {
1525
2007
  try {
1526
- return readFileSync3(this.path(f), "utf8").trim() || null;
2008
+ return readFileSync5(this.path(f), "utf8").trim() || null;
1527
2009
  } catch {
1528
2010
  return null;
1529
2011
  }
@@ -1537,7 +2019,7 @@ var State = class _State {
1537
2019
  return n;
1538
2020
  },
1539
2021
  clearBlocked: () => {
1540
- rmSync(this.path(blocked), { force: true });
2022
+ rmSync2(this.path(blocked), { force: true });
1541
2023
  },
1542
2024
  /**
1543
2025
  * The commit a deploy last failed on.
@@ -1553,7 +2035,7 @@ var State = class _State {
1553
2035
  `);
1554
2036
  },
1555
2037
  clearDeployFailed: () => {
1556
- rmSync(this.path(failed), { force: true });
2038
+ rmSync2(this.path(failed), { force: true });
1557
2039
  },
1558
2040
  /**
1559
2041
  * Consecutive test-gate failures on the same head (CREW-1368). A failure
@@ -1568,12 +2050,12 @@ var State = class _State {
1568
2050
  return next;
1569
2051
  },
1570
2052
  clearTestGateFailed: () => {
1571
- rmSync(this.path(gateFailed), { force: true });
2053
+ rmSync2(this.path(gateFailed), { force: true });
1572
2054
  },
1573
2055
  /** Whether the board was already told about this head's red gate. */
1574
2056
  heldReported: () => {
1575
2057
  try {
1576
- return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
2058
+ return readFileSync5(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1577
2059
  } catch {
1578
2060
  return null;
1579
2061
  }
@@ -1583,7 +2065,7 @@ var State = class _State {
1583
2065
  `);
1584
2066
  },
1585
2067
  clearHeldReported: () => {
1586
- rmSync(this.path(heldReported), { force: true });
2068
+ rmSync2(this.path(heldReported), { force: true });
1587
2069
  },
1588
2070
  testGateReported: (sha) => read(gateReported) === sha,
1589
2071
  noteTestGateReported: (sha) => {
@@ -1610,13 +2092,13 @@ var State = class _State {
1610
2092
  const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
1611
2093
  const read = (f) => {
1612
2094
  try {
1613
- return readFileSync3(this.path(f), "utf8").trim() || null;
2095
+ return readFileSync5(this.path(f), "utf8").trim() || null;
1614
2096
  } catch {
1615
2097
  return null;
1616
2098
  }
1617
2099
  };
1618
2100
  const clear2 = () => {
1619
- for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync(this.path(f), { force: true });
2101
+ for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync2(this.path(f), { force: true });
1620
2102
  };
1621
2103
  return {
1622
2104
  /** Consecutive cycles this route had actionable work and did not win. */
@@ -1675,7 +2157,7 @@ var State = class _State {
1675
2157
  return {
1676
2158
  previous: () => {
1677
2159
  try {
1678
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
2160
+ const parsed = JSON.parse(readFileSync5(this.path(file), "utf8"));
1679
2161
  return parsed && typeof parsed === "object" ? parsed : {};
1680
2162
  } catch {
1681
2163
  return {};
@@ -1684,153 +2166,57 @@ var State = class _State {
1684
2166
  persist: (next) => {
1685
2167
  writeFileSync(this.path(file), JSON.stringify(next));
1686
2168
  }
1687
- };
1688
- }
1689
- /**
1690
- * Open ship-level attention items (CREW-1373), ship-wide rather than
1691
- * per-route: a parked role or a missing hook command is about this
1692
- * machine. A corrupt or missing file reads as empty.
1693
- */
1694
- shipAttention() {
1695
- const file = ".ship-attention.json";
1696
- return {
1697
- previous: () => {
1698
- try {
1699
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
1700
- return Array.isArray(parsed) ? parsed : [];
1701
- } catch {
1702
- return [];
1703
- }
1704
- },
1705
- persist: (next) => {
1706
- writeFileSync(this.path(file), JSON.stringify(next));
1707
- }
1708
- };
1709
- }
1710
- /**
1711
- * When the release phase last ran a cycle (CREW-1373), as the mtime of a
1712
- * marker file. `touch()` is called at the start and the end of every cycle
1713
- * so a long deploy does not read as a stopped timer.
1714
- */
1715
- releaseHeartbeat() {
1716
- const file = ".release-last-run";
1717
- return {
1718
- touch: () => {
1719
- writeFileSync(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
1720
- },
1721
- at: () => {
1722
- try {
1723
- return statSync(this.path(file)).mtimeMs;
1724
- } catch {
1725
- return null;
1726
- }
1727
- }
1728
- };
1729
- }
1730
- readInt(file) {
1731
- try {
1732
- return Number.parseInt(readFileSync3(this.path(file), "utf8").trim(), 10) || 0;
1733
- } catch {
1734
- return 0;
1735
- }
1736
- }
1737
- };
1738
-
1739
- // src/events.ts
1740
- import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
1741
- import { join as join4, dirname as dirname2 } from "path";
1742
- function render(e) {
1743
- const scope = e.role ? `${e.step}[${e.role}]` : e.step;
1744
- const ticket = e.ticket ? ` ${e.ticket}` : "";
1745
- const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
1746
- return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
1747
- }
1748
- var Emitter = class _Emitter {
1749
- cycle;
1750
- opts;
1751
- now;
1752
- step = "poll";
1753
- role;
1754
- constructor(opts) {
1755
- this.opts = opts;
1756
- this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
1757
- this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
1758
- for (const f of [opts.eventFile, opts.logFile]) {
1759
- if (!f) continue;
1760
- try {
1761
- mkdirSync2(dirname2(f), { recursive: true });
1762
- } catch {
1763
- }
1764
- }
1765
- }
1766
- /** Everything emitted after this belongs to `step`, until the next one. */
1767
- enter(step, role) {
1768
- this.step = step;
1769
- this.role = role;
1770
- }
1771
- emit(message, extra = {}) {
1772
- const e = {
1773
- at: this.now().toISOString(),
1774
- cycle: this.cycle,
1775
- route: this.opts.route,
1776
- step: extra.step ?? this.step,
1777
- level: extra.level ?? "info",
1778
- message,
1779
- ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
1780
- ...extra.ticket ? { ticket: extra.ticket } : {},
1781
- ...extra.data ? { data: extra.data } : {}
1782
- };
1783
- const line = render(e);
1784
- try {
1785
- if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
1786
- `);
1787
- } catch {
1788
- }
1789
- try {
1790
- if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
1791
- `);
1792
- } catch {
1793
- }
1794
- try {
1795
- (this.opts.console ?? ((l) => process.stderr.write(`${l}
1796
- `)))(line);
1797
- } catch {
1798
- }
1799
- return e;
1800
- }
1801
- warn(message, extra = {}) {
1802
- return this.emit(message, { ...extra, level: "warn" });
2169
+ };
1803
2170
  }
1804
- error(message, extra = {}) {
1805
- return this.emit(message, { ...extra, level: "error" });
2171
+ /**
2172
+ * Open ship-level attention items (CREW-1373), ship-wide rather than
2173
+ * per-route: a parked role or a missing hook command is about this
2174
+ * machine. A corrupt or missing file reads as empty.
2175
+ */
2176
+ shipAttention() {
2177
+ const file = ".ship-attention.json";
2178
+ return {
2179
+ previous: () => {
2180
+ try {
2181
+ const parsed = JSON.parse(readFileSync5(this.path(file), "utf8"));
2182
+ return Array.isArray(parsed) ? parsed : [];
2183
+ } catch {
2184
+ return [];
2185
+ }
2186
+ },
2187
+ persist: (next) => {
2188
+ writeFileSync(this.path(file), JSON.stringify(next));
2189
+ }
2190
+ };
1806
2191
  }
1807
2192
  /**
1808
- * A view of this emitter labelling every event with a different route,
1809
- * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
1810
- * Emitter is stamped with just one route (see cli.ts) — a phase that
1811
- * actually knows which route it's working (release, per-repo poll) uses
1812
- * this instead, so its events aren't all attributed to `routes[0]`.
2193
+ * When the release phase last ran a cycle (CREW-1373), as the mtime of a
2194
+ * marker file. `touch()` is called at the start and the end of every cycle
2195
+ * so a long deploy does not read as a stopped timer.
1813
2196
  */
1814
- forRoute(route) {
1815
- return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
2197
+ releaseHeartbeat() {
2198
+ const file = ".release-last-run";
2199
+ return {
2200
+ touch: () => {
2201
+ writeFileSync(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
2202
+ },
2203
+ at: () => {
2204
+ try {
2205
+ return statSync2(this.path(file)).mtimeMs;
2206
+ } catch {
2207
+ return null;
2208
+ }
2209
+ }
2210
+ };
1816
2211
  }
1817
- /** Times a step and emits its outcome, so a view can show duration. */
1818
- async timed(step, message, fn, role) {
1819
- this.enter(step, role);
1820
- const started = this.now().getTime();
2212
+ readInt(file) {
1821
2213
  try {
1822
- const result = await fn();
1823
- this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
1824
- return result;
1825
- } catch (err) {
1826
- this.error(`${message} \u2014 ${err.message}`, {
1827
- data: { ms: this.now().getTime() - started, ok: false }
1828
- });
1829
- throw err;
2214
+ return Number.parseInt(readFileSync5(this.path(file), "utf8").trim(), 10) || 0;
2215
+ } catch {
2216
+ return 0;
1830
2217
  }
1831
2218
  }
1832
2219
  };
1833
- var eventFileFor = (stateDir) => join4(stateDir, "events.jsonl");
1834
2220
 
1835
2221
  // src/tracker.ts
1836
2222
  import { hostname } from "os";
@@ -2129,10 +2515,10 @@ function describeRelease(o, route) {
2129
2515
  }
2130
2516
 
2131
2517
  // src/install.ts
2132
- import { dirname as dirname3, join as join5, extname } from "path";
2133
- import { homedir as homedir2 } from "os";
2134
- import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2135
- import { spawnSync } from "child_process";
2518
+ import { dirname as dirname3, join as join6, extname } from "path";
2519
+ import { homedir as homedir3 } from "os";
2520
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2521
+ import { spawnSync as spawnSync2 } from "child_process";
2136
2522
  import { createHash } from "crypto";
2137
2523
 
2138
2524
  // src/runtime-info.ts
@@ -2143,7 +2529,7 @@ function isCompiledBinary(moduleUrl) {
2143
2529
  // src/install.ts
2144
2530
  function crewInvocation(crewHome) {
2145
2531
  if (isCompiledBinary(import.meta.url)) return [process.execPath];
2146
- return [process.execPath, join5(crewHome, "bin", "crew")];
2532
+ return [process.execPath, join6(crewHome, "bin", "crew")];
2147
2533
  }
2148
2534
  var INTERVAL_SECONDS = 120;
2149
2535
  function labelFor(crewHome, job) {
@@ -2181,17 +2567,17 @@ function pathFor(ship) {
2181
2567
  return parts.join(":");
2182
2568
  }
2183
2569
  function findOnPath(bin, searchPath) {
2184
- const candidates = bin.includes("/") ? [bin] : searchPath.split(":").filter(Boolean).map((dir) => join5(dir, bin));
2570
+ const candidates = bin.includes("/") ? [bin] : searchPath.split(":").filter(Boolean).map((dir) => join6(dir, bin));
2185
2571
  for (const candidate of candidates) {
2186
2572
  try {
2187
2573
  accessSync(candidate, constants.X_OK);
2188
- if (statSync2(candidate).isFile()) return candidate;
2574
+ if (statSync3(candidate).isFile()) return candidate;
2189
2575
  } catch {
2190
2576
  }
2191
2577
  }
2192
2578
  return void 0;
2193
2579
  }
2194
- var COMMON_DOCKER_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", join5(homedir2(), ".docker", "bin")].join(":");
2580
+ var COMMON_DOCKER_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", join6(homedir3(), ".docker", "bin")].join(":");
2195
2581
  function dockerPathProblem(ship, shellPath = process.env.PATH ?? "") {
2196
2582
  const schedulerPath = pathFor(ship);
2197
2583
  if (findOnPath("docker", schedulerPath)) return void 0;
@@ -2203,7 +2589,7 @@ function xmlEscape(s) {
2203
2589
  }
2204
2590
  function planLaunchd(ship, crewHome, job) {
2205
2591
  const label = labelFor(crewHome, job);
2206
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2592
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2207
2593
  const crewLog = ship.logFile;
2208
2594
  const schedulerLog = schedulerLogFor(crewLog, job);
2209
2595
  const persistent = job === "run";
@@ -2255,8 +2641,8 @@ ${scheduleKeys}
2255
2641
  }
2256
2642
  function planSystemd(ship, crewHome, job) {
2257
2643
  const label = labelFor(crewHome, job);
2258
- const unitDir = join5(homedir2(), ".config", "systemd", "user");
2259
- const servicePath = join5(unitDir, `${label}.service`);
2644
+ const unitDir = join6(homedir3(), ".config", "systemd", "user");
2645
+ const servicePath = join6(unitDir, `${label}.service`);
2260
2646
  const crewLog = ship.logFile;
2261
2647
  const schedulerLog = schedulerLogFor(crewLog, job);
2262
2648
  const description = job === "release" ? "Tablation crew \u2014 release" : job === "passengers" ? "Tablation crew \u2014 Host Passengers sync" : "Tablation crew \u2014 persistent supervisor loop";
@@ -2293,7 +2679,7 @@ WantedBy=default.target
2293
2679
  schedulerLog
2294
2680
  };
2295
2681
  }
2296
- const timerPath = join5(unitDir, `${label}.timer`);
2682
+ const timerPath = join6(unitDir, `${label}.timer`);
2297
2683
  const service = `# Written by \`crew install\` \u2014 re-run it rather than hand-editing this file.
2298
2684
  [Unit]
2299
2685
  Description=${description}
@@ -2360,7 +2746,7 @@ function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2360
2746
  function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2361
2747
  const label = labelFor(crewHome, job);
2362
2748
  if (hostShip === "macos") {
2363
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2749
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2364
2750
  return {
2365
2751
  mechanism: "launchd",
2366
2752
  unitPaths: [unitPath],
@@ -2370,18 +2756,18 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2370
2756
  };
2371
2757
  }
2372
2758
  if (hostShip === "linux" && hasSystemd) {
2373
- const unitDir = join5(homedir2(), ".config", "systemd", "user");
2759
+ const unitDir = join6(homedir3(), ".config", "systemd", "user");
2374
2760
  if (job === "run") {
2375
2761
  return {
2376
2762
  mechanism: "systemd",
2377
- unitPaths: [join5(unitDir, `${label}.service`)],
2763
+ unitPaths: [join6(unitDir, `${label}.service`)],
2378
2764
  crontabLine: null,
2379
2765
  unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.service`]]
2380
2766
  };
2381
2767
  }
2382
2768
  return {
2383
2769
  mechanism: "systemd",
2384
- unitPaths: [join5(unitDir, `${label}.service`), join5(unitDir, `${label}.timer`)],
2770
+ unitPaths: [join6(unitDir, `${label}.service`), join6(unitDir, `${label}.timer`)],
2385
2771
  crontabLine: null,
2386
2772
  unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.timer`]]
2387
2773
  };
@@ -2399,7 +2785,7 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2399
2785
  function planDaemonControl(crewHome, hostShip, hasSystemd) {
2400
2786
  const label = labelFor(crewHome, "run");
2401
2787
  if (hostShip === "macos") {
2402
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2788
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2403
2789
  return {
2404
2790
  mechanism: "launchd",
2405
2791
  startCommand: ["launchctl", "load", unitPath],
@@ -2423,7 +2809,7 @@ function planDaemonControl(crewHome, hostShip, hasSystemd) {
2423
2809
  }
2424
2810
  function runAll(commands, cwd, log) {
2425
2811
  for (const [bin, ...args] of commands) {
2426
- const res = spawnSync(bin, args, { cwd, encoding: "utf8" });
2812
+ const res = spawnSync2(bin, args, { cwd, encoding: "utf8" });
2427
2813
  if (res.error || res.status !== 0) {
2428
2814
  log.warn(`${bin} ${args.join(" ")} exited ${res.status ?? "error"}`, {
2429
2815
  output: `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? String(res.error) : ""}`
@@ -2488,7 +2874,7 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2488
2874
  if (dryRun) {
2489
2875
  log.emit(`would run: launchctl remove ${plan.removeLabel}`);
2490
2876
  } else {
2491
- if (spawnSync("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
2877
+ if (spawnSync2("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
2492
2878
  log.emit(`removed loaded job ${plan.removeLabel}`);
2493
2879
  }
2494
2880
  }
@@ -2513,7 +2899,7 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2513
2899
  }
2514
2900
  var CREW_LAUNCHD_PREFIX = "com.tablation.crew";
2515
2901
  function launchAgentsDir() {
2516
- return join5(homedir2(), "Library", "LaunchAgents");
2902
+ return join6(homedir3(), "Library", "LaunchAgents");
2517
2903
  }
2518
2904
  function xmlUnescape(s) {
2519
2905
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
@@ -2525,17 +2911,17 @@ function programFromPlist(text) {
2525
2911
  return args.find((a) => a.endsWith("/bin/crew")) ?? args[0] ?? null;
2526
2912
  }
2527
2913
  function loadedCrewLabels() {
2528
- const list = spawnSync("launchctl", ["list"], { encoding: "utf8" });
2914
+ const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
2529
2915
  if (list.status !== 0) return [];
2530
2916
  return [...parseLaunchctlList(list.stdout ?? "").keys()].filter((l) => l.startsWith(CREW_LAUNCHD_PREFIX));
2531
2917
  }
2532
2918
  function listLaunchdUnits(dir = launchAgentsDir(), loaded = []) {
2533
2919
  const files = existsSync5(dir) ? readdirSync(dir) : [];
2534
2920
  const units = files.filter((f) => f.startsWith(CREW_LAUNCHD_PREFIX) && f.endsWith(".plist")).sort().map((f) => {
2535
- const plistPath = join5(dir, f);
2921
+ const plistPath = join6(dir, f);
2536
2922
  let program = null;
2537
2923
  try {
2538
- program = programFromPlist(readFileSync5(plistPath, "utf8"));
2924
+ program = programFromPlist(readFileSync6(plistPath, "utf8"));
2539
2925
  } catch {
2540
2926
  }
2541
2927
  return {
@@ -2548,7 +2934,7 @@ function listLaunchdUnits(dir = launchAgentsDir(), loaded = []) {
2548
2934
  const seen = new Set(units.map((u) => u.label));
2549
2935
  for (const label of [...loaded].sort()) {
2550
2936
  if (!label.startsWith(CREW_LAUNCHD_PREFIX) || seen.has(label)) continue;
2551
- units.push({ label, plistPath: join5(dir, `${label}.plist`), program: null, programExists: false, orphan: true });
2937
+ units.push({ label, plistPath: join6(dir, `${label}.plist`), program: null, programExists: false, orphan: true });
2552
2938
  }
2553
2939
  return units;
2554
2940
  }
@@ -2561,8 +2947,8 @@ function removeLaunchdUnit(unit, dryRun, log) {
2561
2947
  log.emit(`would unload and remove ${unit.label}`);
2562
2948
  return;
2563
2949
  }
2564
- if (existsSync5(unit.plistPath)) spawnSync("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
2565
- spawnSync("launchctl", ["remove", unit.label], { encoding: "utf8" });
2950
+ if (existsSync5(unit.plistPath)) spawnSync2("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
2951
+ spawnSync2("launchctl", ["remove", unit.label], { encoding: "utf8" });
2566
2952
  if (existsSync5(unit.plistPath)) unlinkSync(unit.plistPath);
2567
2953
  log.emit(`removed ${unit.label} (${unit.plistPath})`);
2568
2954
  }
@@ -2595,7 +2981,7 @@ function parseLaunchctlList(text) {
2595
2981
  return jobs;
2596
2982
  }
2597
2983
  function describeForeignLaunchdUnits(crewHome, dir) {
2598
- const list = spawnSync("launchctl", ["list"], { encoding: "utf8" });
2984
+ const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
2599
2985
  const jobs = parseLaunchctlList(list.status === 0 ? list.stdout ?? "" : "");
2600
2986
  const foreign = foreignLaunchdUnits(crewHome, dir, jobs.keys());
2601
2987
  return foreign.map((u) => {
@@ -2608,11 +2994,11 @@ function describeForeignLaunchdUnits(crewHome, dir) {
2608
2994
  });
2609
2995
  }
2610
2996
  function readCrontab() {
2611
- const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
2997
+ const res = spawnSync2("crontab", ["-l"], { encoding: "utf8" });
2612
2998
  return res.status === 0 ? res.stdout ?? "" : "";
2613
2999
  }
2614
3000
  function writeCrontab(text) {
2615
- spawnSync("crontab", ["-"], { input: text, encoding: "utf8" });
3001
+ spawnSync2("crontab", ["-"], { input: text, encoding: "utf8" });
2616
3002
  }
2617
3003
  function addCrontabLine(marker, line) {
2618
3004
  const existing = readCrontab();
@@ -2636,7 +3022,7 @@ function removeCrontabLine(marker) {
2636
3022
  }
2637
3023
  function detectSystemd() {
2638
3024
  if (process.platform !== "linux") return false;
2639
- const res = spawnSync("systemctl", ["--user", "status"], { encoding: "utf8" });
3025
+ const res = spawnSync2("systemctl", ["--user", "status"], { encoding: "utf8" });
2640
3026
  return !res.error;
2641
3027
  }
2642
3028
 
@@ -2648,10 +3034,10 @@ import { execFileSync } from "child_process";
2648
3034
  import {
2649
3035
  existsSync as existsSync6,
2650
3036
  mkdirSync as mkdirSync4,
2651
- openSync,
2652
- closeSync,
3037
+ openSync as openSync2,
3038
+ closeSync as closeSync2,
2653
3039
  writeSync,
2654
- statSync as statSync3,
3040
+ statSync as statSync4,
2655
3041
  unlinkSync as unlinkSync2
2656
3042
  } from "fs";
2657
3043
  import { dirname as dirname4, basename as basename3 } from "path";
@@ -3047,10 +3433,10 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3047
3433
  const deadline = Date.now() + timeoutMs;
3048
3434
  for (; ; ) {
3049
3435
  try {
3050
- const fd = openSync(lockPath, "wx");
3436
+ const fd = openSync2(lockPath, "wx");
3051
3437
  writeSync(fd, `${process.pid}
3052
3438
  `);
3053
- closeSync(fd);
3439
+ closeSync2(fd);
3054
3440
  return () => {
3055
3441
  try {
3056
3442
  unlinkSync2(lockPath);
@@ -3061,7 +3447,7 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3061
3447
  const err = e;
3062
3448
  if (err.code !== "EEXIST") throw err;
3063
3449
  try {
3064
- if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3450
+ if (Date.now() - statSync4(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3065
3451
  try {
3066
3452
  unlinkSync2(lockPath);
3067
3453
  } catch {
@@ -4756,7 +5142,7 @@ function materializeBranchForTicket(dir, cfg, t, role) {
4756
5142
 
4757
5143
  // src/poll.ts
4758
5144
  import { writeFileSync as writeFileSync3 } from "fs";
4759
- import { join as join6 } from "path";
5145
+ import { join as join7 } from "path";
4760
5146
 
4761
5147
  // src/stalled.ts
4762
5148
  var STALLED_AFTER_CYCLES = 30;
@@ -5028,7 +5414,7 @@ async function decideCycle(o) {
5028
5414
  }
5029
5415
  return decision;
5030
5416
  }
5031
- var digestPath = (stateDir, route, role) => join6(stateDir, `digest-${route}-${role}.md`);
5417
+ var digestPath = (stateDir, route, role) => join7(stateDir, `digest-${route}-${role}.md`);
5032
5418
  function writeDigest(o, d, role, stateDir) {
5033
5419
  const me = o.route.resolved?.seats[role];
5034
5420
  if (!me) return false;
@@ -5398,9 +5784,9 @@ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id
5398
5784
 
5399
5785
  // src/agent.ts
5400
5786
  import { spawn as spawn2 } from "child_process";
5401
- import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5787
+ import { readFileSync as readFileSync7, existsSync as existsSync7, statSync as statSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5402
5788
  import { finished } from "stream/promises";
5403
- import { join as join7, dirname as dirname5 } from "path";
5789
+ import { join as join8, dirname as dirname5 } from "path";
5404
5790
  import { createHash as createHash2 } from "crypto";
5405
5791
 
5406
5792
  // src/environment.ts
@@ -5685,54 +6071,6 @@ function renderEnvironment(i) {
5685
6071
  return lines.join("\n");
5686
6072
  }
5687
6073
 
5688
- // src/stream.ts
5689
- function toolTarget(name, input) {
5690
- if (!input) return void 0;
5691
- for (const key of ["file_path", "path", "notebook_path", "url", "pattern"]) {
5692
- const v = input[key];
5693
- if (typeof v === "string" && v) return v;
5694
- }
5695
- if (name === "Bash" && typeof input.command === "string") {
5696
- return input.command.trim().split(/\s+/)[0];
5697
- }
5698
- return void 0;
5699
- }
5700
- function mapStreamLine(line) {
5701
- const obj = line;
5702
- if (!obj || obj.type !== "assistant") return [];
5703
- const content = obj.message?.content;
5704
- if (!Array.isArray(content)) return [];
5705
- const out = [];
5706
- for (const block of content) {
5707
- if (block.type === "thinking") out.push({ kind: "thought", text: block.thinking });
5708
- else if (block.type === "text") out.push({ kind: "text", text: block.text });
5709
- else if (block.type === "tool_use") {
5710
- out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input) });
5711
- }
5712
- }
5713
- return out;
5714
- }
5715
- function extractTurnTokens(line) {
5716
- const obj = line;
5717
- if (!obj || obj.type !== "assistant") return void 0;
5718
- const usage2 = obj.message?.usage;
5719
- if (!usage2) return void 0;
5720
- return (usage2.input_tokens ?? 0) + (usage2.output_tokens ?? 0) + (usage2.cache_read_input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
5721
- }
5722
- function extractResult(line) {
5723
- const obj = line;
5724
- if (!obj || obj.type !== "result") return void 0;
5725
- return {
5726
- sessionId: obj.session_id,
5727
- numTurns: obj.num_turns,
5728
- totalCostUsd: obj.total_cost_usd,
5729
- inputTokens: obj.usage?.input_tokens,
5730
- outputTokens: obj.usage?.output_tokens,
5731
- cacheReadTokens: obj.usage?.cache_read_input_tokens,
5732
- cacheCreationTokens: obj.usage?.cache_creation_input_tokens
5733
- };
5734
- }
5735
-
5736
6074
  // src/agent-log.ts
5737
6075
  async function post(target, path, body) {
5738
6076
  const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
@@ -5814,36 +6152,10 @@ var TicketAttributionTracker = class {
5814
6152
 
5815
6153
  // src/agent.ts
5816
6154
  var DIGEST_MAX_AGE_SECONDS = 600;
5817
- function allowedTools(role) {
5818
- const tablation = "mcp__tablation__*";
5819
- if (role === "triage") return ["Bash", "Read", tablation];
5820
- if (role === "qa") return ["Bash", "Read", "Write", "Grep", "Glob", tablation];
5821
- const base = ["Bash", "Read", "Edit", "Write", "Grep", "Glob", tablation];
5822
- return role === "design" ? [...base, "Skill", "Artifact"] : base;
5823
- }
5824
- var DISALLOWED_TOOLS = [
5825
- "Bash(rm -rf:*)",
5826
- "Bash(rm -fr:*)",
5827
- "Bash(git push --force*)",
5828
- "Bash(git push -f*)",
5829
- "Bash(curl*| sh)",
5830
- "Bash(curl*|sh)",
5831
- "Bash(curl*| bash)",
5832
- "Bash(curl*|bash)",
5833
- "Bash(sudo:*)"
5834
- ];
5835
- var BILLING_VARS_TO_UNSET = [
5836
- "ANTHROPIC_API_KEY",
5837
- "ANTHROPIC_AUTH_TOKEN",
5838
- "CLAUDE_CODE_USE_VERTEX",
5839
- "ANTHROPIC_VERTEX_PROJECT_ID",
5840
- "ANTHROPIC_VERTEX_REGION",
5841
- "CLOUD_ML_REGION",
5842
- "ANTHROPIC_MODEL"
5843
- ];
5844
- function scrubbedEnv(env = process.env) {
6155
+ var LEGACY_TOOLCHAIN_HARNESS = "claude";
6156
+ function scrubbedEnv(env = process.env, harness = LEGACY_TOOLCHAIN_HARNESS) {
5845
6157
  const out = { ...env };
5846
- for (const v of BILLING_VARS_TO_UNSET) delete out[v];
6158
+ for (const v of adapterFor(harness).unsetEnv) delete out[v];
5847
6159
  return out;
5848
6160
  }
5849
6161
  var CREW_LANE_ROLE_VAR = "CREW_LANE_ROLE";
@@ -5857,23 +6169,23 @@ var AgentError = class extends Error {
5857
6169
  var AgentSpawnError = class extends AgentError {
5858
6170
  };
5859
6171
  function planAgentRun(o) {
5860
- const briefPath = join7(o.route.promptsDir, "personas", `lane-${o.role}.md`);
6172
+ const briefPath = join8(o.route.promptsDir, "personas", `lane-${o.role}.md`);
5861
6173
  if (!existsSync7(briefPath)) {
5862
6174
  throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
5863
6175
  }
5864
- const commonPath = join7(o.route.promptsDir, "personas", "common.md");
6176
+ const commonPath = join8(o.route.promptsDir, "personas", "common.md");
5865
6177
  if (!existsSync7(commonPath)) throw new AgentError(`no shared policy at ${commonPath}`);
5866
6178
  const now = o.now ?? (() => Date.now());
5867
6179
  const routeLabel = routeSlug(o.route.route);
5868
- const digestPath2 = join7(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
6180
+ const digestPath2 = join8(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
5869
6181
  let digest;
5870
6182
  let ageSeconds;
5871
6183
  if (existsSync7(digestPath2)) {
5872
- ageSeconds = Math.floor((now() - statSync4(digestPath2).mtimeMs) / 1e3);
5873
- if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync6(digestPath2, "utf8");
6184
+ ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
6185
+ if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync7(digestPath2, "utf8");
5874
6186
  }
5875
- const common = readFileSync6(commonPath, "utf8");
5876
- const brief = readFileSync6(briefPath, "utf8");
6187
+ const common = readFileSync7(commonPath, "utf8");
6188
+ const brief = readFileSync7(briefPath, "utf8");
5877
6189
  const effectiveCommon = o.divergedPrompt ? "" : common;
5878
6190
  const effectiveBrief = o.divergedPrompt ?? brief;
5879
6191
  const prompt = assemblePrompt({
@@ -5884,9 +6196,18 @@ function planAgentRun(o) {
5884
6196
  digest
5885
6197
  });
5886
6198
  const promptSha = createHash2("sha256").update(`${effectiveCommon}${effectiveBrief}`).digest("hex");
5887
- const streamsDir = join7(o.stateDir, "streams");
6199
+ const streamsDir = join8(o.stateDir, "streams");
5888
6200
  const base = `${routeLabel}-${o.role}-${o.cycle}`;
5889
- const model = o.agentModel ?? o.ship.agent.model;
6201
+ const toolchain = resolveToolchain(o.ship, o.route);
6202
+ const adapter = adapterFor(toolchain.harness);
6203
+ const model = o.agentModel ?? toolchain.model;
6204
+ const invocation = adapter.invocation(toolchain, {
6205
+ role: o.role,
6206
+ model,
6207
+ // Skipped when there's no key to send: a route `crew connect` hasn't
6208
+ // resolved yet has nothing to authenticate the MCP connection with either.
6209
+ mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
6210
+ });
5890
6211
  const agentLog = o.route.resolved && o.apiKey ? {
5891
6212
  baseUrl: o.route.baseUrl,
5892
6213
  workspaceId: o.route.resolved.workspaceId,
@@ -5904,43 +6225,16 @@ function planAgentRun(o) {
5904
6225
  // (config.ts), and the digest's `repo` column is what actually places
5905
6226
  // the work. Kept deliberately rather than guessed at (ISSUE-349).
5906
6227
  cwd: o.route.dir,
5907
- bin: o.ship.agent.bin,
5908
- args: [
5909
- "-p",
5910
- "--allowedTools",
5911
- ...allowedTools(o.role),
5912
- "--disallowedTools",
5913
- ...DISALLOWED_TOOLS,
5914
- "--model",
5915
- model,
5916
- "--output-format",
5917
- "stream-json",
5918
- "--verbose",
5919
- // Registers the tracker's own MCP tool surface for this run only —
5920
- // `${CREW_API_KEY}` is interpolated by the CLI from the environment
5921
- // (set below), so the resolved key is never written to an argv string
5922
- // or a config file on disk. Skipped when there's no key to send: a
5923
- // route `crew connect` hasn't resolved yet has nothing to authenticate
5924
- // the MCP connection with either.
5925
- ...o.apiKey ? ["--mcp-config", JSON.stringify({
5926
- mcpServers: {
5927
- tablation: {
5928
- type: "http",
5929
- url: `${o.route.baseUrl}/api/mcp`,
5930
- headers: { Authorization: `Bearer \${${API_KEY_VAR}}` }
5931
- }
5932
- }
5933
- })] : []
5934
- ],
6228
+ bin: invocation.bin,
6229
+ args: invocation.args,
6230
+ toolchain: toolchain.name,
6231
+ harness: toolchain.harness,
5935
6232
  prompt,
5936
6233
  promptBytes: Buffer.byteLength(prompt, "utf8"),
5937
- unsetEnv: BILLING_VARS_TO_UNSET,
6234
+ unsetEnv: adapter.unsetEnv,
5938
6235
  setEnv: {
5939
6236
  ...o.apiKey ? { [API_KEY_VAR]: o.apiKey } : {},
5940
- // Extended thinking is what makes the CLI emit `thinking` stream
5941
- // blocks at all — without it, `run.cycles` (below) is always empty
5942
- // and Agent Log Cycles reporting has nothing to report (ISSUE-558).
5943
- ...o.ship.agent.maxThinkingTokens > 0 ? { MAX_THINKING_TOKENS: String(o.ship.agent.maxThinkingTokens) } : {},
6237
+ ...adapter.runEnv(toolchain),
5944
6238
  // The operator's global SessionStart hook (pair-context-hook.sh) injects
5945
6239
  // the interactive Pair persona's prompt for any session it can't tell
5946
6240
  // apart from a person's own terminal. Every headless lane run this
@@ -5954,8 +6248,8 @@ function planAgentRun(o) {
5954
6248
  digestAttached: digest !== void 0,
5955
6249
  digestAgeSeconds: ageSeconds,
5956
6250
  model,
5957
- streamPath: join7(streamsDir, `${base}.jsonl`),
5958
- eventsPath: join7(streamsDir, `${base}.events.jsonl`),
6251
+ streamPath: join8(streamsDir, `${base}.jsonl`),
6252
+ eventsPath: join8(streamsDir, `${base}.events.jsonl`),
5959
6253
  ticket: o.ticket,
5960
6254
  agentLog,
5961
6255
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
@@ -5965,6 +6259,8 @@ function planAgentRun(o) {
5965
6259
  function describePlan(p) {
5966
6260
  return [
5967
6261
  `role: ${p.role}`,
6262
+ // Only for a non-default toolchain, so a plain Claude run's output is unchanged.
6263
+ ...p.toolchain && p.toolchain !== "claude" ? [`toolchain: ${p.toolchain} (${p.harness ?? "claude"})`] : [],
5968
6264
  `cwd: ${p.cwd}`,
5969
6265
  `command: ${p.bin} ${p.args.join(" ")}`,
5970
6266
  `prompt: ${p.promptBytes} bytes on stdin (never argv)`,
@@ -5990,6 +6286,7 @@ function endSink(sink) {
5990
6286
  return finished(sink).catch(() => void 0);
5991
6287
  }
5992
6288
  function spawnAgent(plan, emit) {
6289
+ const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
5993
6290
  const started = Date.now();
5994
6291
  const startedAt = new Date(started).toISOString();
5995
6292
  const rawSink = openSink(plan.streamPath);
@@ -5997,7 +6294,7 @@ function spawnAgent(plan, emit) {
5997
6294
  return new Promise((resolve5, reject) => {
5998
6295
  const child = spawn2(plan.bin, plan.args, {
5999
6296
  cwd: plan.cwd,
6000
- env: { ...scrubbedEnv(), ...plan.setEnv },
6297
+ env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv },
6001
6298
  stdio: ["pipe", "pipe", "inherit"]
6002
6299
  });
6003
6300
  let buf = "";
@@ -6018,9 +6315,9 @@ function spawnAgent(plan, emit) {
6018
6315
  return;
6019
6316
  }
6020
6317
  try {
6021
- result = extractResult(parsed) ?? result;
6318
+ result = adapter.extractResult(parsed) ?? result;
6022
6319
  let turnText = "";
6023
- for (const ev of mapStreamLine(parsed)) {
6320
+ for (const ev of adapter.mapStreamLine(parsed)) {
6024
6321
  const e = {
6025
6322
  at: (/* @__PURE__ */ new Date()).toISOString(),
6026
6323
  cycle: emit.cycle,
@@ -6058,7 +6355,7 @@ function spawnAgent(plan, emit) {
6058
6355
  `);
6059
6356
  }
6060
6357
  }
6061
- const turnTokens = extractTurnTokens(parsed);
6358
+ const turnTokens = adapter.extractTurnTokens(parsed);
6062
6359
  if (turnTokens !== void 0) attribution.add(turnText, turnTokens);
6063
6360
  } catch {
6064
6361
  }
@@ -6136,12 +6433,12 @@ function spawnAgent(plan, emit) {
6136
6433
 
6137
6434
  // src/run-worktree.ts
6138
6435
  import { existsSync as existsSync9 } from "fs";
6139
- import { dirname as dirname6, join as join9 } from "path";
6436
+ import { dirname as dirname6, join as join10 } from "path";
6140
6437
 
6141
6438
  // src/ports.ts
6142
6439
  import { execFileSync as execFileSync2 } from "child_process";
6143
6440
  import { existsSync as existsSync8 } from "fs";
6144
- import { join as join8 } from "path";
6441
+ import { join as join9 } from "path";
6145
6442
  var run = (cmd, args) => {
6146
6443
  try {
6147
6444
  return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -6186,7 +6483,7 @@ function ticketForPort(port, s = DEFAULT_PORTS) {
6186
6483
  }
6187
6484
  function worktreeExists(parent, prefix, n) {
6188
6485
  for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
6189
- if (existsSync8(join8(parent, name, ".git"))) return true;
6486
+ if (existsSync8(join9(parent, name, ".git"))) return true;
6190
6487
  }
6191
6488
  return false;
6192
6489
  }
@@ -6274,7 +6571,7 @@ function planRunWorktree(dir, cfg, t, role) {
6274
6571
  if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
6275
6572
  return { action: "recut", path: existing.path, branch, base: baseRef };
6276
6573
  }
6277
- const path = join9(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
6574
+ const path = join10(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
6278
6575
  if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
6279
6576
  return { action: "cut-remote", path, branch: located.branch };
6280
6577
  }
@@ -6482,7 +6779,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
6482
6779
  }
6483
6780
 
6484
6781
  // src/connect-wizard.ts
6485
- import { delimiter, dirname as dirname7, join as join10 } from "path";
6782
+ import { delimiter, dirname as dirname7, join as join11 } from "path";
6486
6783
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync10, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
6487
6784
  import { stringify } from "yaml";
6488
6785
  var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
@@ -6505,7 +6802,7 @@ function defaultIsExecutable(p) {
6505
6802
  function findOnPath2(name, pathEnv, isExecutable = defaultIsExecutable) {
6506
6803
  for (const dir of pathEnv.split(delimiter)) {
6507
6804
  if (!dir) continue;
6508
- const candidate = join10(dir, name);
6805
+ const candidate = join11(dir, name);
6509
6806
  if (isExecutable(candidate)) return candidate;
6510
6807
  }
6511
6808
  return void 0;
@@ -6600,8 +6897,8 @@ Wrote ${o.configPath}.
6600
6897
  }
6601
6898
 
6602
6899
  // src/release-run.ts
6603
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
6604
- import { dirname as dirname8, join as join11 } from "path";
6900
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync3 } from "fs";
6901
+ import { dirname as dirname8, join as join12 } from "path";
6605
6902
  import { tmpdir as tmpdir2 } from "os";
6606
6903
 
6607
6904
  // src/release.ts
@@ -7259,13 +7556,13 @@ async function bump(o, size) {
7259
7556
  }
7260
7557
  return printed;
7261
7558
  }
7262
- const files = o.repo.release.versionFiles.map((f) => join11(o.cwd, f));
7559
+ const files = o.repo.release.versionFiles.map((f) => join12(o.cwd, f));
7263
7560
  const first = files[0];
7264
7561
  if (!existsSync11(first)) {
7265
7562
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
7266
7563
  return null;
7267
7564
  }
7268
- const current = JSON.parse(readFileSync7(first, "utf8")).version;
7565
+ const current = JSON.parse(readFileSync8(first, "utf8")).version;
7269
7566
  if (!current) {
7270
7567
  o.emit.error(`no version field in ${o.repo.release.versionFiles[0]}`);
7271
7568
  return null;
@@ -7278,7 +7575,7 @@ async function bump(o, size) {
7278
7575
  return version;
7279
7576
  }
7280
7577
  for (const f of files) {
7281
- const src = readFileSync7(f, "utf8");
7578
+ const src = readFileSync8(f, "utf8");
7282
7579
  const out = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
7283
7580
  if (out === src) {
7284
7581
  o.emit.error(`no version field in ${f}`);
@@ -7291,13 +7588,13 @@ async function bump(o, size) {
7291
7588
  function writeChangelog(o, version, entries) {
7292
7589
  const file = o.repo.release.changelog;
7293
7590
  if (!file || entries.length === 0) return;
7294
- const path = join11(o.cwd, file);
7591
+ const path = join12(o.cwd, file);
7295
7592
  const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
7296
7593
  if (o.dryRun) {
7297
7594
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
7298
7595
  return;
7299
7596
  }
7300
- const existing = existsSync11(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
7597
+ const existing = existsSync11(path) ? readFileSync8(path, "utf8") : "# Changelog\n";
7301
7598
  writeFileSync6(path, insertChangelogSection(existing, section));
7302
7599
  }
7303
7600
  async function confirm(o, expected) {
@@ -7379,11 +7676,11 @@ async function confirmLastRelease(o, decision) {
7379
7676
  function openReleaseCheckout(o) {
7380
7677
  const { remote, base } = o.repo.branch;
7381
7678
  const branch = `crew/release-${Date.now()}`;
7382
- const root = mkdtempSync(join11(tmpdir2(), "crew-release-"));
7383
- const dir = join11(root, "checkout");
7679
+ const root = mkdtempSync(join12(tmpdir2(), "crew-release-"));
7680
+ const dir = join12(root, "checkout");
7384
7681
  const close = () => {
7385
7682
  gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
7386
- rmSync2(root, { recursive: true, force: true });
7683
+ rmSync3(root, { recursive: true, force: true });
7387
7684
  gitOk(o.cwd, ["worktree", "prune"]);
7388
7685
  gitOk(o.cwd, ["branch", "-D", branch]);
7389
7686
  };
@@ -7394,10 +7691,10 @@ function openReleaseCheckout(o) {
7394
7691
  throw e;
7395
7692
  }
7396
7693
  for (const f of o.repo.worktrees.copy) {
7397
- const src = join11(o.cwd, f);
7694
+ const src = join12(o.cwd, f);
7398
7695
  if (!existsSync11(src)) continue;
7399
- mkdirSync7(dirname8(join11(dir, f)), { recursive: true });
7400
- copyFileSync(src, join11(dir, f));
7696
+ mkdirSync7(dirname8(join12(dir, f)), { recursive: true });
7697
+ copyFileSync(src, join12(dir, f));
7401
7698
  }
7402
7699
  return { dir, branch, close };
7403
7700
  }
@@ -7866,7 +8163,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
7866
8163
  }
7867
8164
 
7868
8165
  // src/watch.ts
7869
- import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
8166
+ import { watch as watchFile, existsSync as existsSync12, statSync as statSync6, openSync as openSync3, readSync, closeSync as closeSync3 } from "fs";
7870
8167
  var E = "\x1B[";
7871
8168
  var dim = (s) => `${E}2m${s}${E}0m`;
7872
8169
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -7968,13 +8265,13 @@ function renderHeaders(states, now = Date.now()) {
7968
8265
  }
7969
8266
  function readFrom(file, from) {
7970
8267
  if (!existsSync12(file)) return { events: [], offset: from };
7971
- const size = statSync5(file).size;
8268
+ const size = statSync6(file).size;
7972
8269
  if (size < from) return readFrom(file, 0);
7973
8270
  if (size === from) return { events: [], offset: from };
7974
- const fd = openSync2(file, "r");
8271
+ const fd = openSync3(file, "r");
7975
8272
  const buf = Buffer.alloc(size - from);
7976
8273
  readSync(fd, buf, 0, buf.length, from);
7977
- closeSync2(fd);
8274
+ closeSync3(fd);
7978
8275
  const text = buf.toString("utf8");
7979
8276
  const lastNewline = text.lastIndexOf("\n");
7980
8277
  if (lastNewline === -1) return { events: [], offset: from };
@@ -8207,8 +8504,8 @@ function nextRoles(decision, ran) {
8207
8504
  }
8208
8505
 
8209
8506
  // src/agents.ts
8210
- import { readFileSync as readFileSync8, existsSync as existsSync13 } from "fs";
8211
- import { join as join12 } from "path";
8507
+ import { readFileSync as readFileSync9, existsSync as existsSync13 } from "fs";
8508
+ import { join as join13 } from "path";
8212
8509
  import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
8213
8510
  var PERSONA_NAME = {
8214
8511
  dev: "Developer",
@@ -8222,12 +8519,12 @@ var CREW_AGENT_COLUMN = "agent_id";
8222
8519
  var AgentsSyncError = class extends Error {
8223
8520
  };
8224
8521
  function personaDefaultPrompt(promptsDir, role) {
8225
- const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
8522
+ const briefPath = join13(promptsDir, "personas", `lane-${role}.md`);
8226
8523
  if (!existsSync13(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
8227
- if (role === "pair") return readFileSync8(briefPath, "utf8");
8228
- const commonPath = join12(promptsDir, "personas", "common.md");
8524
+ if (role === "pair") return readFileSync9(briefPath, "utf8");
8525
+ const commonPath = join13(promptsDir, "personas", "common.md");
8229
8526
  if (!existsSync13(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
8230
- return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
8527
+ return `${readFileSync9(commonPath, "utf8")}${readFileSync9(briefPath, "utf8")}`;
8231
8528
  }
8232
8529
  async function resolveAgentId(route, role, opts) {
8233
8530
  const cached = route.resolved?.agentPersonas?.[role]?.agentId;
@@ -8893,14 +9190,14 @@ import {
8893
9190
  } from "@tablation/client";
8894
9191
 
8895
9192
  // src/skills.ts
8896
- import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
8897
- import { join as join13 } from "path";
9193
+ import { readFileSync as readFileSync10, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
9194
+ import { join as join14 } from "path";
8898
9195
  import { parse as parseYaml } from "yaml";
8899
9196
  import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
8900
9197
  var SkillSyncError = class extends Error {
8901
9198
  };
8902
9199
  function parseSkillFile(path) {
8903
- const text = readFileSync9(path, "utf8");
9200
+ const text = readFileSync10(path, "utf8");
8904
9201
  const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
8905
9202
  if (!match) throw new SkillSyncError(`${path}: missing YAML frontmatter (expected a leading --- block)`);
8906
9203
  const frontmatter = match[1];
@@ -8913,9 +9210,9 @@ function parseSkillFile(path) {
8913
9210
  return { name: meta.name, description: meta.description, prompt: body, file: path };
8914
9211
  }
8915
9212
  function listLocalSkills(promptsDir) {
8916
- const dir = join13(promptsDir, "skills");
9213
+ const dir = join14(promptsDir, "skills");
8917
9214
  if (!existsSync14(dir)) return [];
8918
- return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
9215
+ return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join14(dir, f)));
8919
9216
  }
8920
9217
  async function syncSkills(route, opts) {
8921
9218
  if (!route.resolved) {
@@ -9094,7 +9391,7 @@ async function showLogEntry(route, entryId, opts = {}) {
9094
9391
  }
9095
9392
 
9096
9393
  // src/repos-cmd.ts
9097
- import { homedir as homedir3 } from "os";
9394
+ import { homedir as homedir4 } from "os";
9098
9395
  import { basename as basename6, resolve as resolve3 } from "path";
9099
9396
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
9100
9397
  var ReposError = class extends Error {
@@ -9141,7 +9438,7 @@ function parseAddArgs(args, routes, isDir) {
9141
9438
  return { route, path: "." };
9142
9439
  }
9143
9440
  function expandDir(p, base) {
9144
- const home = p === "~" ? homedir3() : p.startsWith("~/") ? `${homedir3()}/${p.slice(2)}` : p;
9441
+ const home = p === "~" ? homedir4() : p.startsWith("~/") ? `${homedir4()}/${p.slice(2)}` : p;
9145
9442
  return resolve3(base, home);
9146
9443
  }
9147
9444
  function routeMapOf(doc, route) {
@@ -9238,72 +9535,6 @@ function resolveRepoProject(projects, opt, routeAreaId) {
9238
9535
  );
9239
9536
  }
9240
9537
 
9241
- // src/claude-mcp.ts
9242
- import { spawnSync as spawnSync2 } from "child_process";
9243
- import { readFileSync as readFileSync10 } from "fs";
9244
- import { homedir as homedir4 } from "os";
9245
- import { join as join14 } from "path";
9246
- var MCP_SERVER_NAME = "tablation";
9247
- var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
9248
- function mcpUrlFor(baseUrl) {
9249
- return `${baseUrl.replace(/\/+$/, "")}/api/mcp`;
9250
- }
9251
- function runClaude(bin, args) {
9252
- const r = spawnSync2(bin, args, { encoding: "utf8", timeout: 3e4 });
9253
- const missing = r.error?.code === "ENOENT";
9254
- return { status: r.status, missing, stderr: r.stderr ?? "" };
9255
- }
9256
- function userScopeEntry(home) {
9257
- try {
9258
- const j = JSON.parse(readFileSync10(join14(home, ".claude.json"), "utf8"));
9259
- return j.mcpServers?.[MCP_SERVER_NAME];
9260
- } catch {
9261
- return void 0;
9262
- }
9263
- }
9264
- function inspectClaudeMcp(o) {
9265
- const got = runClaude(o.bin, ["mcp", "get", MCP_SERVER_NAME]);
9266
- if (got.missing) return { state: "no-claude" };
9267
- if (got.status !== 0) return { state: "missing" };
9268
- const entry = userScopeEntry(o.home ?? homedir4());
9269
- if (!entry) {
9270
- return { state: "different", detail: `a "${MCP_SERVER_NAME}" server exists, but not in user scope (~/.claude.json) \u2014 a project or local registration` };
9271
- }
9272
- if (entry.url !== o.url) return { state: "different", detail: `it points at ${entry.url ?? "(no url)"}, not ${o.url}` };
9273
- const auth = Object.entries(entry.headers ?? {}).find(([k]) => k.toLowerCase() === "authorization")?.[1];
9274
- if (auth !== `Bearer ${o.key}`) return { state: "different", detail: `it is registered with a different key than this route's` };
9275
- return { state: "registered", url: o.url };
9276
- }
9277
- function ensureClaudeMcp(o) {
9278
- const manual = `claude mcp add --transport http ${MCP_SERVER_NAME} ${o.url} --header "Authorization: Bearer <key>" --scope user`;
9279
- const st = inspectClaudeMcp(o);
9280
- if (st.state === "no-claude") return `mcp: \`${o.bin}\` not found, so Claude Code was not configured. To do it by hand: ${manual}`;
9281
- if (st.state === "registered") return `mcp: ${MCP_SERVER_NAME} already registered with Claude Code \u2192 ${st.url}`;
9282
- if (st.state === "different" && !o.replace) {
9283
- return `mcp: left Claude Code's "${MCP_SERVER_NAME}" server alone \u2014 ${st.detail}. Re-run with --mcp replace to overwrite it.`;
9284
- }
9285
- if (o.dryRun) return `(dry run) would register the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url}`;
9286
- if (st.state === "different") {
9287
- runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
9288
- }
9289
- const add = runClaude(o.bin, [
9290
- "mcp",
9291
- "add",
9292
- "--transport",
9293
- "http",
9294
- MCP_SERVER_NAME,
9295
- o.url,
9296
- "--header",
9297
- `Authorization: Bearer ${o.key}`,
9298
- "--scope",
9299
- "user"
9300
- ]);
9301
- if (add.status !== 0) {
9302
- return `mcp: \`claude mcp add\` failed (${redact(add.stderr.trim(), o.key) || `exit ${add.status}`}). To do it by hand: ${manual}`;
9303
- }
9304
- return `mcp: registered the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url} (user scope, ~/.claude.json)`;
9305
- }
9306
-
9307
9538
  // src/doctor-fix.ts
9308
9539
  import { parseDocument as parseDocument2 } from "yaml";
9309
9540
  var sound = (r) => r.hasRepos && r.problems.length === 0;
@@ -9465,7 +9696,7 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
9465
9696
  }
9466
9697
 
9467
9698
  // src/stream-sweep.ts
9468
- import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
9699
+ import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync4, statSync as statSync7 } from "fs";
9469
9700
  import { join as join15 } from "path";
9470
9701
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
9471
9702
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
@@ -9474,7 +9705,7 @@ function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
9474
9705
  const cutoff = now - retentionDays * DAY_MS2;
9475
9706
  return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
9476
9707
  try {
9477
- return statSync6(path).mtimeMs < cutoff;
9708
+ return statSync7(path).mtimeMs < cutoff;
9478
9709
  } catch {
9479
9710
  return false;
9480
9711
  }
@@ -9488,7 +9719,7 @@ function applyStreamSweep(paths, dryRun, log) {
9488
9719
  continue;
9489
9720
  }
9490
9721
  try {
9491
- rmSync3(path, { force: true });
9722
+ rmSync4(path, { force: true });
9492
9723
  removed++;
9493
9724
  log.emit(`removed stream artifact ${path}`, { step: "worktree" });
9494
9725
  } catch (e) {
@@ -9497,9 +9728,20 @@ function applyStreamSweep(paths, dryRun, log) {
9497
9728
  }
9498
9729
  return removed;
9499
9730
  }
9731
+ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
9732
+ if (!existsSync16(stateDir)) return [];
9733
+ const cutoff = now - retentionDays * DAY_MS2;
9734
+ return readdirSync3(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join15(stateDir, name)).filter((path) => {
9735
+ try {
9736
+ return statSync7(path).mtimeMs < cutoff;
9737
+ } catch {
9738
+ return false;
9739
+ }
9740
+ }).sort();
9741
+ }
9500
9742
 
9501
9743
  // src/cli.ts
9502
- import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
9744
+ import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
9503
9745
  import { createInterface } from "readline/promises";
9504
9746
  import { dirname as dirOf, resolve as resolvePath } from "path";
9505
9747
 
@@ -10237,7 +10479,7 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
10237
10479
  }
10238
10480
 
10239
10481
  // src/daemon.ts
10240
- import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
10482
+ import { readdirSync as readdirSync6, statSync as statSync8 } from "fs";
10241
10483
  import { join as join19 } from "path";
10242
10484
  var DEFAULT_FLOOR_MS = 5e3;
10243
10485
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -10269,7 +10511,7 @@ async function runDaemonLoop(o) {
10269
10511
  function latestMtime(path) {
10270
10512
  let stat;
10271
10513
  try {
10272
- stat = statSync7(path);
10514
+ stat = statSync8(path);
10273
10515
  } catch {
10274
10516
  return 0;
10275
10517
  }
@@ -10866,6 +11108,7 @@ if (command === "passenger-sync-daemon") {
10866
11108
  const emit = new Emitter({
10867
11109
  route: route?.route ?? "",
10868
11110
  eventFile: dryRun ? void 0 : eventFileFor(cfg.ship.stateDir),
11111
+ rotateBytes: cfg.ship.eventsRotateBytes,
10869
11112
  logFile: dryRun ? void 0 : cfg.ship.logFile,
10870
11113
  console: (l) => process.stderr.write(`${l}
10871
11114
  `)
@@ -10968,7 +11211,10 @@ if (command === "passenger-sync-daemon") {
10968
11211
  emitReleaseSummary(emit, summaries);
10969
11212
  if (!dryRun) state.releaseHeartbeat().touch();
10970
11213
  try {
10971
- const paths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
11214
+ const paths = [
11215
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
11216
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
11217
+ ];
10972
11218
  if (paths.length) {
10973
11219
  emit.enter("worktree");
10974
11220
  const removed = applyStreamSweep(paths, dryRun, emit);
@@ -12020,6 +12266,7 @@ ${changed2.map((c) => ` - ${c}
12020
12266
  newEmitter: () => new Emitter({
12021
12267
  route: route.route,
12022
12268
  eventFile: eventFileFor(cfg.ship.stateDir),
12269
+ rotateBytes: cfg.ship.eventsRotateBytes,
12023
12270
  logFile: cfg.ship.logFile,
12024
12271
  console: (l) => process.stderr.write(`${l}
12025
12272
  `)
@@ -12629,7 +12876,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12629
12876
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
12630
12877
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
12631
12878
  `);
12632
- renameSync(tmpPath, resolvedPath);
12879
+ renameSync2(tmpPath, resolvedPath);
12633
12880
  process.stderr.write(`Resolved ids written to ${resolvedPath}
12634
12881
  `);
12635
12882
  }
@@ -12673,8 +12920,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12673
12920
  if (!wroteConfig) process.stdout.write(routeBlock);
12674
12921
  if (!flag("no-mcp") && apiKey) {
12675
12922
  const replace = value("mcp") === "replace";
12676
- process.stderr.write(`${ensureClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
12923
+ const seen = /* @__PURE__ */ new Set();
12924
+ for (const tc of shipToolchains(cfg.ship)) {
12925
+ const id = `${tc.harness}\0${tc.bin}`;
12926
+ if (seen.has(id)) continue;
12927
+ seen.add(id);
12928
+ process.stderr.write(`${adapterFor(tc.harness).ensureMcp({ bin: tc.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
12677
12929
  `);
12930
+ }
12678
12931
  }
12679
12932
  if (found.holds.length) {
12680
12933
  process.stderr.write(
@@ -12846,7 +13099,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12846
13099
  { step: "worktree" }
12847
13100
  );
12848
13101
  }
12849
- const streamPaths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
13102
+ if (!dryRun) {
13103
+ const rotated = rotateEventsFile(eventFileFor(cfg.ship.stateDir), cfg.ship.eventsRotateBytes);
13104
+ if (rotated) {
13105
+ anything = true;
13106
+ emit.emit(`rotated oversized event log to ${rotated}`, { step: "worktree" });
13107
+ }
13108
+ }
13109
+ const streamPaths = [
13110
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
13111
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
13112
+ ];
12850
13113
  if (streamPaths.length) {
12851
13114
  anything = true;
12852
13115
  const removed = applyStreamSweep(streamPaths, dryRun, emit);
@@ -13220,6 +13483,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13220
13483
  name: cfg.ship.name,
13221
13484
  platform: cfg.ship.platform,
13222
13485
  agent: { bin: cfg.ship.agent.bin, model: cfg.ship.agent.model },
13486
+ // Additive (CREW-1511): the configured toolchains and the ship's
13487
+ // default. `agent` above stays for older crew-macos builds.
13488
+ toolchain: resolveToolchain(cfg.ship).name,
13489
+ toolchains: shipToolchains(cfg.ship).map((x) => ({
13490
+ name: x.name,
13491
+ harness: x.harness,
13492
+ bin: x.bin,
13493
+ launcher: x.launcher ?? null,
13494
+ model: x.model
13495
+ })),
13223
13496
  stateDir: cfg.ship.stateDir,
13224
13497
  logFile: cfg.ship.logFile,
13225
13498
  eventFile: eventFileFor(cfg.ship.stateDir),
@@ -13245,6 +13518,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13245
13518
  dir: route.dir,
13246
13519
  enabled: route.enabled,
13247
13520
  area: route.area ?? null,
13521
+ // Additive (CREW-1511): the toolchain this route's sessions run on.
13522
+ toolchain: resolveToolchain(cfg.ship, route).name,
13248
13523
  baseUrl: route.baseUrl,
13249
13524
  repos: repos.map((r) => ({
13250
13525
  name: r.name,
@@ -13349,6 +13624,15 @@ watermark: ${state.watermark()}
13349
13624
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
13350
13625
  `
13351
13626
  );
13627
+ try {
13628
+ const evFile = eventFileFor(cfg.ship.stateDir);
13629
+ const evSize = existsSync20(evFile) ? statSync9(evFile).size : 0;
13630
+ process.stdout.write(
13631
+ evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
13632
+ ` : "events log: ok\n"
13633
+ );
13634
+ } catch {
13635
+ }
13352
13636
  const tracker = new Tracker(route, cfg.ship);
13353
13637
  const contractProblems = validateContract(tracker.contract);
13354
13638
  process.stdout.write(
@@ -13461,14 +13745,24 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
13461
13745
  }
13462
13746
  }
13463
13747
  const schedulerPath = pathFor(cfg.ship);
13464
- const agentBinPath = findOnPath(cfg.ship.agent.bin, schedulerPath);
13465
13748
  const gitPath = findOnPath("git", schedulerPath);
13466
- process.stdout.write(
13467
- `agent binary: ${cfg.ship.agent.bin} \u2014 ${agentBinPath ? `OK (${agentBinPath})` : "NOT FOUND on the scheduler PATH"}
13468
- git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler PATH"}
13749
+ const defaultToolchain = resolveToolchain(cfg.ship).name;
13750
+ let allBinsFound = true;
13751
+ for (const tc of shipToolchains(cfg.ship)) {
13752
+ const adapter = adapterFor(tc.harness);
13753
+ const binPath = findOnPath(tc.launcher ?? tc.bin, schedulerPath);
13754
+ if (!binPath) allBinsFound = false;
13755
+ const label = tc.name === defaultToolchain ? "agent binary: " : `toolchain ${tc.name}:`.padEnd(18);
13756
+ process.stdout.write(
13757
+ `${label} ${tc.launcher ?? tc.bin} \u2014 ${binPath ? `OK (${binPath})` : "NOT FOUND on the scheduler PATH"}${tc.name === defaultToolchain ? "" : ` [${adapter.displayName}]`}
13469
13758
  `
13470
- );
13471
- if (!agentBinPath || !gitPath) {
13759
+ );
13760
+ for (const limit of adapter.limitations) process.stdout.write(` cannot enforce: ${limit}
13761
+ `);
13762
+ }
13763
+ process.stdout.write(`git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler PATH"}
13764
+ `);
13765
+ if (!allBinsFound || !gitPath) {
13472
13766
  process.stdout.write(
13473
13767
  ` scheduler PATH is ${schedulerPath} \u2014 add the directory holding the missing binary to ship.extraPath in crew.yaml, then re-run \`crew doctor\`.
13474
13768
  `
@@ -13476,14 +13770,21 @@ git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler P
13476
13770
  }
13477
13771
  if (route.apiKey) {
13478
13772
  const mcpUrl = mcpUrlFor(route.baseUrl);
13479
- const m = inspectClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrl, key: route.apiKey });
13480
- process.stdout.write(
13481
- m.state === "registered" ? `mcp: tablation registered (user scope) \u2192 ${m.url}
13482
- ` : m.state === "no-claude" ? `mcp: claude not found \u2014 Claude Code was not checked
13773
+ const seenMcp = /* @__PURE__ */ new Set();
13774
+ for (const tc of shipToolchains(cfg.ship)) {
13775
+ const id = `${tc.harness}\0${tc.bin}`;
13776
+ if (seenMcp.has(id)) continue;
13777
+ seenMcp.add(id);
13778
+ const adapter = adapterFor(tc.harness);
13779
+ const m = adapter.inspectMcp({ bin: tc.bin, url: mcpUrl, key: route.apiKey });
13780
+ process.stdout.write(
13781
+ m.state === "registered" ? `mcp: tablation registered (user scope) \u2192 ${m.url}
13782
+ ` : m.state === "no-binary" ? `mcp: ${tc.bin} not found \u2014 ${adapter.displayName} was not checked
13483
13783
  ` : m.state === "different" ? `mcp: tablation registered but not for this route \u2014 ${m.detail}
13484
13784
  ` : `mcp: not registered \u2014 run crew connect
13485
13785
  `
13486
- );
13786
+ );
13787
+ }
13487
13788
  }
13488
13789
  for (const c of cfg.routes.filter((r) => r.enabled)) {
13489
13790
  let repos;