@tablation/crew 0.105.0 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1966 -1607
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1698,9 +1698,9 @@ var keychainSource = {
1698
1698
  write(s, token, c) {
1699
1699
  try {
1700
1700
  if (process.platform === "darwin") {
1701
- const q = (v) => `"${v.replace(/(["\\])/g, "\\$1")}"`;
1701
+ const q2 = (v) => `"${v.replace(/(["\\])/g, "\\$1")}"`;
1702
1702
  execFileSync("security", ["-i"], {
1703
- input: `add-generic-password -U -s ${q(keychainService(s))} -a ${q(c.host)} -w ${q(token)}
1703
+ input: `add-generic-password -U -s ${q2(keychainService(s))} -a ${q2(c.host)} -w ${q2(token)}
1704
1704
  `,
1705
1705
  stdio: ["pipe", "ignore", "pipe"]
1706
1706
  });
@@ -2185,6 +2185,12 @@ function parseSandbox(raw, file) {
2185
2185
  const r = raw;
2186
2186
  const packages = stringList(r.packages, "sandbox.packages", file);
2187
2187
  const setup = stringList(r.setup, "sandbox.setup", file);
2188
+ const warm = stringList(r.warm, "sandbox.warm", file);
2189
+ for (const w of warm) {
2190
+ if (w.startsWith("/") || w.split("/").some((seg) => seg === ".." || seg === "" || seg === ".") || /[\\\s]/.test(w)) {
2191
+ throw new RepoConfigError(`${file}: sandbox.warm entry "${w}" must be a repo-relative path or glob with no "..", empty segments, backslashes or whitespace`);
2192
+ }
2193
+ }
2188
2194
  for (const p of packages) {
2189
2195
  if (!PACKAGE_NAME.test(p)) throw new RepoConfigError(`${file}: sandbox.packages entry "${p}" is not a package name (lowercase letters, digits, + . - and an optional =version)`);
2190
2196
  }
@@ -2198,7 +2204,7 @@ function parseSandbox(raw, file) {
2198
2204
  throw new RepoConfigError(`${file}: sandbox.image and sandbox.packages are alternatives \u2014 a published image already carries its packages; put the rest in sandbox.setup`);
2199
2205
  }
2200
2206
  }
2201
- return { ...image ? { image } : {}, packages, setup };
2207
+ return { ...image ? { image } : {}, packages, setup, warm };
2202
2208
  }
2203
2209
  function parseRepoConfig(text2, file) {
2204
2210
  let raw;
@@ -2218,7 +2224,7 @@ function parseRepoConfig(text2, file) {
2218
2224
  rejectUnknown(raw.branch, BRANCH_KEYS, "branch", file);
2219
2225
  rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix", "name"]), "worktrees", file);
2220
2226
  rejectUnknown(raw.docs, /* @__PURE__ */ new Set(["triagePolicy", "designGuide"]), "docs", file);
2221
- rejectUnknown(raw.sandbox, /* @__PURE__ */ new Set(["image", "packages", "setup"]), "sandbox", file);
2227
+ rejectUnknown(raw.sandbox, /* @__PURE__ */ new Set(["image", "packages", "setup", "warm"]), "sandbox", file);
2222
2228
  if (raw.version === void 0) throw new RepoConfigError(`${file}: version is required`);
2223
2229
  if (raw.version !== SPEC_VERSION) {
2224
2230
  throw new RepoConfigError(
@@ -2719,7 +2725,22 @@ function parseShipRunner(v) {
2719
2725
  if (v === "host" || v === "sandbox") return v;
2720
2726
  throw new ConfigError(`ship.runner must be "host" or "sandbox", got ${JSON.stringify(v)}`);
2721
2727
  }
2722
- var SANDBOX_KEYS = ["bin", "image", "policy", "cacheVolume", "mirrorDir", "workDir", "agentBin", "forgeTokenVar"];
2728
+ var SANDBOX_KEYS = [
2729
+ "bin",
2730
+ "image",
2731
+ "policy",
2732
+ "cacheVolume",
2733
+ "mirrorDir",
2734
+ "workDir",
2735
+ "agentBin",
2736
+ "forgeTokenVar",
2737
+ "engine",
2738
+ "cacheWorkspace",
2739
+ "cacheImage",
2740
+ "depsImage",
2741
+ "depsCommand",
2742
+ "depsLockfile"
2743
+ ];
2723
2744
  function parseShipSandbox(v) {
2724
2745
  if (v === void 0 || v === null) return void 0;
2725
2746
  if (typeof v !== "object" || Array.isArray(v)) throw new ConfigError("ship.sandbox must be a map of settings");
@@ -5352,8 +5373,9 @@ function attentionProblems(items, already) {
5352
5373
  // src/sandbox-image.ts
5353
5374
  import { createHash as createHash5 } from "crypto";
5354
5375
  import { spawn as spawn2 } from "child_process";
5376
+ import { execFileSync as execFileSync4 } from "child_process";
5355
5377
  import { mkdirSync as mkdirSync9, readFileSync as readFileSync14, renameSync as renameSync3, writeFileSync as writeFileSync10, rmSync as rmSync9 } from "fs";
5356
- import { join as join15 } from "path";
5378
+ import { dirname as dirname7, join as join15 } from "path";
5357
5379
  import { tmpdir as tmpdir2 } from "os";
5358
5380
 
5359
5381
  // src/passenger-containers.ts
@@ -5613,7 +5635,25 @@ function crewLayer(base) {
5613
5635
  ].join("\n");
5614
5636
  return { name: "crew", tag: `crew-sandbox-crew:${hash({ base: base.tag, dockerfile, files })}`, dockerfile, files };
5615
5637
  }
5616
- function repoLayer(repo, decl, crew) {
5638
+ var WARM_DIR = "/tmp/crew-warm";
5639
+ var WARM_MAX_BYTES = 64 * 1024 * 1024;
5640
+ var globToRegExp = (glob) => new RegExp(`^${glob.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
5641
+ function readWarmFiles(dir, ref, patterns) {
5642
+ if (patterns.length === 0) return {};
5643
+ const git2 = (args) => execFileSync4("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: WARM_MAX_BYTES, stdio: ["ignore", "pipe", "pipe"] });
5644
+ const all = git2(["ls-tree", "-r", "--name-only", ref]).split("\n").filter(Boolean);
5645
+ const res = patterns.map(globToRegExp);
5646
+ const out = {};
5647
+ let total = 0;
5648
+ for (const path of all.filter((f) => res.some((re) => re.test(f))).sort()) {
5649
+ const content = git2(["show", `${ref}:${path}`]);
5650
+ total += content.length;
5651
+ if (total > WARM_MAX_BYTES) throw new Error(`sandbox.warm files exceed ${WARM_MAX_BYTES} bytes`);
5652
+ out[path] = content;
5653
+ }
5654
+ return out;
5655
+ }
5656
+ function repoLayer(repo, decl, crew, warm) {
5617
5657
  const env = Object.entries(CREW_ENV).map(([k, v]) => `${k}=${v}`).join(" ");
5618
5658
  const lines2 = [];
5619
5659
  if (decl.image) {
@@ -5635,15 +5675,22 @@ function repoLayer(repo, decl, crew) {
5635
5675
  `USER ${SANDBOX_UID}`,
5636
5676
  "WORKDIR /sandbox"
5637
5677
  );
5678
+ const files = {};
5679
+ const warming = Object.keys(warm).length > 0;
5680
+ if (warming) {
5681
+ for (const [path, content] of Object.entries(warm)) files[`warm/${path}`] = content;
5682
+ lines2.push(`COPY --chown=${SANDBOX_UID}:${SANDBOX_UID} warm/ ${WARM_DIR}/`, `WORKDIR ${WARM_DIR}`);
5683
+ }
5638
5684
  for (const step of decl.setup) lines2.push(run(step));
5685
+ if (warming) lines2.push("WORKDIR /sandbox", run(`rm -rf ${WARM_DIR}`));
5639
5686
  lines2.push(`LABEL io.crew.sandbox.layer="repo" io.crew.sandbox.repo=${JSON.stringify(repo)}`, "");
5640
5687
  const dockerfile = lines2.join("\n");
5641
- return { name: "repo", tag: `crew-sandbox/${slug(repo)}:${hash({ crew: crew.tag, dockerfile })}`, dockerfile, files: {} };
5688
+ return { name: "repo", tag: `crew-sandbox/${slug(repo)}:${hash({ crew: crew.tag, dockerfile, files })}`, dockerfile, files };
5642
5689
  }
5643
- function planSandbox(repo, decl) {
5690
+ function planSandbox(repo, decl, warm = {}) {
5644
5691
  const base = baseLayer();
5645
5692
  const crew = crewLayer(base);
5646
- const layer = repoLayer(repo, decl, crew);
5693
+ const layer = repoLayer(repo, decl, crew, warm);
5647
5694
  return { repo, tag: layer.tag, layers: [base, crew, layer] };
5648
5695
  }
5649
5696
  var sandboxStatePath = (stateDir) => join15(stateDir, "sandbox-images.json");
@@ -5718,7 +5765,10 @@ async function buildSandboxImage(plan, deps) {
5718
5765
  mkdirSync9(ctx, { recursive: true });
5719
5766
  try {
5720
5767
  writeFileSync10(join15(ctx, "Dockerfile"), layer.dockerfile);
5721
- for (const [name, content] of Object.entries(layer.files)) writeFileSync10(join15(ctx, name), content);
5768
+ for (const [name, content] of Object.entries(layer.files)) {
5769
+ mkdirSync9(dirname7(join15(ctx, name)), { recursive: true });
5770
+ writeFileSync10(join15(ctx, name), content);
5771
+ }
5722
5772
  const code = await deps.run(["build", "--tag", layer.tag, "--file", join15(ctx, "Dockerfile"), ctx], logPath);
5723
5773
  if (code !== 0) {
5724
5774
  state[plan.repo] = { ...entry, failure: { tag: plan.tag, at: now().toISOString(), logPath } };
@@ -5749,7 +5799,7 @@ function sandboxFailedItem(repo, tag, layer, logPath, nowIso = (/* @__PURE__ */
5749
5799
  async function sandboxBuildTick(repos, deps, force = false) {
5750
5800
  const nowMs = (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
5751
5801
  for (const r of repos) {
5752
- const plan = planSandbox(r.name, r.decl);
5802
+ const plan = planSandbox(r.name, r.decl, r.warm);
5753
5803
  const state = readSandboxState(deps.stateDir)[r.name];
5754
5804
  const upToDate = state?.tags[0]?.tag === plan.tag && deps.hasImage(plan.tag);
5755
5805
  if (upToDate) {
@@ -5772,10 +5822,10 @@ async function sandboxBuildTick(repos, deps, force = false) {
5772
5822
  }
5773
5823
 
5774
5824
  // src/doctor-checks.ts
5775
- import { execFileSync as execFileSync4 } from "child_process";
5825
+ import { execFileSync as execFileSync5 } from "child_process";
5776
5826
  var PROBE_BRANCH = "crew-probe-branch";
5777
5827
  var realGit = (cwd, args, env) => {
5778
- execFileSync4("git", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout: 3e4 });
5828
+ execFileSync5("git", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout: 3e4 });
5779
5829
  };
5780
5830
  function isSshRemote(url) {
5781
5831
  return /^ssh:\/\//.test(url) || /^[\w.-]+@[\w.-]+:/.test(url);
@@ -5897,16 +5947,16 @@ function roleCapWarnings(ship) {
5897
5947
 
5898
5948
  // src/selftest.ts
5899
5949
  import { spawn as spawn5 } from "child_process";
5900
- import { existsSync as existsSync22, mkdirSync as mkdirSync14, mkdtempSync as mkdtempSync2, readFileSync as readFileSync21, rmSync as rmSync14, writeFileSync as writeFileSync16 } from "fs";
5950
+ import { existsSync as existsSync25, mkdirSync as mkdirSync14, mkdtempSync as mkdtempSync2, readFileSync as readFileSync22, rmSync as rmSync14, writeFileSync as writeFileSync16 } from "fs";
5901
5951
  import { tmpdir as tmpdir4 } from "os";
5902
- import { join as join25 } from "path";
5952
+ import { join as join27 } from "path";
5903
5953
 
5904
5954
  // src/agent.ts
5905
- import { spawnSync as spawnSync5 } from "child_process";
5906
- import { readFileSync as readFileSync20, existsSync as existsSync21, statSync as statSync7, mkdirSync as mkdirSync13, writeFileSync as writeFileSync15, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
5955
+ import { spawnSync as spawnSync6 } from "child_process";
5956
+ import { readFileSync as readFileSync21, existsSync as existsSync24, statSync as statSync7, mkdirSync as mkdirSync13, writeFileSync as writeFileSync15, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
5907
5957
  import { StringDecoder } from "string_decoder";
5908
5958
  import { finished } from "stream/promises";
5909
- import { join as join24, dirname as dirname8, basename as basename8 } from "path";
5959
+ import { join as join26, dirname as dirname9, basename as basename8 } from "path";
5910
5960
  import { createHash as createHash6 } from "crypto";
5911
5961
 
5912
5962
  // src/state.ts
@@ -7061,9 +7111,9 @@ async function addRunToTicket(target, t, recordId, run3) {
7061
7111
  }
7062
7112
  }
7063
7113
  var queueDir = (stateDir) => join17(stateDir, "pending-agent-logs");
7064
- function queueAgentRun(stateDir, name, q) {
7114
+ function queueAgentRun(stateDir, name, q2) {
7065
7115
  mkdirSync11(queueDir(stateDir), { recursive: true });
7066
- writeFileSync12(join17(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
7116
+ writeFileSync12(join17(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
7067
7117
  }
7068
7118
  async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
7069
7119
  const dir = queueDir(stateDir);
@@ -7071,23 +7121,23 @@ async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__
7071
7121
  let sent = 0;
7072
7122
  for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
7073
7123
  const path = join17(dir, f);
7074
- let q;
7124
+ let q2;
7075
7125
  try {
7076
- q = JSON.parse(readFileSync16(path, "utf8"));
7126
+ q2 = JSON.parse(readFileSync16(path, "utf8"));
7077
7127
  } catch {
7078
7128
  continue;
7079
7129
  }
7080
- if (q.version !== 1 || q.route !== route2 || now.toISOString() < q.nextAttemptAt) continue;
7130
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
7081
7131
  try {
7082
- const apiKey = await keyFor(q.role);
7132
+ const apiKey = await keyFor(q2.role);
7083
7133
  if (!apiKey) continue;
7084
- const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
7134
+ const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
7085
7135
  if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
7086
7136
  if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
7087
7137
  version: 1,
7088
- route: q.route,
7089
- role: q.role,
7090
- target: q.target,
7138
+ route: q2.route,
7139
+ role: q2.role,
7140
+ target: q2.target,
7091
7141
  write: sentRun.counterRetry,
7092
7142
  attempts: 0,
7093
7143
  nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -7101,22 +7151,22 @@ async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__
7101
7151
  log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
7102
7152
  continue;
7103
7153
  }
7104
- const attempts = q.attempts + 1;
7154
+ const attempts = q2.attempts + 1;
7105
7155
  writeFileSync12(path, JSON.stringify({
7106
- ...q,
7156
+ ...q2,
7107
7157
  attempts,
7108
7158
  warned: true,
7109
7159
  nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
7110
7160
  }));
7111
- if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
7161
+ if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
7112
7162
  }
7113
7163
  }
7114
7164
  return sent;
7115
7165
  }
7116
7166
  var countersDir = (stateDir) => join17(stateDir, "pending-ticket-counters");
7117
- function queueTicketCounters(stateDir, name, q) {
7167
+ function queueTicketCounters(stateDir, name, q2) {
7118
7168
  mkdirSync11(countersDir(stateDir), { recursive: true });
7119
- writeFileSync12(join17(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
7169
+ writeFileSync12(join17(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
7120
7170
  }
7121
7171
  async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
7122
7172
  const dir = countersDir(stateDir);
@@ -7124,34 +7174,34 @@ async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /
7124
7174
  let sent = 0;
7125
7175
  for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
7126
7176
  const path = join17(dir, f);
7127
- let q;
7177
+ let q2;
7128
7178
  try {
7129
- q = JSON.parse(readFileSync16(path, "utf8"));
7179
+ q2 = JSON.parse(readFileSync16(path, "utf8"));
7130
7180
  } catch {
7131
7181
  continue;
7132
7182
  }
7133
- if (q.version !== 1 || q.route !== route2 || now.toISOString() < q.nextAttemptAt) continue;
7183
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
7134
7184
  try {
7135
- const apiKey = await keyFor(q.role);
7185
+ const apiKey = await keyFor(q2.role);
7136
7186
  if (!apiKey) continue;
7137
- await writeCounters({ ...q.target, apiKey }, q.write);
7187
+ await writeCounters({ ...q2.target, apiKey }, q2.write);
7138
7188
  rmSync11(path, { force: true });
7139
7189
  sent += 1;
7140
- log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
7190
+ log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
7141
7191
  } catch (e) {
7142
7192
  if (!isRetryable(e)) {
7143
7193
  rmSync11(path, { force: true });
7144
7194
  log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
7145
7195
  continue;
7146
7196
  }
7147
- const attempts = q.attempts + 1;
7197
+ const attempts = q2.attempts + 1;
7148
7198
  writeFileSync12(path, JSON.stringify({
7149
- ...q,
7199
+ ...q2,
7150
7200
  attempts,
7151
7201
  warned: true,
7152
7202
  nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
7153
7203
  }));
7154
- if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
7204
+ if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
7155
7205
  }
7156
7206
  }
7157
7207
  return sent;
@@ -8516,15 +8566,15 @@ function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
8516
8566
 
8517
8567
  // src/run-worktree.ts
8518
8568
  import { existsSync as existsSync17 } from "fs";
8519
- import { dirname as dirname7, join as join19 } from "path";
8569
+ import { dirname as dirname8, join as join19 } from "path";
8520
8570
 
8521
8571
  // src/ports.ts
8522
- import { execFileSync as execFileSync5 } from "child_process";
8572
+ import { execFileSync as execFileSync6 } from "child_process";
8523
8573
  import { existsSync as existsSync16 } from "fs";
8524
8574
  import { join as join18 } from "path";
8525
8575
  var run2 = (cmd, args) => {
8526
8576
  try {
8527
- return execFileSync5(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
8577
+ return execFileSync6(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
8528
8578
  } catch {
8529
8579
  return "";
8530
8580
  }
@@ -8665,7 +8715,7 @@ function planRunWorktree(dir, cfg, t, role, opts = {}) {
8665
8715
  if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
8666
8716
  return { action: "recut", path: existing.path, branch, base: baseRef };
8667
8717
  }
8668
- const path = join19(dirname7(dir), worktreeForTicket(dir, cfg, t, role));
8718
+ const path = join19(dirname8(dir), worktreeForTicket(dir, cfg, t, role));
8669
8719
  if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
8670
8720
  return { action: "cut-remote", path, branch: located.branch };
8671
8721
  }
@@ -9936,11 +9986,11 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
9936
9986
  }
9937
9987
 
9938
9988
  // src/runner.ts
9939
- import { spawn as spawn3, execFile, execFileSync as execFileSync6 } from "child_process";
9989
+ import { spawn as spawn3, execFile as execFile2, execFileSync as execFileSync7 } from "child_process";
9940
9990
  import { mkdtempSync, writeFileSync as writeFileSync14, rmSync as rmSync13 } from "fs";
9941
9991
  import { tmpdir as tmpdir3 } from "os";
9942
- import { basename as basename7, join as join23 } from "path";
9943
- import { promisify } from "util";
9992
+ import { basename as basename7, join as join25 } from "path";
9993
+ import { promisify as promisify2 } from "util";
9944
9994
 
9945
9995
  // src/run-limit.ts
9946
9996
  var DEFAULT_MAX_RUN_MINUTES = 240;
@@ -9991,1216 +10041,1712 @@ function runKilledComment(b) {
9991
10041
  The agent run on this ticket was stopped at its wall-clock limit of ${b.limitMinutes} minutes before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The worktree is left as it was. The next run on this seat resumes from it rather than starting over.`;
9992
10042
  }
9993
10043
 
9994
- // src/runner.ts
9995
- var execFileP = promisify(execFile);
9996
- var DEFAULT_SANDBOX_CONFIG = {
9997
- bin: "openshell",
9998
- mirrorDir: "/cache",
9999
- workDir: "/sandbox/work"
10044
+ // src/sandbox-cache.ts
10045
+ import { execFile } from "child_process";
10046
+ import { promisify } from "util";
10047
+
10048
+ // src/deprovision-host.ts
10049
+ import { spawnSync as spawnSync5 } from "child_process";
10050
+ import { existsSync as existsSync23, readdirSync as readdirSync11 } from "fs";
10051
+
10052
+ // src/interrupted.ts
10053
+ import { existsSync as existsSync21, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
10054
+ import { join as join23 } from "path";
10055
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
10056
+ function interruptedComment(b) {
10057
+ const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
10058
+ return `${INTERRUPTED_MARKER}
10059
+
10060
+ The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
10061
+ }
10062
+ var handled = /* @__PURE__ */ new Set();
10063
+ var pidAlive = (pid) => {
10064
+ try {
10065
+ process.kill(pid, 0);
10066
+ return true;
10067
+ } catch (e) {
10068
+ return e.code === "EPERM";
10069
+ }
10000
10070
  };
10001
- var SANDBOX_STDIN_LIMIT = 4 * 1024 * 1024 - 64 * 1024;
10002
- var SANDBOX_PROMPT_PATH = "/sandbox/prompt.txt";
10003
- var HostRunner = class {
10004
- kind = "host";
10005
- start(plan, o) {
10006
- const child = spawn3(plan.bin, plan.args, {
10007
- cwd: plan.cwd,
10008
- env: o.env,
10009
- stdio: ["pipe", o.streamFd, "inherit"],
10010
- detached: true
10011
- });
10012
- return { child, handle: { runner: "host", pid: child.pid } };
10071
+ function runFinished(streamPath) {
10072
+ const path = `${streamPath}.meta.json`;
10073
+ if (!existsSync21(path)) return false;
10074
+ try {
10075
+ return JSON.parse(readFileSync20(path, "utf8")).running !== true;
10076
+ } catch {
10077
+ return true;
10013
10078
  }
10014
- async kill(handle) {
10015
- if (handle.pid !== void 0) await killRunGroup(handle.pid);
10079
+ }
10080
+ function orphanedStreams(o) {
10081
+ const dir = join23(o.stateDir, "streams");
10082
+ if (!existsSync21(dir)) return [];
10083
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
10084
+ const out = [];
10085
+ for (const name of readdirSync9(dir)) {
10086
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
10087
+ const streamPath = join23(dir, name);
10088
+ if (runFinished(streamPath)) continue;
10089
+ const sidecar = readRunSidecar(streamPath);
10090
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
10091
+ if (ownerAlive(sidecar.ownerPid)) continue;
10092
+ out.push({ streamPath, sidecar });
10016
10093
  }
10017
- isAlive(handle) {
10018
- if (handle.pid === void 0) return false;
10019
- try {
10020
- process.kill(handle.pid, 0);
10021
- return true;
10022
- } catch (e) {
10023
- return e.code === "EPERM";
10094
+ return out;
10095
+ }
10096
+ async function reconcileRuns(o) {
10097
+ let adopted = 0;
10098
+ let interrupted = 0;
10099
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
10100
+ if (handled.has(streamPath)) continue;
10101
+ handled.add(streamPath);
10102
+ const isAlive = o.isAlive ?? runIsAlive;
10103
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
10104
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
10105
+ const work = (async () => {
10106
+ try {
10107
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
10108
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
10109
+ isAlive: o.isAlive,
10110
+ pollMs: o.pollMs,
10111
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
10112
+ });
10113
+ if (result.adopted) adopted++;
10114
+ else interrupted++;
10115
+ try {
10116
+ await o.afterRun?.(sidecar, result);
10117
+ } catch (e) {
10118
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
10119
+ }
10120
+ } catch (e) {
10121
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
10122
+ } finally {
10123
+ if (lock?.ok) lock.release();
10124
+ if (ticketLock?.ok) ticketLock.release();
10125
+ }
10126
+ })();
10127
+ if (isAlive(sidecar)) {
10128
+ if (o.inFlight) {
10129
+ o.inFlight.add(work);
10130
+ void work.finally(() => o.inFlight.delete(work));
10131
+ }
10132
+ } else {
10133
+ await work;
10024
10134
  }
10025
10135
  }
10026
- reattach(meta) {
10027
- return { runner: "host", pid: meta.pid };
10028
- }
10029
- async release() {
10136
+ return { adopted, interrupted };
10137
+ }
10138
+
10139
+ // src/live-runs.ts
10140
+ import { existsSync as existsSync22, readdirSync as readdirSync10 } from "fs";
10141
+ import { join as join24 } from "path";
10142
+ function liveRuns(stateDir, isAlive = runIsAlive) {
10143
+ const dir = join24(stateDir, "streams");
10144
+ if (!existsSync22(dir)) return [];
10145
+ const out = [];
10146
+ for (const name of readdirSync10(dir)) {
10147
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
10148
+ const streamPath = join24(dir, name);
10149
+ if (runFinished(streamPath)) continue;
10150
+ const sidecar = readRunSidecar(streamPath);
10151
+ if (!sidecar || !isAlive(sidecar)) continue;
10152
+ out.push({ ...sidecar, streamPath });
10030
10153
  }
10031
- };
10032
- function shQuote2(s) {
10033
- return `'${s.replace(/'/g, `'\\''`)}'`;
10154
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
10034
10155
  }
10035
- function sandboxName(plan, now = Date.now()) {
10036
- const base = [plan.route, plan.role, plan.ticket ?? ""].join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10037
- return `crew-${base.slice(0, 36)}-${now.toString(36)}`.replace(/-+/g, "-");
10156
+ function siblingRuns(runs, ownTicketRecordId) {
10157
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
10038
10158
  }
10039
- var SandboxRunner = class {
10040
- kind = "sandbox";
10041
- cfg;
10042
- /** Injected for tests; the real one reads the plan's checkout. */
10043
- originOf;
10044
- constructor(cfg = {}, originOf = gitOrigin) {
10045
- this.cfg = { ...DEFAULT_SANDBOX_CONFIG, ...cfg };
10046
- this.originOf = originOf;
10047
- }
10048
- os(args, o = {}) {
10049
- return execFileP(this.cfg.bin, args, { env: o.env ?? process.env, maxBuffer: 16 * 1024 * 1024 });
10050
- }
10051
- async start(plan, o) {
10052
- const origin = this.originOf(plan.cwd);
10053
- if (!origin) throw new Error(`cannot find the origin remote of ${plan.cwd}, so there is nothing to clone into the sandbox`);
10054
- const repoName = basename7(origin).replace(/\.git$/, "");
10055
- const name = sandboxName(plan);
10056
- const { cfg } = this;
10057
- const secretKeys = Object.keys(plan.setEnv);
10058
- if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0 && !secretKeys.includes(cfg.forgeTokenVar)) secretKeys.push(cfg.forgeTokenVar);
10059
- const createEnv = { ...process.env, ...plan.setEnv };
10060
- if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0) createEnv[cfg.forgeTokenVar] = o.env[cfg.forgeTokenVar];
10061
- const create2 = ["sandbox", "create", "--name", name, "--detach"];
10062
- if (cfg.policy) create2.push("--policy", cfg.policy);
10063
- if (cfg.image) create2.push("--from", cfg.image);
10064
- if (cfg.cacheVolume) {
10065
- create2.push("--driver-config-json", JSON.stringify({
10066
- docker: { mounts: [{ type: "volume", source: cfg.cacheVolume, target: cfg.mirrorDir, read_only: true }] }
10067
- }));
10068
- }
10069
- for (const k of secretKeys) create2.push("--env-from", k);
10070
- const maxSeconds = Math.max(60, Math.ceil((plan.maxRunMinutes ?? 0) * 60) + 600);
10071
- create2.push("--", "sleep", String(plan.maxRunMinutes ? maxSeconds : 86400));
10072
- const t0 = Date.now();
10073
- try {
10074
- await this.os(create2, { env: createEnv });
10075
- } catch (e) {
10076
- await this.deleteQuietly(name);
10077
- throw new Error(`openshell could not create the sandbox: ${describeExecError(e)}`);
10078
- }
10079
- o.emit.emit(`sandbox_created: ${name}`, { role: plan.role, data: { event: "sandbox_created", sandbox: name, ms: Date.now() - t0 } });
10080
- const handle = { runner: "sandbox", sandbox: name, sandboxBin: cfg.bin };
10159
+ async function settleRunEnd(o) {
10160
+ const { tracker, emit } = o;
10161
+ const all = o.runs ?? liveRuns(o.stateDir);
10162
+ const slug2 = routeSlug(o.route.route);
10163
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
10164
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
10165
+ const newest = (rs) => rs[rs.length - 1];
10166
+ if (o.memberId) {
10081
10167
  try {
10082
- const branch = `crew-run-${name.slice(-8)}`;
10083
- const clone = [
10084
- `git clone --quiet --shared ${shQuote2(`${cfg.mirrorDir}/${repoName}.git`)} ${shQuote2(cfg.workDir)}`,
10085
- `cd ${shQuote2(cfg.workDir)}`,
10086
- `git remote set-url origin ${shQuote2(origin)}`,
10087
- `git checkout --quiet -b ${shQuote2(branch)}`
10088
- ].join(" && ");
10089
- await this.os(["sandbox", "exec", "-n", name, "--no-login-shell", "--", "sh", "-c", clone]);
10090
- const harness = cfg.agentBin ?? basename7(plan.bin);
10091
- const viaUpload = Buffer.byteLength(plan.prompt) > SANDBOX_STDIN_LIMIT;
10092
- if (viaUpload) {
10093
- const tmp = mkdtempSync(join23(tmpdir3(), "crew-prompt-"));
10094
- try {
10095
- const file = join23(tmp, "prompt.txt");
10096
- writeFileSync14(file, plan.prompt, { mode: 384 });
10097
- await this.os(["sandbox", "upload", name, file, SANDBOX_PROMPT_PATH]);
10098
- } finally {
10099
- rmSync13(tmp, { recursive: true, force: true });
10100
- }
10101
- }
10102
- const command2 = viaUpload ? ["sh", "-c", `exec ${[harness, ...plan.args].map(shQuote2).join(" ")} < ${SANDBOX_PROMPT_PATH}`] : [harness, ...plan.args];
10103
- const child = spawn3(cfg.bin, ["sandbox", "exec", "-n", name, "--no-login-shell", "--workdir", cfg.workDir, "--", ...command2], {
10104
- env: process.env,
10105
- stdio: ["pipe", o.streamFd, "inherit"],
10106
- detached: true
10107
- });
10108
- return { child, handle: { ...handle, pid: child.pid }, ...viaUpload ? { promptDelivered: true } : {} };
10168
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
10169
+ else await tracker.setCrewStatus(o.memberId, "idle");
10109
10170
  } catch (e) {
10110
- await this.release(handle, o.emit);
10111
- throw new Error(`could not prepare the sandbox ${name}: ${describeExecError(e)}`);
10171
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
10112
10172
  }
10113
10173
  }
10114
- async kill(handle) {
10115
- if (handle.sandbox) await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10116
- if (handle.pid !== void 0) await killRunGroup(handle.pid, { graceMs: 2e3 });
10117
- }
10118
- isAlive(handle) {
10119
- return new HostRunner().isAlive(handle);
10120
- }
10121
- reattach(meta) {
10122
- return { runner: "sandbox", pid: meta.pid, sandbox: meta.sandbox, sandboxBin: meta.sandboxBin };
10123
- }
10124
- async release(handle, emit) {
10125
- if (!handle.sandbox) return;
10126
- const t0 = Date.now();
10127
- await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10128
- emit.emit(`sandbox_deleted: ${handle.sandbox}`, { data: { event: "sandbox_deleted", sandbox: handle.sandbox, ms: Date.now() - t0 } });
10129
- }
10130
- async deleteQuietly(name, bin = this.cfg.bin) {
10131
- try {
10132
- await execFileP(bin, ["sandbox", "delete", name], { timeout: 6e4 });
10133
- } catch {
10174
+ try {
10175
+ if (shipSiblings.length) {
10176
+ const n = newest(shipSiblings);
10177
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
10178
+ } else {
10179
+ await tracker.beatIdle(o.shipName);
10134
10180
  }
10181
+ } catch (e) {
10182
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
10135
10183
  }
10136
- };
10137
- function describeExecError(e) {
10138
- const err3 = e;
10139
- return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10140
10184
  }
10141
- function gitOrigin(cwd) {
10185
+ function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
10142
10186
  try {
10143
- return execFileSync6("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
10187
+ return worktreeForNumber(d, n)?.path ?? null;
10144
10188
  } catch {
10145
10189
  return null;
10146
10190
  }
10191
+ }) {
10192
+ const ownRoute = run3.route === routeSlug(route2.route);
10193
+ const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
10194
+ const num = run3.ticket?.replace(/^\D+/, "") ?? "";
10195
+ return {
10196
+ role: run3.role,
10197
+ seat: ownRoute ? seatIds[run3.role] ?? null : null,
10198
+ ticket: run3.ticket,
10199
+ ticketRecordId: run3.ticketRecordId,
10200
+ repo: run3.repo ?? null,
10201
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
10202
+ stream: run3.streamPath,
10203
+ pid: run3.pid,
10204
+ startedAt: run3.spawnedAt,
10205
+ deadline: run3.deadline ?? null
10206
+ };
10147
10207
  }
10148
- function runnerFor(kind, sandbox) {
10149
- return kind === "sandbox" ? new SandboxRunner(sandbox) : new HostRunner();
10150
- }
10151
- function runnerOfSidecar(sc) {
10152
- return sc.runner === "sandbox" ? new SandboxRunner(sc.sandboxBin ? { bin: sc.sandboxBin } : {}) : new HostRunner();
10153
- }
10154
- function checkSandboxRunner(cfg = {}, run3 = (b, a) => execFileSync6(b, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15e3 })) {
10155
- const bin = cfg.bin ?? DEFAULT_SANDBOX_CONFIG.bin;
10156
- let version;
10208
+
10209
+ // src/seat-keys.ts
10210
+ import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
10211
+ var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
10212
+ function keyIsAdmin(route2) {
10213
+ const role = route2.resolved?.role;
10214
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
10215
+ }
10216
+ var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
10217
+ var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
10218
+ var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
10219
+ function seatKeySlug(workspaceSlug, agentId) {
10220
+ return `${workspaceSlug}#agent:${agentId}`;
10221
+ }
10222
+ function slugFor(route2, agentId) {
10223
+ const [workspaceSlug] = splitRoute(route2.route);
10224
+ return seatKeySlug(workspaceSlug, agentId);
10225
+ }
10226
+ function seatAgentId(route2, role) {
10227
+ return route2.resolved?.agentPersonas?.[role]?.agentId;
10228
+ }
10229
+ async function resolveReleaseKey(route2, getStore = getSessionStore) {
10230
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10231
+ const agentId = route2.resolved?.releaseAgentId;
10232
+ if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
10157
10233
  try {
10158
- version = run3(bin, ["--version"]).trim();
10234
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10235
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10236
+ return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10159
10237
  } catch (e) {
10160
- return { ok: false, detail: `${bin} is not installed or not on the scheduler PATH (${e.message.split("\n")[0]})` };
10238
+ const reason = describeReadError(e);
10239
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10161
10240
  }
10241
+ }
10242
+ async function resolveSeatKey(route2, role, getStore = getSessionStore) {
10243
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10244
+ if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
10245
+ const agentId = seatAgentId(route2, role);
10246
+ if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
10162
10247
  try {
10163
- run3(bin, ["status"]);
10248
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10249
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10250
+ return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10164
10251
  } catch (e) {
10165
- const err3 = e;
10166
- return {
10167
- ok: false,
10168
- detail: `${version}: the gateway does not answer (${(err3.stderr || err3.message).trim().split("\n").filter(Boolean).slice(0, 2).join(" / ")}). Start it; on Docker Desktop its docker section needs supervisor_network_mode = "bridge", and allow_driver_config = true for the cache volume mount.`
10169
- };
10252
+ const reason = describeReadError(e);
10253
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10170
10254
  }
10171
- return { ok: true, detail: version };
10172
10255
  }
10173
-
10174
- // src/agent.ts
10175
- var DIGEST_MAX_AGE_SECONDS = 600;
10176
- var LEGACY_TOOLCHAIN_HARNESS = "claude";
10177
- var LEGACY_TOOLCHAIN_PROVIDER = "anthropic";
10178
- function scrubbedEnv(env = process.env, harness = LEGACY_TOOLCHAIN_HARNESS) {
10179
- const out = { ...env };
10180
- for (const v of adapterFor(harness).unsetEnv) delete out[v];
10181
- return out;
10256
+ function safeStderrLine(e) {
10257
+ const err3 = e;
10258
+ const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
10259
+ for (const line of raw.split("\n")) {
10260
+ const t = line.trim();
10261
+ if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
10262
+ return t.slice(0, 120);
10263
+ }
10264
+ return void 0;
10182
10265
  }
10183
- var CREW_LANE_ROLE_VAR = "CREW_LANE_ROLE";
10184
- function assemblePrompt(p) {
10185
- const sections = [p.roster, p.environment, `${p.common}${p.brief}`];
10186
- if (p.digest) sections.push(p.digest);
10187
- return sections.join("\n\n");
10266
+ function describeReadError(e) {
10267
+ const fsErr = e;
10268
+ if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
10269
+ const code = e?.code;
10270
+ const line = safeStderrLine(e);
10271
+ const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
10272
+ const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
10273
+ return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
10188
10274
  }
10189
- var AgentError = class extends Error {
10190
- };
10191
- var AgentSpawnError = class extends AgentError {
10192
- };
10193
- var WorkspaceAuthRefused = class extends AgentSpawnError {
10194
- };
10195
- var WorkspaceAllowanceExhausted = class extends AgentError {
10196
- };
10197
- var ALLOWANCE_REFUSAL_MARKER = "AI_ALLOWANCE_EXCEEDED";
10198
- function streamShowsAllowanceRefusal(streamPath) {
10199
- let raw;
10275
+ function keychainExitCode(reason) {
10276
+ const m = /security exited (\d+)/.exec(reason);
10277
+ return m ? Number(m[1]) : void 0;
10278
+ }
10279
+ function describeStoreError(e) {
10280
+ const msg = e instanceof Error ? e.message : String(e);
10281
+ if (/User interaction is not allowed/i.test(msg)) {
10282
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
10283
+ }
10284
+ return "keychain unavailable (could not write to the OS keychain)";
10285
+ }
10286
+ async function mintMissingSeatKeys(route2, o) {
10287
+ const out = [];
10288
+ if (!route2.resolved) return out;
10289
+ const resolved = route2.resolved;
10290
+ const doFetch = o.fetchImpl ?? fetch;
10291
+ const targets = [
10292
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
10293
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
10294
+ ];
10295
+ let store;
10200
10296
  try {
10201
- raw = readFileSync20(streamPath, "utf8");
10202
- } catch {
10203
- return false;
10297
+ store = (o.getStore ?? getSessionStore)();
10298
+ } catch (e) {
10299
+ return targets.map(({ role }) => ({
10300
+ role,
10301
+ action: "failed",
10302
+ detail: describeStoreError(e)
10303
+ }));
10204
10304
  }
10205
- if (!raw.includes(ALLOWANCE_REFUSAL_MARKER)) return false;
10206
- for (const line of raw.split("\n")) {
10207
- if (!line.includes(ALLOWANCE_REFUSAL_MARKER)) continue;
10208
- let ev;
10305
+ const host = hostFromUrl2(route2.baseUrl);
10306
+ const base = route2.baseUrl.replace(/\/+$/, "");
10307
+ if (!o.dryRun) {
10308
+ const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
10209
10309
  try {
10210
- ev = JSON.parse(line);
10211
- } catch {
10310
+ await store.set(host, probeSlug, {
10311
+ apiKey: "probe",
10312
+ workspaceId: resolved.workspaceId,
10313
+ identityId: "_probe",
10314
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
10315
+ });
10316
+ await store.clear(host, probeSlug);
10317
+ } catch (e) {
10318
+ const detail = `${describeStoreError(e)}; no keys minted`;
10319
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
10320
+ }
10321
+ }
10322
+ for (const { role, agentId } of targets) {
10323
+ if (!agentId) {
10324
+ out.push({ role, action: "no_agent" });
10212
10325
  continue;
10213
10326
  }
10214
- if (typeof ev !== "object" || ev === null) continue;
10215
- const e = ev;
10216
- if (e.type === "result" && e.is_error === true) {
10217
- if (typeof e.result === "string" && e.result.includes(ALLOWANCE_REFUSAL_MARKER)) return true;
10218
- } else if (e.type === "assistant" && (e.isApiErrorMessage === true || e.error !== void 0)) {
10219
- if (JSON.stringify(e.message ?? "").includes(ALLOWANCE_REFUSAL_MARKER)) return true;
10327
+ const slug2 = slugFor(route2, agentId);
10328
+ try {
10329
+ if ((await store.get(host, slug2))?.apiKey) {
10330
+ out.push({ role, action: "already_stored" });
10331
+ continue;
10332
+ }
10333
+ if (o.dryRun) {
10334
+ out.push({ role, action: "minted", detail: "dry run" });
10335
+ continue;
10336
+ }
10337
+ const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
10338
+ method: "POST",
10339
+ headers: {
10340
+ Authorization: `Bearer ${resolveApiKey(route2)}`,
10341
+ "Content-Type": "application/json",
10342
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
10343
+ },
10344
+ body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
10345
+ });
10346
+ if (res.status === 403) {
10347
+ out.push({ role, action: "forbidden" });
10348
+ break;
10349
+ }
10350
+ if (!res.ok) {
10351
+ out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
10352
+ continue;
10353
+ }
10354
+ const body = await res.json();
10355
+ if (!body.key) {
10356
+ out.push({ role, action: "failed", detail: "response carried no key" });
10357
+ continue;
10358
+ }
10359
+ try {
10360
+ const session = {
10361
+ apiKey: body.key,
10362
+ workspaceId: resolved.workspaceId,
10363
+ identityId: agentId,
10364
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10365
+ ...body.id ? { keyId: body.id } : {}
10366
+ };
10367
+ await store.set(host, slug2, session);
10368
+ } catch (e) {
10369
+ let revoked = false;
10370
+ if (body.id) {
10371
+ try {
10372
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
10373
+ method: "DELETE",
10374
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10375
+ });
10376
+ revoked = del2.ok;
10377
+ } catch {
10378
+ revoked = false;
10379
+ }
10380
+ }
10381
+ out.push({
10382
+ role,
10383
+ action: "failed",
10384
+ detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
10385
+ });
10386
+ continue;
10387
+ }
10388
+ out.push({ role, action: "minted" });
10389
+ } catch (e) {
10390
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
10220
10391
  }
10221
10392
  }
10222
- return false;
10393
+ return out;
10223
10394
  }
10224
- var digestPathFor = (stateDir, routeLabel, role, runId) => join24(stateDir, `digest-${routeLabel}-${role}${runId ? `-${runId}` : ""}.md`);
10225
- function planAgentRun(o) {
10226
- const briefPath = join24(o.route.promptsDir, "personas", `lane-${o.role}.md`);
10227
- if (!existsSync21(briefPath)) {
10228
- throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
10395
+ function describeMintOutcome(o) {
10396
+ switch (o.action) {
10397
+ case "minted":
10398
+ return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
10399
+ case "already_stored":
10400
+ return `${o.role}: agent key already stored`;
10401
+ case "no_agent":
10402
+ return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
10403
+ case "forbidden":
10404
+ return FORBIDDEN_LINE;
10405
+ case "failed":
10406
+ return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
10229
10407
  }
10230
- const commonPath = join24(o.route.promptsDir, "personas", "common.md");
10231
- const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync21(commonPath);
10232
- const now = o.now ?? (() => Date.now());
10233
- const routeLabel = routeSlug(o.route.route);
10234
- const ownDigest = o.runId ? digestPathFor(o.stateDir, routeLabel, o.role, o.runId) : void 0;
10235
- const digestPath2 = ownDigest && existsSync21(ownDigest) ? ownDigest : digestPathFor(o.stateDir, routeLabel, o.role);
10236
- let digest;
10237
- let ageSeconds;
10238
- if (existsSync21(digestPath2)) {
10239
- ageSeconds = Math.floor((now() - statSync7(digestPath2).mtimeMs) / 1e3);
10240
- if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync20(digestPath2, "utf8");
10241
- }
10242
- const common = usesCommon ? readFileSync20(commonPath, "utf8") : "";
10243
- const brief = readFileSync20(briefPath, "utf8");
10244
- const effectiveCommon = o.divergedPrompt ? "" : common;
10245
- const effectiveBrief = appendAdditions(o.divergedPrompt ?? brief, o.personaAdditions);
10246
- const localSkills = new Map(listLocalSkills(o.route.promptsDir).map((s) => [s.name, s.prompt]));
10247
- let expanded;
10408
+ }
10409
+ async function revokeSeatKeys(route2, o) {
10410
+ const out = [];
10411
+ if (!route2.resolved) return out;
10412
+ const resolved = route2.resolved;
10413
+ const doFetch = o.fetchImpl ?? fetch;
10414
+ const targets = [
10415
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
10416
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
10417
+ ];
10418
+ let store;
10248
10419
  try {
10249
- expanded = expandSkillMarkers(
10250
- `${effectiveCommon}${effectiveBrief}`,
10251
- (name) => o.skillOverrides?.[name] ?? localSkills.get(name)
10252
- );
10420
+ store = (o.getStore ?? getSessionStore)();
10253
10421
  } catch (e) {
10254
- if (e instanceof SkillIncludeError) throw new AgentError(`${e.message} \u2014 refusing to run an unscoped session`);
10255
- throw e;
10422
+ return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
10256
10423
  }
10257
- const prompt = assemblePrompt({
10258
- roster: o.roster,
10259
- environment: o.environment,
10260
- common: "",
10261
- brief: expanded.text,
10262
- digest
10263
- });
10264
- const promptSha = createHash6("sha256").update(expanded.text).digest("hex");
10265
- const streamsDir = join24(o.stateDir, "streams");
10266
- const base = `${routeLabel}-${o.role}-${o.cycle}${o.runId ? `-${o.runId}` : ""}`;
10267
- const seat = resolveSeat(o.ship, o.route, o.role, o.agentPersona ?? (o.agentModel ? { model: o.agentModel } : {}), o.repo);
10268
- const { model } = seat;
10269
- let toolchain = seat.toolchain;
10270
- if (toolchain.auth === "workspace") {
10271
- if (!o.apiKey || o.seatKind === "ship") {
10272
- throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, which needs the seat's own agent key \u2014 this seat would fall back to the ship key (run \`crew agents sync\`)`);
10424
+ const host = hostFromUrl2(route2.baseUrl);
10425
+ const base = route2.baseUrl.replace(/\/+$/, "");
10426
+ for (const { role, agentId } of targets) {
10427
+ if (!agentId) {
10428
+ out.push({ role, action: "no_agent" });
10429
+ continue;
10273
10430
  }
10274
- if (!o.route.resolved) {
10275
- throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, but route ${o.route.route} is not connected (run \`crew connect\`)`);
10431
+ const slug2 = slugFor(route2, agentId);
10432
+ try {
10433
+ const session = await store.get(host, slug2);
10434
+ if (!session?.apiKey) {
10435
+ out.push({ role, action: "not_stored" });
10436
+ continue;
10437
+ }
10438
+ if (!session.keyId) {
10439
+ out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
10440
+ continue;
10441
+ }
10442
+ if (o.dryRun) {
10443
+ out.push({ role, action: "revoked", detail: "dry run" });
10444
+ continue;
10445
+ }
10446
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
10447
+ method: "DELETE",
10448
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10449
+ });
10450
+ if (!del2.ok && del2.status !== 404) {
10451
+ out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
10452
+ continue;
10453
+ }
10454
+ await store.clear(host, slug2);
10455
+ out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
10456
+ } catch (e) {
10457
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
10276
10458
  }
10277
- toolchain = { ...toolchain, inference: { baseUrl: inferenceBaseUrl(o.route.baseUrl, o.route.resolved.workspaceId), token: o.apiKey } };
10278
10459
  }
10279
- const adapter = adapterFor(toolchain.harness);
10280
- const invocation = adapter.invocation(toolchain, {
10281
- role: o.role,
10282
- model,
10283
- // Skipped when there's no key to send: a route `crew connect` hasn't
10284
- // resolved yet has nothing to authenticate the MCP connection with either.
10285
- mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
10286
- });
10287
- const columns = resolveContract(o.route.contract).columns;
10288
- const agentLog = o.route.resolved && o.apiKey ? {
10289
- baseUrl: o.route.baseUrl,
10290
- workspaceId: o.route.resolved.workspaceId,
10291
- apiKey: o.apiKey,
10292
- userAgent: o.ship.userAgent,
10293
- agentId: o.resolvedAgentId,
10294
- ...o.route.resolved.models?.issues ? { tickets: {
10295
- issuesModelId: o.route.resolved.models.issues,
10296
- keyColumn: columns.key,
10297
- tagColumn: columns.tag,
10298
- counters: {
10299
- agentRuns: columns.agentRuns,
10300
- tokensIn: columns.tokensIn,
10301
- tokensOut: columns.tokensOut,
10302
- cacheReadTokens: columns.cacheReadTokens,
10303
- cacheWriteTokens: columns.cacheWriteTokens,
10304
- costUsd: columns.costUsd
10305
- }
10306
- } } : {}
10307
- } : void 0;
10308
- return {
10309
- role: o.role,
10310
- route: routeLabel,
10311
- // The route's directory, which is a STARTING POINT and not the
10312
- // ticket's repo: the session picks its own working directory even when
10313
- // the runner already selected a ticket (`o.repo` is known then, and
10314
- // only feeds the per-repo toolchain choice). Where an area spans
10315
- // several repos this is whichever one `repos` happened to list first
10316
- // (config.ts), and the digest's `repo` column is what actually places
10317
- // the work. Kept deliberately rather than guessed at (ISSUE-349).
10318
- cwd: o.route.dir,
10319
- bin: invocation.bin,
10320
- args: invocation.args,
10321
- toolchain: toolchain.name,
10322
- harness: toolchain.harness,
10323
- workspaceAuth: toolchain.auth === "workspace",
10324
- provider: toolchainProvider(toolchain),
10325
- maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
10326
- ...o.ship.runner === "sandbox" ? { runner: "sandbox", ...o.ship.sandbox ? { sandboxConfig: o.ship.sandbox } : {} } : {},
10327
- resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
10328
- warnings: [...seat.warnings ?? [], ...expanded.warnings],
10329
- prompt,
10330
- promptBytes: Buffer.byteLength(prompt, "utf8"),
10331
- unsetEnv: adapter.unsetEnv,
10332
- setEnv: {
10333
- ...o.apiKey ? { [API_KEY_VAR]: o.apiKey } : {},
10334
- ...adapter.runEnv(toolchain),
10335
- // The operator's global SessionStart hook (pair-context-hook.sh) injects
10336
- // the interactive Pair persona's prompt for any session it can't tell
10337
- // apart from a person's own terminal. Every headless lane run this
10338
- // planner produces carries a real ticket brief already (`prompt`
10339
- // above) — it must never be replaced by the Pair persona with no
10340
- // ticket attached, which silently no-ops and strands the ticket
10341
- // in_progress (ISSUE-490, ISSUE-591). Setting this is what lets that
10342
- // hook tell the two apart; it has nothing to do with the run itself.
10343
- [CREW_LANE_ROLE_VAR]: o.role,
10344
- // CREW-2783: the ship's forge credential — git helper config plus the
10345
- // token as an env var for `gh` and friends. Empty without `forges:`.
10346
- ...forgeRunEnv(o.ship.forges, { crewCommand: crewCommandLine(), configFile: process.env.CREW_CONFIG, stateDir: o.stateDir })
10347
- },
10348
- digestAttached: digest !== void 0,
10349
- digestAgeSeconds: ageSeconds,
10350
- model,
10351
- streamPath: join24(streamsDir, `${base}.jsonl`),
10352
- eventsPath: join24(streamsDir, `${base}.events.jsonl`),
10353
- ticket: o.ticket,
10354
- ticketRecordId: o.ticketRecordId ?? null,
10355
- repo: o.repo ?? null,
10356
- routeName: o.route.route,
10357
- agentLog,
10358
- promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
10359
- promptSha
10360
- };
10460
+ return out;
10361
10461
  }
10362
- function describePlan(p) {
10363
- return [
10364
- `role: ${p.role}`,
10365
- ...p.resolution ? [`resolved: toolchain ${p.toolchain} \u2190 ${p.resolution.toolchain}; model ${p.model} \u2190 ${p.resolution.model}${p.provider ? `; provider ${p.provider}` : ""}`] : [],
10366
- ...(p.warnings ?? []).map((w) => `warning: ${w}`),
10367
- // Only for a non-default toolchain, so a plain Claude run's output is unchanged.
10368
- ...p.toolchain && p.toolchain !== "claude" ? [`toolchain: ${p.toolchain} (${p.harness ?? "claude"})`] : [],
10369
- `cwd: ${p.cwd}`,
10370
- `command: ${p.bin} ${p.args.join(" ")}`,
10371
- `prompt: ${p.promptBytes} bytes on stdin (never argv)`,
10372
- `digest: ${p.digestAttached ? `attached, ${p.digestAgeSeconds}s old` : "absent \u2014 the agent will fetch the tracker itself"}`,
10373
- `unset: ${p.unsetEnv.join(" ")}`,
10374
- // The VALUE is deliberately absent: --dry-run output is pasted into
10375
- // tickets and chat.
10376
- `set: ${Object.keys(p.setEnv).join(" ") || "(nothing)"}`,
10377
- `stream: ${p.streamPath}`
10378
- ].join("\n");
10462
+ function describeRevokeOutcome(o) {
10463
+ switch (o.action) {
10464
+ case "revoked":
10465
+ return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
10466
+ case "not_stored":
10467
+ return `${o.role}: no agent key stored on this ship`;
10468
+ case "no_agent":
10469
+ return `${o.role}: no linked Agents row`;
10470
+ case "failed":
10471
+ return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
10472
+ }
10379
10473
  }
10380
- var pidPathFor = (streamPath) => `${streamPath}.pid`;
10381
- function readRunSidecar(streamPath) {
10474
+ async function canMintAgentKeys(route2, o = {}) {
10475
+ const resolved = route2.resolved;
10476
+ if (!resolved) return void 0;
10477
+ const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
10478
+ if (!agentId) return void 0;
10382
10479
  try {
10383
- const sc = JSON.parse(readFileSync20(pidPathFor(streamPath), "utf8"));
10384
- return typeof sc.pid === "number" && typeof sc.spawnedAt === "string" && typeof sc.role === "string" ? sc : null;
10480
+ const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
10481
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10482
+ });
10483
+ if (res.ok) return true;
10484
+ return res.status === 403 ? false : void 0;
10385
10485
  } catch {
10386
- return null;
10486
+ return void 0;
10387
10487
  }
10388
10488
  }
10389
- function runIsAlive(sc) {
10390
- try {
10391
- process.kill(sc.pid, 0);
10392
- } catch (e) {
10393
- if (e.code !== "EPERM") return false;
10394
- }
10395
- const ps = spawnSync5("ps", ["-o", "lstart=", "-p", String(sc.pid)], { encoding: "utf8" });
10396
- const started = Date.parse(ps.stdout.trim());
10397
- if (ps.status !== 0 || Number.isNaN(started)) return ps.status === 0;
10398
- return Math.abs(started - Date.parse(sc.spawnedAt)) < 15e3;
10489
+
10490
+ // src/deprovision-host.ts
10491
+ var DOCKER_LABEL_OWNER = "crew.owner=crew";
10492
+ var DOCKER_LABEL_CACHE = "crew.kind=cache";
10493
+ var DOCKER_LABEL_REPO_LAYER = "crew.layer=repo";
10494
+ function docker(args) {
10495
+ const res = spawnSync5("docker", args, { encoding: "utf8" });
10496
+ if (res.error || res.status !== 0) throw new Error(`docker ${args[0]} ${args[1] ?? ""} failed: ${(res.stderr || String(res.error ?? "")).trim().split("\n").pop()}`);
10497
+ return res.stdout;
10399
10498
  }
10400
- function identityOf(plan, adapterHarness) {
10499
+ var lines = (s) => s.split("\n").map((l) => l.trim()).filter(Boolean);
10500
+ function labeledDocker() {
10501
+ if (!dockerAvailable()) return { volumes: [], images: [] };
10401
10502
  return {
10402
- role: plan.role,
10403
- route: plan.route,
10404
- ticket: plan.ticket ?? null,
10405
- model: plan.model,
10406
- toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
10407
- harness: adapterHarness,
10408
- provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER,
10409
- eventsPath: plan.eventsPath,
10410
- streamPath: plan.streamPath,
10411
- agentLog: plan.agentLog ? withoutKey(plan.agentLog) : void 0,
10412
- promptVersion: plan.promptVersion,
10413
- promptSha: plan.promptSha
10503
+ volumes: lines(docker(["volume", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_CACHE}`])),
10504
+ images: lines(docker(["image", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_REPO_LAYER}`]))
10414
10505
  };
10415
10506
  }
10416
- async function killForDeadline(kill, pid, emit, role, ticket, spawnedAt, limitMinutes) {
10417
- const elapsedMs = Date.now() - Date.parse(spawnedAt);
10418
- const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
10419
- emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
10420
- role,
10421
- ...ticket ? { ticket } : {},
10422
- data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
10423
- });
10424
- await kill();
10425
- return { limitMinutes: limit, elapsedMs };
10426
- }
10427
- var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
10428
- function openSink(path) {
10429
- try {
10430
- mkdirSync13(dirname8(path), { recursive: true });
10431
- return createWriteStream(path, { flags: "a" });
10432
- } catch {
10433
- return void 0;
10434
- }
10435
- }
10436
- function endSink(sink) {
10437
- if (!sink) return Promise.resolve();
10438
- sink.end();
10439
- return finished(sink).catch(() => void 0);
10507
+ function realDeprovisionDeps(cfg, emit, crewHome, getStore) {
10508
+ const stateDir = cfg.ship.stateDir;
10509
+ const forgeHosts = () => Object.keys(cfg.ship.forges ?? {});
10510
+ const routeFor = (label) => cfg.routes.find((r) => routeSlug(r.route) === label);
10511
+ const sandboxes = () => dockerAvailable() ? listPassengerContainers().map((c) => c.name) : [];
10512
+ const unitPaths = () => {
10513
+ const host = hostPlatform();
10514
+ if (host === "windows") return [];
10515
+ const ctx = installContext();
10516
+ const systemd = detectSystemd();
10517
+ return ["run", "release", "passengers"].flatMap((job) => planInstall(cfg.ship, crewHome, host, systemd, job, ctx).unitPaths).filter((p) => existsSync23(p));
10518
+ };
10519
+ return {
10520
+ liveRuns: () => liveRuns(stateDir).map((r) => ({ role: r.role, route: r.route, pid: r.pid, ticketRecordId: r.ticketRecordId ?? void 0, streamPath: r.streamPath })),
10521
+ killRun: (run3) => killRunGroup(run3.pid),
10522
+ recordKill(run3) {
10523
+ emit.emit(`run_killed: ${run3.role} run on ${run3.route} stopped by crew deprovision`, {
10524
+ step: "agent",
10525
+ role: run3.role,
10526
+ data: { event: "run_killed", reason: "deprovision", pid: run3.pid }
10527
+ });
10528
+ },
10529
+ async handBack(run3) {
10530
+ const route2 = routeFor(run3.route);
10531
+ if (!run3.ticketRecordId || !route2) return;
10532
+ const memberId = route2.resolved?.seats[run3.role];
10533
+ if (!memberId) return;
10534
+ const identity = await resolveSeatKey(route2, run3.role, getStore);
10535
+ const tracker = new Tracker(route2, cfg.ship, { apiKey: identity.apiKey });
10536
+ const t = await tracker.ticket(run3.ticketRecordId);
10537
+ const target = repoTargetFor(route2, t.repo_id);
10538
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
10539
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
10540
+ let dirty = null;
10541
+ try {
10542
+ if (wt) dirty = status(wt.path).length > 0;
10543
+ } catch {
10544
+ }
10545
+ await tracker.postEvent(run3.ticketRecordId, interruptedComment({
10546
+ branch: wt?.branch ?? null,
10547
+ dirty,
10548
+ reason: "the host was released and wiped"
10549
+ }), memberId, "interrupted");
10550
+ },
10551
+ sandboxes,
10552
+ removeSandbox: (name) => {
10553
+ stopContainer(name);
10554
+ },
10555
+ async clearShipRows() {
10556
+ const out = [];
10557
+ for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
10558
+ try {
10559
+ const found = await new Tracker(r, cfg.ship).clearForRelease(cfg.ship.name);
10560
+ out.push({ route: r.route, ok: true, detail: found ? "cleared engaged, tunnel_status, relay_url" : "no Ships row for this ship" });
10561
+ } catch (e) {
10562
+ out.push({ route: r.route, ok: false, detail: e.message });
10563
+ }
10564
+ }
10565
+ return out;
10566
+ },
10567
+ forgeHosts,
10568
+ eraseForgeToken: (host) => eraseForgeToken(cfg.ship.forges, host, stateDir),
10569
+ labeled: labeledDocker,
10570
+ removeVolume: (name) => {
10571
+ docker(["volume", "rm", "-f", name]);
10572
+ },
10573
+ removeImage: (name) => {
10574
+ docker(["image", "rm", "-f", name]);
10575
+ },
10576
+ remaining() {
10577
+ const left = [];
10578
+ if (existsSync23(cfg.configFile)) left.push(`config ${cfg.configFile}`);
10579
+ for (const h of forgeHosts()) if (hasForgeToken(cfg.ship.forges, h, stateDir)) left.push(`forge token for ${h}`);
10580
+ try {
10581
+ for (const k of readdirSync11(keysDir(stateDir))) left.push(`seat key ${k}`);
10582
+ } catch {
10583
+ }
10584
+ for (const s of sandboxes()) left.push(`sandbox ${s}`);
10585
+ try {
10586
+ const { volumes, images } = labeledDocker();
10587
+ for (const v of volumes) left.push(`volume ${v}`);
10588
+ for (const i of images) left.push(`image ${i}`);
10589
+ } catch (e) {
10590
+ left.push(`docker could not be checked (${e.message})`);
10591
+ }
10592
+ for (const u of unitPaths()) left.push(`unit ${u}`);
10593
+ if (loadedCrewLabels().length) left.push(`loaded units ${loadedCrewLabels().join(", ")}`);
10594
+ return left;
10595
+ }
10596
+ };
10440
10597
  }
10441
- var FileTail = class {
10442
- offset = 0;
10443
- decoder = new StringDecoder("utf8");
10444
- path;
10445
- constructor(path) {
10446
- this.path = path;
10447
- }
10448
- poll() {
10449
- let fd;
10598
+
10599
+ // src/sandbox-cache.ts
10600
+ var execFileP = promisify(execFile);
10601
+ var realExec2 = (bin, args, o) => execFileP(bin, args, { env: o?.env ?? process.env, maxBuffer: 16 * 1024 * 1024 });
10602
+ var OPENSHELL_ATTACHABLE_LABEL = "openshell.ai/sandbox-attachable=true";
10603
+ var OPENSHELL_WORKSPACE_LABEL = "openshell.ai/sandbox-attachable-workspace";
10604
+ var VOLUME_ROOT = "/cache";
10605
+ var DEFAULT_CACHE_SETTINGS = {
10606
+ engine: "docker",
10607
+ workspace: "crew",
10608
+ helperImage: "alpine/git:latest",
10609
+ depsLockfile: "pnpm-lock.yaml"
10610
+ };
10611
+ function parseRemote(url) {
10612
+ const u = url.trim();
10613
+ const m = /^[a-z+]+:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(u) ?? /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+?)(?:\.git)?\/?$/.exec(u);
10614
+ if (!m) return null;
10615
+ return { host: normalizeForgeHost(m[1]) || DEFAULT_FORGE_HOST, path: m[2].replace(/^\/+/, "") };
10616
+ }
10617
+ function mirrorRelPath(r) {
10618
+ return `mirrors/${r.host}/${r.path}.git`;
10619
+ }
10620
+ function depsRelDir(r) {
10621
+ return `cache/deps/${r.host}/${r.path}`;
10622
+ }
10623
+ var q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
10624
+ function cloneScript(o) {
10625
+ const mirror = `${o.mirrorDir}/${mirrorRelPath(o.repo)}`;
10626
+ const depsRoot = `${o.mirrorDir}/${depsRelDir(o.repo)}`;
10627
+ return [
10628
+ `git clone --quiet --shared ${q(mirror)} ${q(o.workDir)}`,
10629
+ `cd ${q(o.workDir)}`,
10630
+ `git remote set-url origin ${q(o.origin)}`,
10631
+ `git checkout --quiet -b ${q(o.branch)}`,
10632
+ // A missing lockfile or tree is not an error: the run installs as it would without a cache.
10633
+ `{ if [ -f ${q(o.lockfile)} ]; then h=$(sha256sum ${q(o.lockfile)} | cut -c1-16); d=${q(depsRoot)}/$h; [ -d "$d" ] && cp -R -P "$d"/. . ; fi; true; }`
10634
+ ].join(" && ");
10635
+ }
10636
+ var REFRESH_SCRIPT = `
10637
+ set -e
10638
+ m=${VOLUME_ROOT}/"$1"; url="$2"
10639
+ export GIT_TERMINAL_PROMPT=0
10640
+ git config --global --add safe.directory '*'
10641
+ if [ -n "$CREW_FORGE_TOKEN" ]; then
10642
+ H="Authorization: Basic $(printf '%s:%s' "$CREW_FORGE_USER" "$CREW_FORGE_TOKEN" | base64 | tr -d '\\n')"
10643
+ git() { command git -c "http.extraHeader=$H" "$@"; }
10644
+ fi
10645
+ if [ -d "$m" ]; then
10646
+ git -C "$m" fetch --prune --atomic --quiet origin
10647
+ else
10648
+ mkdir -p "$(dirname "$m")"; rm -rf "$m.tmp"
10649
+ git clone --mirror --quiet "$url" "$m.tmp"
10650
+ git -C "$m.tmp" config gc.auto 0
10651
+ mv "$m.tmp" "$m"
10652
+ fi
10653
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$m/crew-last-refresh"
10654
+ `;
10655
+ var DEPS_SCRIPT = `
10656
+ set -e
10657
+ m=${VOLUME_ROOT}/"$1"; droot=${VOLUME_ROOT}/"$2"; lock="$3"
10658
+ export GIT_TERMINAL_PROMPT=0
10659
+ git config --global --add safe.directory '*'
10660
+ git -C "$m" cat-file -e HEAD:"$lock" 2>/dev/null || exit 0
10661
+ h=$(git -C "$m" show HEAD:"$lock" | sha256sum | cut -c1-16)
10662
+ [ -d "$droot/$h" ] && exit 0
10663
+ w=$(mktemp -d); trap 'rm -rf "$w"' EXIT
10664
+ git clone --quiet --shared "$m" "$w/src"
10665
+ cd "$w/src"
10666
+ sh -c "$CREW_DEPS_COMMAND"
10667
+ mkdir -p "$droot/$h.tmp"
10668
+ find . -name node_modules -type d -prune | while read -r p; do
10669
+ mkdir -p "$droot/$h.tmp/$(dirname "$p")"; cp -R -P "$p" "$droot/$h.tmp/$p"
10670
+ done
10671
+ rm -rf "$droot/$h"; mv "$droot/$h.tmp" "$droot/$h"
10672
+ `;
10673
+ var SandboxCache = class {
10674
+ s;
10675
+ exec;
10676
+ constructor(s, exec = realExec2) {
10677
+ this.s = s;
10678
+ this.exec = exec;
10679
+ }
10680
+ engine(args, env) {
10681
+ return this.exec(this.s.engine, args, { env });
10682
+ }
10683
+ /** Creates the volume with the labels OpenShell and `crew deprovision` look for, if it is not there. */
10684
+ async ensureVolume() {
10450
10685
  try {
10451
- fd = openSync3(this.path, "r");
10686
+ await this.engine(["volume", "inspect", this.s.volume]);
10687
+ return false;
10452
10688
  } catch {
10453
- return "";
10454
- }
10455
- try {
10456
- let text2 = "";
10457
- const chunk = Buffer.alloc(64 * 1024);
10458
- for (; ; ) {
10459
- const n = readSync(fd, chunk, 0, chunk.length, this.offset);
10460
- if (n <= 0) break;
10461
- this.offset += n;
10462
- text2 += this.decoder.write(chunk.subarray(0, n));
10463
- }
10464
- return text2;
10465
- } finally {
10466
- closeSync3(fd);
10467
10689
  }
10690
+ await this.engine([
10691
+ "volume",
10692
+ "create",
10693
+ "--label",
10694
+ OPENSHELL_ATTACHABLE_LABEL,
10695
+ "--label",
10696
+ `${OPENSHELL_WORKSPACE_LABEL}=${this.s.workspace}`,
10697
+ "--label",
10698
+ DOCKER_LABEL_OWNER,
10699
+ "--label",
10700
+ DOCKER_LABEL_CACHE,
10701
+ this.s.volume
10702
+ ]);
10703
+ return true;
10704
+ }
10705
+ helper(image, script, args, env = {}) {
10706
+ const flags = Object.keys(env).flatMap((k) => ["-e", k]);
10707
+ return this.engine(["run", "--rm", "--label", DOCKER_LABEL_OWNER, "-v", `${this.s.volume}:${VOLUME_ROOT}`, ...flags, "--entrypoint", "sh", image, "-c", script, "sh", ...args], { ...process.env, ...env });
10468
10708
  }
10469
- };
10470
- var TAIL_POLL_MS = 200;
10471
- var RunObserver = class {
10472
- cycles = [];
10473
- attribution = new TicketAttributionTracker();
10474
- /** Hand-off writes and their outcomes, to tell a run whose hand-off never landed (CREW-2363). */
10475
- handoff = new HandoffTranscriptTracker();
10476
- result;
10477
- /** Prefix tokens of the first assistant call that carried usage; kept once. */
10478
- prefixTokens;
10479
- /** Set once the stream carries the harness's final result (turn count or cost). */
10480
- sawResult = false;
10481
- muted = false;
10482
10709
  /**
10483
- * The label stamped on events. Starts as the poll hint and switches to the
10484
- * ticket the run actually claims (CREW-1776); `id.ticket` stays the hint.
10710
+ * Refreshes every mirror (and, where configured, its dependency tree). One
10711
+ * repository failing does not stop the others; the caller raises attention
10712
+ * for the failures and the runs keep using the previous mirror state.
10485
10713
  */
10486
- liveTicket;
10487
- /** Called after `liveTicket` changes, so the sidecar can be rewritten. */
10488
- onClaim;
10489
- buf = "";
10490
- mapper;
10491
- id;
10492
- adapter;
10493
- eventsSink;
10494
- emitCycle;
10495
- constructor(id, adapter, eventsSink, emitCycle) {
10496
- this.id = id;
10497
- this.adapter = adapter;
10498
- this.eventsSink = eventsSink;
10499
- this.emitCycle = emitCycle;
10500
- this.liveTicket = id.ticket ?? void 0;
10501
- this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
10502
- }
10503
- feed(text2) {
10504
- this.buf += text2;
10505
- const lines2 = this.buf.split("\n");
10506
- this.buf = lines2.pop() ?? "";
10507
- for (const line of lines2) this.handleLine(line);
10714
+ async refresh(repos) {
10715
+ await this.ensureVolume();
10716
+ const out = [];
10717
+ for (const r of repos) {
10718
+ const env = r.token ? { CREW_FORGE_TOKEN: r.token, CREW_FORGE_USER: r.username ?? DEFAULT_FORGE_USERNAME } : {};
10719
+ try {
10720
+ await this.helper(this.s.helperImage, REFRESH_SCRIPT, [mirrorRelPath(r), forgeDescriptorFor(r.host).httpsUrl(r.host, r.path)], env);
10721
+ } catch (e) {
10722
+ out.push({ repo: r, ok: false, detail: describe(e) });
10723
+ continue;
10724
+ }
10725
+ if (this.s.depsCommand && this.s.depsImage) {
10726
+ try {
10727
+ await this.helper(this.s.depsImage, DEPS_SCRIPT, [mirrorRelPath(r), depsRelDir(r), this.s.depsLockfile], { CREW_DEPS_COMMAND: this.s.depsCommand });
10728
+ } catch (e) {
10729
+ out.push({ repo: r, ok: false, detail: `dependency tree: ${describe(e)}` });
10730
+ continue;
10731
+ }
10732
+ }
10733
+ out.push({ repo: r, ok: true });
10734
+ }
10735
+ return out;
10508
10736
  }
10509
- end() {
10510
- if (this.buf) this.handleLine(this.buf);
10511
- this.buf = "";
10737
+ /** Mirrors in the volume with their last refresh; empty when the volume does not exist. */
10738
+ async list() {
10512
10739
  try {
10513
- this.consume(void 0, this.mapper.flush());
10740
+ await this.engine(["volume", "inspect", this.s.volume]);
10514
10741
  } catch {
10742
+ return [];
10743
+ }
10744
+ const script = `cd ${VOLUME_ROOT}/mirrors 2>/dev/null || exit 0; find . -name '*.git' -type d -prune | while read -r d; do printf '%s\\t%s\\n' "\${d#./}" "$(cat "$d/crew-last-refresh" 2>/dev/null)"; done`;
10745
+ const { stdout } = await this.helper(this.s.helperImage, script, []);
10746
+ const rows = [];
10747
+ for (const line of stdout.split("\n").filter(Boolean)) {
10748
+ const [rel, when] = line.split(" ");
10749
+ const m = /^([^/]+)\/(.+)\.git$/.exec(rel ?? "");
10750
+ if (m) rows.push({ repo: { host: m[1], path: m[2] }, lastRefresh: when?.trim() || null });
10515
10751
  }
10752
+ return rows;
10516
10753
  }
10517
- handleLine(line) {
10518
- if (!line) return;
10519
- let parsed;
10520
- try {
10521
- parsed = JSON.parse(line);
10522
- } catch {
10523
- return;
10754
+ /** Drops mirrors (and their dependency trees) for repositories not in `keep`. Returns what was dropped. */
10755
+ async prune(keep) {
10756
+ const have = new Set(keep.map((r) => `${r.host}/${r.path}`));
10757
+ const drop = (await this.list()).map((m) => m.repo).filter((r) => !have.has(`${r.host}/${r.path}`));
10758
+ for (const r of drop) {
10759
+ await this.helper(this.s.helperImage, `rm -rf ${VOLUME_ROOT}/${q(mirrorRelPath(r))} ${VOLUME_ROOT}/${q(depsRelDir(r))}`, []);
10524
10760
  }
10761
+ return drop;
10762
+ }
10763
+ /** Recreates the volume empty; the next pass refreshes every mirror. */
10764
+ async reset() {
10525
10765
  try {
10526
- const got = this.adapter.extractResult(parsed);
10527
- if (got) {
10528
- this.result = { ...this.result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
10529
- if (got.numTurns !== void 0 || got.costUsd !== void 0) this.sawResult = true;
10530
- }
10531
- this.handoff.feed(parsed);
10532
- this.consume(parsed, this.mapper.map(parsed));
10533
- } catch {
10766
+ await this.engine(["volume", "rm", "--force", this.s.volume]);
10767
+ } catch (e) {
10768
+ throw new Error(`could not remove volume ${this.s.volume}: ${describe(e)}`);
10534
10769
  }
10770
+ await this.ensureVolume();
10535
10771
  }
10536
- // Turns mapped events (and, when there is a source line, its token usage)
10537
- // into sink writes and buffered cycles. `parsed` is undefined for events
10538
- // the mapper flushes at end of stream, which have no line of their own.
10539
- consume(parsed, events) {
10540
- const plan = this.id;
10541
- let turnText = "";
10542
- for (const ev of events) if ((ev.kind === "thought" || ev.kind === "text") && ev.text) this.attribution.note(ev.text);
10543
- for (const ev of events) {
10544
- if (ev.kind === "tool") this.attribution.noteWrite(ev.tool, ev.input);
10545
- if (ev.kind === "tool" && isClaimWrite(ev.tool, ev.input)) {
10546
- const claimed = this.attribution.current;
10547
- if (claimed && claimed !== this.liveTicket) {
10548
- if (!this.muted) {
10549
- this.eventsSink?.write(`${JSON.stringify({
10550
- at: (/* @__PURE__ */ new Date()).toISOString(),
10551
- cycle: this.emitCycle,
10552
- route: plan.route,
10553
- step: "agent",
10554
- level: "info",
10555
- message: `ticket: ${this.liveTicket ?? "none"} \u2192 ${claimed}`,
10556
- role: plan.role,
10557
- ticket: claimed,
10558
- data: { kind: "ticket", from: this.liveTicket ?? null, to: claimed }
10559
- })}
10560
- `);
10561
- }
10562
- this.liveTicket = claimed;
10563
- if (!this.muted) this.onClaim?.();
10564
- }
10565
- }
10566
- const e = {
10567
- at: (/* @__PURE__ */ new Date()).toISOString(),
10568
- cycle: this.emitCycle,
10569
- route: plan.route,
10570
- step: "agent",
10571
- level: "info",
10572
- message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
10573
- role: plan.role,
10574
- ...this.liveTicket ? { ticket: this.liveTicket } : {},
10575
- data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
10576
- };
10577
- if (!this.muted) this.eventsSink?.write(`${JSON.stringify(e)}
10578
- `);
10579
- if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
10580
- turnText += `${ev.text}
10581
- `;
10582
- const cycleIndex = this.cycles.length;
10583
- this.cycles.push({
10584
- cycleIndex,
10585
- occurredAt: e.at,
10586
- thinking: ev.text,
10587
- // A conversational `text` turn is tagged 'assistant' so it's
10588
- // distinguishable from a thinking block once both share this
10589
- // buffer (ISSUE-594) — matching the precedent
10590
- // `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
10591
- // for the same column. A `thought` cycle stays untagged, same
10592
- // as before this change.
10593
- ...ev.kind === "text" ? { role: "assistant" } : {}
10594
- });
10595
- if (!this.muted) {
10596
- this.eventsSink?.write(`${JSON.stringify({
10597
- ...e,
10598
- message: "cycle",
10599
- data: { kind: "cycle", cycleIndex, text: ev.text }
10600
- })}
10601
- `);
10602
- }
10772
+ };
10773
+ function describe(e) {
10774
+ const err3 = e;
10775
+ return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10776
+ }
10777
+ function cacheSettingsOf(cfg) {
10778
+ if (!cfg?.cacheVolume) return null;
10779
+ return {
10780
+ engine: cfg.engine ?? DEFAULT_CACHE_SETTINGS.engine,
10781
+ volume: cfg.cacheVolume,
10782
+ workspace: cfg.cacheWorkspace ?? DEFAULT_CACHE_SETTINGS.workspace,
10783
+ helperImage: cfg.cacheImage ?? DEFAULT_CACHE_SETTINGS.helperImage,
10784
+ depsImage: cfg.depsImage ?? cfg.image,
10785
+ depsCommand: cfg.depsCommand,
10786
+ depsLockfile: cfg.depsLockfile ?? DEFAULT_CACHE_SETTINGS.depsLockfile
10787
+ };
10788
+ }
10789
+ var sandboxCacheKey = (volume, r) => `sandbox_cache:${volume}:${r.host}/${r.path}`;
10790
+ var CACHE_REFRESH_INTERVAL_MS = 6e4;
10791
+ function makeCachePass(o) {
10792
+ const now = o.now ?? Date.now;
10793
+ let last = -Infinity;
10794
+ return async () => {
10795
+ if (now() - last < CACHE_REFRESH_INTERVAL_MS) return [];
10796
+ last = now();
10797
+ const all = [];
10798
+ for (const r of o.repos()) {
10799
+ let release;
10800
+ try {
10801
+ release = o.lock(r.dir);
10802
+ all.push(...await o.cache.refresh([r]));
10803
+ } catch (e) {
10804
+ all.push({ repo: r, ok: false, detail: describe(e) });
10805
+ } finally {
10806
+ release?.();
10603
10807
  }
10604
10808
  }
10605
- if (this.prefixTokens === void 0 && parsed !== void 0) this.prefixTokens = this.adapter.extractPrefixTokens?.(parsed);
10606
- const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
10607
- if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
10608
- }
10809
+ for (const out of all) {
10810
+ const key = sandboxCacheKey(o.volume, out.repo);
10811
+ if (out.ok) o.ok(key);
10812
+ else await o.fail(key, `sandbox cache: ${out.repo.host}/${out.repo.path} did not refresh (${out.detail}); runs use the previous mirror. Resumes automatically when a later pass refreshes it`);
10813
+ }
10814
+ return all;
10815
+ };
10816
+ }
10817
+
10818
+ // src/runner.ts
10819
+ var execFileP2 = promisify2(execFile2);
10820
+ var DEFAULT_SANDBOX_CONFIG = {
10821
+ bin: "openshell",
10822
+ mirrorDir: "/cache",
10823
+ workDir: "/sandbox/work"
10609
10824
  };
10610
- function writeRunMeta(id, startedAt, liveTicket, extra) {
10611
- try {
10612
- writeFileSync15(`${id.streamPath}.meta.json`, JSON.stringify({
10613
- route: id.route,
10614
- role: id.role,
10615
- hint: id.ticket,
10616
- ticket: liveTicket ?? id.ticket,
10617
- startedAt,
10618
- model: id.model,
10619
- toolchain: id.toolchain,
10620
- harness: id.harness,
10621
- provider: id.provider,
10622
- exitCode: null,
10623
- running: true,
10624
- ...extra
10625
- }));
10626
- } catch {
10825
+ var SANDBOX_STDIN_LIMIT = 4 * 1024 * 1024 - 64 * 1024;
10826
+ var SANDBOX_PROMPT_PATH = "/sandbox/prompt.txt";
10827
+ var HostRunner = class {
10828
+ kind = "host";
10829
+ start(plan, o) {
10830
+ const child = spawn3(plan.bin, plan.args, {
10831
+ cwd: plan.cwd,
10832
+ env: o.env,
10833
+ stdio: ["pipe", o.streamFd, "inherit"],
10834
+ detached: true
10835
+ });
10836
+ return { child, handle: { runner: "host", pid: child.pid } };
10627
10837
  }
10628
- }
10629
- async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved) {
10630
- let moved = false;
10631
- if (hintMoved) {
10632
- try {
10633
- moved = await hintMoved();
10634
- } catch {
10635
- }
10838
+ async kill(handle) {
10839
+ if (handle.pid !== void 0) await killRunGroup(handle.pid);
10636
10840
  }
10637
- const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket, { hintMoved: moved });
10638
- const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
10639
- const { result } = obs;
10640
- const pending = end.interrupted ? null : obs.handoff.pending();
10641
- if (pending) {
10841
+ isAlive(handle) {
10842
+ if (handle.pid === void 0) return false;
10642
10843
  try {
10643
- const now = /* @__PURE__ */ new Date();
10644
- writePendingHandoff(stateDirOfStream(id.streamPath), {
10645
- ...pending,
10646
- version: 1,
10647
- route: id.route,
10648
- role: id.role,
10649
- ...attributedTicket ? { ticket: attributedTicket } : {},
10650
- streamPath: id.streamPath,
10651
- createdAt: now.toISOString(),
10652
- attempts: 0,
10653
- nextAttemptAt: now.toISOString()
10654
- });
10844
+ process.kill(handle.pid, 0);
10845
+ return true;
10655
10846
  } catch (e) {
10656
- emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
10847
+ return e.code === "EPERM";
10657
10848
  }
10658
10849
  }
10659
- try {
10660
- writeRunMeta(id, startedAt, obs.liveTicket, {
10661
- attributedTicket: attributedTicket ?? null,
10662
- attributedBy,
10663
- running: void 0,
10664
- exitCode: end.code,
10665
- ...end.interrupted ? { interrupted: end.interrupted } : {},
10666
- ...pending ? { handoff: "pending" } : {},
10667
- sessionId: result?.sessionId ?? null,
10668
- numTurns: result?.numTurns ?? null,
10669
- prefixTokens: obs.prefixTokens ?? null,
10670
- totalCostUsd: result?.costUsd ?? null
10671
- });
10672
- } catch {
10850
+ reattach(meta) {
10851
+ return { runner: "host", pid: meta.pid };
10673
10852
  }
10674
- const ms3 = Date.now() - Date.parse(startedAt);
10675
- emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (${pending ? "hand-off pending" : `exit ${end.code ?? "unknown"}`})`, {
10676
- data: {
10677
- ms: ms3,
10678
- code: end.code,
10679
- ...pending ? { handoff: "pending" } : {},
10680
- ...end.interrupted ? { interrupted: end.interrupted } : {},
10681
- ...identity,
10682
- attributedTicket: attributedTicket ?? null,
10683
- attributedBy,
10684
- ...result?.sessionId ? { sessionId: result.sessionId } : {},
10685
- ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
10686
- ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
10853
+ async release() {
10854
+ }
10855
+ };
10856
+ function shQuote2(s) {
10857
+ return `'${s.replace(/'/g, `'\\''`)}'`;
10858
+ }
10859
+ function sandboxName(plan, now = Date.now()) {
10860
+ const base = [plan.route, plan.role, plan.ticket ?? ""].join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10861
+ return `crew-${base.slice(0, 36)}-${now.toString(36)}`.replace(/-+/g, "-");
10862
+ }
10863
+ var SandboxRunner = class {
10864
+ kind = "sandbox";
10865
+ cfg;
10866
+ /** Injected for tests; the real one reads the plan's checkout. */
10867
+ originOf;
10868
+ constructor(cfg = {}, originOf = gitOrigin) {
10869
+ this.cfg = { ...DEFAULT_SANDBOX_CONFIG, ...cfg };
10870
+ this.originOf = originOf;
10871
+ }
10872
+ os(args, o = {}) {
10873
+ return execFileP2(this.cfg.bin, args, { env: o.env ?? process.env, maxBuffer: 16 * 1024 * 1024 });
10874
+ }
10875
+ async start(plan, o) {
10876
+ const origin = this.originOf(plan.cwd);
10877
+ if (!origin) throw new Error(`cannot find the origin remote of ${plan.cwd}, so there is nothing to clone into the sandbox`);
10878
+ const repo = parseRemote(origin);
10879
+ if (!repo) throw new Error(`cannot read a forge host and path from the origin remote ${origin}`);
10880
+ const name = sandboxName(plan);
10881
+ const { cfg } = this;
10882
+ const secretKeys = Object.keys(plan.setEnv);
10883
+ if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0 && !secretKeys.includes(cfg.forgeTokenVar)) secretKeys.push(cfg.forgeTokenVar);
10884
+ const createEnv = { ...process.env, ...plan.setEnv };
10885
+ if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0) createEnv[cfg.forgeTokenVar] = o.env[cfg.forgeTokenVar];
10886
+ const create2 = ["sandbox", "create", "--name", name, "--detach"];
10887
+ if (cfg.policy) create2.push("--policy", cfg.policy);
10888
+ if (cfg.image) create2.push("--from", cfg.image);
10889
+ if (cfg.cacheVolume) {
10890
+ create2.push("--driver-config-json", JSON.stringify({
10891
+ docker: { mounts: [{ type: "volume", source: cfg.cacheVolume, target: cfg.mirrorDir, read_only: true }] }
10892
+ }));
10687
10893
  }
10688
- });
10689
- if (id.agentLog && agentLogKey) {
10690
- const report = {
10691
- ticketReference: attributedTicket,
10692
- outcome: end.interrupted ? "interrupted" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
10693
- startedAt,
10694
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
10695
- cycles: obs.cycles,
10696
- model: id.model,
10697
- tokensIn: result?.inputTokens,
10698
- tokensOut: result?.outputTokens,
10699
- cacheReadTokens: result?.cacheReadTokens,
10700
- cacheWriteTokens: result?.cacheWriteTokens,
10701
- costUsd: result?.costUsd,
10702
- // Both come from a finished run: without the result line (interrupted) neither is trustworthy.
10703
- ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
10704
- ...result?.numTurns !== void 0 && obs.prefixTokens !== void 0 ? { prefixTokens: obs.prefixTokens } : {},
10705
- harness: identity.harness,
10706
- provider: identity.provider,
10707
- promptVersion: id.promptVersion,
10708
- promptSha: id.promptSha
10709
- };
10894
+ for (const k of secretKeys) create2.push("--env-from", k);
10895
+ const maxSeconds = Math.max(60, Math.ceil((plan.maxRunMinutes ?? 0) * 60) + 600);
10896
+ create2.push("--", "sleep", String(plan.maxRunMinutes ? maxSeconds : 86400));
10897
+ const t0 = Date.now();
10710
10898
  try {
10711
- const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
10712
- try {
10713
- clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
10714
- } catch {
10715
- }
10716
- if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
10717
- if (sent.counterRetry) {
10718
- try {
10719
- queueTicketCounters(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
10720
- version: 1,
10721
- route: id.route,
10722
- role: id.role,
10723
- target: id.agentLog,
10724
- write: sent.counterRetry,
10725
- attempts: 0,
10726
- nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
10727
- });
10728
- } catch {
10729
- }
10730
- }
10899
+ await this.os(create2, { env: createEnv });
10731
10900
  } catch (e) {
10732
- if (e instanceof AgentLogUnreachableError) {
10733
- try {
10734
- queueAgentRun(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
10735
- version: 1,
10736
- route: id.route,
10737
- role: id.role,
10738
- target: id.agentLog,
10739
- report,
10740
- attempts: 0,
10741
- nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
10742
- });
10743
- emit.warn(`could not report agent log (${e.message}); queued to retry`, { step: "agent" });
10744
- return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
10745
- } catch {
10746
- }
10747
- }
10748
- if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
10901
+ await this.deleteQuietly(name);
10902
+ throw new Error(`openshell could not create the sandbox: ${describeExecError(e)}`);
10903
+ }
10904
+ o.emit.emit(`sandbox_created: ${name}`, { role: plan.role, data: { event: "sandbox_created", sandbox: name, ms: Date.now() - t0 } });
10905
+ const handle = { runner: "sandbox", sandbox: name, sandboxBin: cfg.bin };
10906
+ try {
10907
+ const branch = `crew-run-${name.slice(-8)}`;
10908
+ const clone = cloneScript({
10909
+ mirrorDir: cfg.mirrorDir,
10910
+ repo,
10911
+ workDir: cfg.workDir,
10912
+ origin,
10913
+ branch,
10914
+ lockfile: cfg.depsLockfile ?? DEFAULT_CACHE_SETTINGS.depsLockfile
10915
+ });
10916
+ await this.os(["sandbox", "exec", "-n", name, "--no-login-shell", "--", "sh", "-c", clone]);
10917
+ const harness = cfg.agentBin ?? basename7(plan.bin);
10918
+ const viaUpload = Buffer.byteLength(plan.prompt) > SANDBOX_STDIN_LIMIT;
10919
+ if (viaUpload) {
10920
+ const tmp = mkdtempSync(join25(tmpdir3(), "crew-prompt-"));
10749
10921
  try {
10750
- await raiseShipAttention(
10751
- { state: new State(stateDirOfStream(id.streamPath)), emit },
10752
- agentLogRefusedItem(id.route, id.role, e.status)
10753
- );
10754
- } catch {
10922
+ const file = join25(tmp, "prompt.txt");
10923
+ writeFileSync14(file, plan.prompt, { mode: 384 });
10924
+ await this.os(["sandbox", "upload", name, file, SANDBOX_PROMPT_PATH]);
10925
+ } finally {
10926
+ rmSync13(tmp, { recursive: true, force: true });
10755
10927
  }
10756
10928
  }
10757
- emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
10758
- }
10759
- }
10760
- return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
10761
- }
10762
- function spawnAgent(plan, emit, hintMoved) {
10763
- const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
10764
- const runner = runnerFor(plan.runner, plan.sandboxConfig);
10765
- const started = Date.now();
10766
- const startedAt = new Date(started).toISOString();
10767
- for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
10768
- const eventsSink = openSink(plan.eventsPath);
10769
- const id = identityOf(plan, adapter.harness);
10770
- let streamFd;
10771
- try {
10772
- mkdirSync13(dirname8(plan.streamPath), { recursive: true });
10773
- streamFd = openSync3(plan.streamPath, "a");
10774
- } catch (e) {
10775
- void endSink(eventsSink);
10776
- return Promise.reject(new AgentSpawnError(`cannot open the stream file ${plan.streamPath}: ${e.message}`));
10929
+ const command2 = viaUpload ? ["sh", "-c", `exec ${[harness, ...plan.args].map(shQuote2).join(" ")} < ${SANDBOX_PROMPT_PATH}`] : [harness, ...plan.args];
10930
+ const child = spawn3(cfg.bin, ["sandbox", "exec", "-n", name, "--no-login-shell", "--workdir", cfg.workDir, "--", ...command2], {
10931
+ env: process.env,
10932
+ stdio: ["pipe", o.streamFd, "inherit"],
10933
+ detached: true
10934
+ });
10935
+ return { child, handle: { ...handle, pid: child.pid }, ...viaUpload ? { promptDelivered: true } : {} };
10936
+ } catch (e) {
10937
+ await this.release(handle, o.emit);
10938
+ throw new Error(`could not prepare the sandbox ${name}: ${describeExecError(e)}`);
10939
+ }
10777
10940
  }
10778
- const failed = (e) => {
10779
- void endSink(eventsSink);
10780
- return Promise.reject(new AgentSpawnError(`cannot run ${plan.bin}: ${e.message}`));
10781
- };
10782
- let begun;
10783
- try {
10784
- begun = runner.start(plan, { env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv }, streamFd, emit });
10785
- } catch (e) {
10786
- closeSync3(streamFd);
10787
- return failed(e);
10941
+ async kill(handle) {
10942
+ if (handle.sandbox) await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10943
+ if (handle.pid !== void 0) await killRunGroup(handle.pid, { graceMs: 2e3 });
10788
10944
  }
10789
- if (begun instanceof Promise) {
10790
- return begun.then(
10791
- (run3) => {
10792
- closeSync3(streamFd);
10793
- return supervise(run3);
10794
- },
10795
- (e) => {
10796
- closeSync3(streamFd);
10797
- return failed(e);
10798
- }
10799
- );
10945
+ isAlive(handle) {
10946
+ return new HostRunner().isAlive(handle);
10800
10947
  }
10801
- closeSync3(streamFd);
10802
- return supervise(begun);
10803
- function supervise({ child, handle, promptDelivered }) {
10804
- return new Promise((resolve7, reject) => {
10805
- const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
10806
- const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
10807
- if (child.pid !== void 0) {
10808
- const sidecar = {
10809
- pid: child.pid,
10810
- ownerPid: process.pid,
10811
- spawnedAt,
10812
- role: plan.role,
10813
- route: plan.route,
10814
- ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
10815
- ...handle.runner === "sandbox" ? { runner: "sandbox", sandbox: handle.sandbox, sandboxBin: handle.sandboxBin } : {},
10816
- ticket: plan.ticket ?? null,
10817
- ticketRecordId: plan.ticketRecordId ?? null,
10818
- repo: plan.repo ?? null,
10819
- routeName: plan.routeName,
10820
- model: plan.model,
10821
- toolchain: id.toolchain,
10822
- harness: id.harness,
10823
- provider: id.provider,
10824
- eventsPath: plan.eventsPath,
10825
- agentLog: id.agentLog,
10826
- promptVersion: plan.promptVersion,
10827
- promptSha: plan.promptSha
10828
- };
10829
- try {
10830
- writeFileSync15(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
10831
- } catch {
10832
- }
10833
- }
10834
- const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
10835
- const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
10836
- obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
10837
- if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
10838
- const tail3 = new FileTail(plan.streamPath);
10839
- const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
10840
- let killed;
10841
- const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
10842
- void killForDeadline(() => runner.kill(handle, WALL_CLOCK_REASON), child.pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
10843
- killed = k;
10844
- });
10845
- }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
10846
- let spawnFailed = false;
10847
- child.on("error", (err3) => {
10848
- spawnFailed = true;
10849
- clearInterval(timer);
10850
- clearTimeout(limitTimer);
10851
- void runner.release(handle, emit);
10852
- reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
10853
- });
10854
- child.on("close", async (code) => {
10855
- clearInterval(timer);
10856
- clearTimeout(limitTimer);
10857
- obs.feed(tail3.poll());
10858
- obs.end();
10859
- await endSink(eventsSink);
10860
- if (spawnFailed) return;
10861
- await runner.release(handle, emit);
10862
- if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
10863
- killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
10864
- }
10865
- const { ms: ms3, handoffPending } = await finishRun(
10866
- id,
10867
- obs,
10868
- emit,
10869
- startedAt,
10870
- { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
10871
- plan.agentLog?.apiKey,
10872
- hintMoved
10873
- );
10874
- resolve7({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
10875
- });
10876
- child.stdin?.on("error", (err3) => {
10877
- emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
10878
- });
10879
- if (promptDelivered) child.stdin?.end();
10880
- else child.stdin?.end(plan.prompt);
10881
- });
10948
+ reattach(meta) {
10949
+ return { runner: "sandbox", pid: meta.pid, sandbox: meta.sandbox, sandboxBin: meta.sandboxBin };
10882
10950
  }
10883
- }
10884
- async function reattachRun(streamPath, sc, emit, o = {}) {
10885
- const adapter = adapterFor(sc.harness);
10886
- const isAlive = o.isAlive ?? runIsAlive;
10887
- const id = { ...sc, streamPath };
10888
- const eventsSink = openSink(sc.eventsPath);
10889
- const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
10890
- const tail3 = new FileTail(streamPath);
10891
- obs.muted = true;
10892
- obs.feed(tail3.poll());
10893
- obs.muted = false;
10894
- const runner = runnerOfSidecar(sc);
10895
- const adopted = isAlive(sc);
10896
- let killed;
10897
- if (adopted) {
10898
- o.onAdopted?.();
10899
- const pollMs = o.pollMs ?? TAIL_POLL_MS;
10900
- while (isAlive(sc)) {
10901
- obs.feed(tail3.poll());
10902
- if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
10903
- killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
10904
- continue;
10905
- }
10906
- await new Promise((r) => setTimeout(r, pollMs));
10951
+ async release(handle, emit) {
10952
+ if (!handle.sandbox) return;
10953
+ const t0 = Date.now();
10954
+ await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10955
+ emit.emit(`sandbox_deleted: ${handle.sandbox}`, { data: { event: "sandbox_deleted", sandbox: handle.sandbox, ms: Date.now() - t0 } });
10956
+ }
10957
+ async deleteQuietly(name, bin = this.cfg.bin) {
10958
+ try {
10959
+ await execFileP2(bin, ["sandbox", "delete", name], { timeout: 6e4 });
10960
+ } catch {
10907
10961
  }
10908
10962
  }
10909
- obs.feed(tail3.poll());
10910
- obs.end();
10911
- await endSink(eventsSink);
10912
- await runner.release(runner.reattach(sc), emit);
10913
- const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
10914
- const { ms: ms3, ticket } = await finishRun(
10915
- id,
10916
- obs,
10917
- emit,
10918
- sc.spawnedAt,
10919
- { code: null, ...interrupted ? { interrupted } : {} },
10920
- o.agentLogKey
10921
- );
10922
- return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
10923
- }
10924
-
10925
- // src/seat-keys.ts
10926
- import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
10927
- var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
10928
- function keyIsAdmin(route2) {
10929
- const role = route2.resolved?.role;
10930
- return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
10963
+ };
10964
+ function describeExecError(e) {
10965
+ const err3 = e;
10966
+ return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10931
10967
  }
10932
- var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
10933
- var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
10934
- var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
10935
- function seatKeySlug(workspaceSlug, agentId) {
10936
- return `${workspaceSlug}#agent:${agentId}`;
10968
+ function gitOrigin(cwd) {
10969
+ try {
10970
+ return execFileSync7("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
10971
+ } catch {
10972
+ return null;
10973
+ }
10937
10974
  }
10938
- function slugFor(route2, agentId) {
10939
- const [workspaceSlug] = splitRoute(route2.route);
10940
- return seatKeySlug(workspaceSlug, agentId);
10975
+ function runnerFor(kind, sandbox) {
10976
+ return kind === "sandbox" ? new SandboxRunner(sandbox) : new HostRunner();
10941
10977
  }
10942
- function seatAgentId(route2, role) {
10943
- return route2.resolved?.agentPersonas?.[role]?.agentId;
10978
+ function runnerOfSidecar(sc) {
10979
+ return sc.runner === "sandbox" ? new SandboxRunner(sc.sandboxBin ? { bin: sc.sandboxBin } : {}) : new HostRunner();
10944
10980
  }
10945
- async function resolveReleaseKey(route2, getStore = getSessionStore) {
10946
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10947
- const agentId = route2.resolved?.releaseAgentId;
10948
- if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
10981
+ function checkSandboxRunner(cfg = {}, run3 = (b, a) => execFileSync7(b, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15e3 })) {
10982
+ const bin = cfg.bin ?? DEFAULT_SANDBOX_CONFIG.bin;
10983
+ let version;
10949
10984
  try {
10950
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10951
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10952
- return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10985
+ version = run3(bin, ["--version"]).trim();
10953
10986
  } catch (e) {
10954
- const reason = describeReadError(e);
10955
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10987
+ return { ok: false, detail: `${bin} is not installed or not on the scheduler PATH (${e.message.split("\n")[0]})` };
10956
10988
  }
10957
- }
10958
- async function resolveSeatKey(route2, role, getStore = getSessionStore) {
10959
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10960
- if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
10961
- const agentId = seatAgentId(route2, role);
10962
- if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
10963
10989
  try {
10964
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10965
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10966
- return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10990
+ run3(bin, ["status"]);
10967
10991
  } catch (e) {
10968
- const reason = describeReadError(e);
10969
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10970
- }
10971
- }
10972
- function safeStderrLine(e) {
10973
- const err3 = e;
10974
- const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
10975
- for (const line of raw.split("\n")) {
10976
- const t = line.trim();
10977
- if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
10978
- return t.slice(0, 120);
10992
+ const err3 = e;
10993
+ return {
10994
+ ok: false,
10995
+ detail: `${version}: the gateway does not answer (${(err3.stderr || err3.message).trim().split("\n").filter(Boolean).slice(0, 2).join(" / ")}). Start it; on Docker Desktop its docker section needs supervisor_network_mode = "bridge", and allow_driver_config = true for the cache volume mount.`
10996
+ };
10979
10997
  }
10980
- return void 0;
10998
+ return { ok: true, detail: version };
10981
10999
  }
10982
- function describeReadError(e) {
10983
- const fsErr = e;
10984
- if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
10985
- const code = e?.code;
10986
- const line = safeStderrLine(e);
10987
- const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
10988
- const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
10989
- return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
11000
+
11001
+ // src/agent.ts
11002
+ var DIGEST_MAX_AGE_SECONDS = 600;
11003
+ var LEGACY_TOOLCHAIN_HARNESS = "claude";
11004
+ var LEGACY_TOOLCHAIN_PROVIDER = "anthropic";
11005
+ function scrubbedEnv(env = process.env, harness = LEGACY_TOOLCHAIN_HARNESS) {
11006
+ const out = { ...env };
11007
+ for (const v of adapterFor(harness).unsetEnv) delete out[v];
11008
+ return out;
10990
11009
  }
10991
- function keychainExitCode(reason) {
10992
- const m = /security exited (\d+)/.exec(reason);
10993
- return m ? Number(m[1]) : void 0;
11010
+ var CREW_LANE_ROLE_VAR = "CREW_LANE_ROLE";
11011
+ function assemblePrompt(p) {
11012
+ const sections = [p.roster, p.environment, `${p.common}${p.brief}`];
11013
+ if (p.digest) sections.push(p.digest);
11014
+ return sections.join("\n\n");
10994
11015
  }
10995
- function describeStoreError(e) {
10996
- const msg = e instanceof Error ? e.message : String(e);
10997
- if (/User interaction is not allowed/i.test(msg)) {
10998
- return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
11016
+ var AgentError = class extends Error {
11017
+ };
11018
+ var AgentSpawnError = class extends AgentError {
11019
+ };
11020
+ var WorkspaceAuthRefused = class extends AgentSpawnError {
11021
+ };
11022
+ var WorkspaceAllowanceExhausted = class extends AgentError {
11023
+ };
11024
+ var ALLOWANCE_REFUSAL_MARKER = "AI_ALLOWANCE_EXCEEDED";
11025
+ function streamShowsAllowanceRefusal(streamPath) {
11026
+ let raw;
11027
+ try {
11028
+ raw = readFileSync21(streamPath, "utf8");
11029
+ } catch {
11030
+ return false;
11031
+ }
11032
+ if (!raw.includes(ALLOWANCE_REFUSAL_MARKER)) return false;
11033
+ for (const line of raw.split("\n")) {
11034
+ if (!line.includes(ALLOWANCE_REFUSAL_MARKER)) continue;
11035
+ let ev;
11036
+ try {
11037
+ ev = JSON.parse(line);
11038
+ } catch {
11039
+ continue;
11040
+ }
11041
+ if (typeof ev !== "object" || ev === null) continue;
11042
+ const e = ev;
11043
+ if (e.type === "result" && e.is_error === true) {
11044
+ if (typeof e.result === "string" && e.result.includes(ALLOWANCE_REFUSAL_MARKER)) return true;
11045
+ } else if (e.type === "assistant" && (e.isApiErrorMessage === true || e.error !== void 0)) {
11046
+ if (JSON.stringify(e.message ?? "").includes(ALLOWANCE_REFUSAL_MARKER)) return true;
11047
+ }
11048
+ }
11049
+ return false;
11050
+ }
11051
+ var digestPathFor = (stateDir, routeLabel, role, runId) => join26(stateDir, `digest-${routeLabel}-${role}${runId ? `-${runId}` : ""}.md`);
11052
+ function planAgentRun(o) {
11053
+ const briefPath = join26(o.route.promptsDir, "personas", `lane-${o.role}.md`);
11054
+ if (!existsSync24(briefPath)) {
11055
+ throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
11056
+ }
11057
+ const commonPath = join26(o.route.promptsDir, "personas", "common.md");
11058
+ const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync24(commonPath);
11059
+ const now = o.now ?? (() => Date.now());
11060
+ const routeLabel = routeSlug(o.route.route);
11061
+ const ownDigest = o.runId ? digestPathFor(o.stateDir, routeLabel, o.role, o.runId) : void 0;
11062
+ const digestPath2 = ownDigest && existsSync24(ownDigest) ? ownDigest : digestPathFor(o.stateDir, routeLabel, o.role);
11063
+ let digest;
11064
+ let ageSeconds;
11065
+ if (existsSync24(digestPath2)) {
11066
+ ageSeconds = Math.floor((now() - statSync7(digestPath2).mtimeMs) / 1e3);
11067
+ if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync21(digestPath2, "utf8");
11068
+ }
11069
+ const common = usesCommon ? readFileSync21(commonPath, "utf8") : "";
11070
+ const brief = readFileSync21(briefPath, "utf8");
11071
+ const effectiveCommon = o.divergedPrompt ? "" : common;
11072
+ const effectiveBrief = appendAdditions(o.divergedPrompt ?? brief, o.personaAdditions);
11073
+ const localSkills = new Map(listLocalSkills(o.route.promptsDir).map((s) => [s.name, s.prompt]));
11074
+ let expanded;
11075
+ try {
11076
+ expanded = expandSkillMarkers(
11077
+ `${effectiveCommon}${effectiveBrief}`,
11078
+ (name) => o.skillOverrides?.[name] ?? localSkills.get(name)
11079
+ );
11080
+ } catch (e) {
11081
+ if (e instanceof SkillIncludeError) throw new AgentError(`${e.message} \u2014 refusing to run an unscoped session`);
11082
+ throw e;
11083
+ }
11084
+ const prompt = assemblePrompt({
11085
+ roster: o.roster,
11086
+ environment: o.environment,
11087
+ common: "",
11088
+ brief: expanded.text,
11089
+ digest
11090
+ });
11091
+ const promptSha = createHash6("sha256").update(expanded.text).digest("hex");
11092
+ const streamsDir = join26(o.stateDir, "streams");
11093
+ const base = `${routeLabel}-${o.role}-${o.cycle}${o.runId ? `-${o.runId}` : ""}`;
11094
+ const seat = resolveSeat(o.ship, o.route, o.role, o.agentPersona ?? (o.agentModel ? { model: o.agentModel } : {}), o.repo);
11095
+ const { model } = seat;
11096
+ let toolchain = seat.toolchain;
11097
+ if (toolchain.auth === "workspace") {
11098
+ if (!o.apiKey || o.seatKind === "ship") {
11099
+ throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, which needs the seat's own agent key \u2014 this seat would fall back to the ship key (run \`crew agents sync\`)`);
11100
+ }
11101
+ if (!o.route.resolved) {
11102
+ throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, but route ${o.route.route} is not connected (run \`crew connect\`)`);
11103
+ }
11104
+ toolchain = { ...toolchain, inference: { baseUrl: inferenceBaseUrl(o.route.baseUrl, o.route.resolved.workspaceId), token: o.apiKey } };
11105
+ }
11106
+ const adapter = adapterFor(toolchain.harness);
11107
+ const invocation = adapter.invocation(toolchain, {
11108
+ role: o.role,
11109
+ model,
11110
+ // Skipped when there's no key to send: a route `crew connect` hasn't
11111
+ // resolved yet has nothing to authenticate the MCP connection with either.
11112
+ mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
11113
+ });
11114
+ const columns = resolveContract(o.route.contract).columns;
11115
+ const agentLog = o.route.resolved && o.apiKey ? {
11116
+ baseUrl: o.route.baseUrl,
11117
+ workspaceId: o.route.resolved.workspaceId,
11118
+ apiKey: o.apiKey,
11119
+ userAgent: o.ship.userAgent,
11120
+ agentId: o.resolvedAgentId,
11121
+ ...o.route.resolved.models?.issues ? { tickets: {
11122
+ issuesModelId: o.route.resolved.models.issues,
11123
+ keyColumn: columns.key,
11124
+ tagColumn: columns.tag,
11125
+ counters: {
11126
+ agentRuns: columns.agentRuns,
11127
+ tokensIn: columns.tokensIn,
11128
+ tokensOut: columns.tokensOut,
11129
+ cacheReadTokens: columns.cacheReadTokens,
11130
+ cacheWriteTokens: columns.cacheWriteTokens,
11131
+ costUsd: columns.costUsd
11132
+ }
11133
+ } } : {}
11134
+ } : void 0;
11135
+ return {
11136
+ role: o.role,
11137
+ route: routeLabel,
11138
+ // The route's directory, which is a STARTING POINT and not the
11139
+ // ticket's repo: the session picks its own working directory even when
11140
+ // the runner already selected a ticket (`o.repo` is known then, and
11141
+ // only feeds the per-repo toolchain choice). Where an area spans
11142
+ // several repos this is whichever one `repos` happened to list first
11143
+ // (config.ts), and the digest's `repo` column is what actually places
11144
+ // the work. Kept deliberately rather than guessed at (ISSUE-349).
11145
+ cwd: o.route.dir,
11146
+ bin: invocation.bin,
11147
+ args: invocation.args,
11148
+ toolchain: toolchain.name,
11149
+ harness: toolchain.harness,
11150
+ workspaceAuth: toolchain.auth === "workspace",
11151
+ provider: toolchainProvider(toolchain),
11152
+ maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
11153
+ ...o.ship.runner === "sandbox" ? { runner: "sandbox", ...o.ship.sandbox ? { sandboxConfig: o.ship.sandbox } : {} } : {},
11154
+ resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
11155
+ warnings: [...seat.warnings ?? [], ...expanded.warnings],
11156
+ prompt,
11157
+ promptBytes: Buffer.byteLength(prompt, "utf8"),
11158
+ unsetEnv: adapter.unsetEnv,
11159
+ setEnv: {
11160
+ ...o.apiKey ? { [API_KEY_VAR]: o.apiKey } : {},
11161
+ ...adapter.runEnv(toolchain),
11162
+ // The operator's global SessionStart hook (pair-context-hook.sh) injects
11163
+ // the interactive Pair persona's prompt for any session it can't tell
11164
+ // apart from a person's own terminal. Every headless lane run this
11165
+ // planner produces carries a real ticket brief already (`prompt`
11166
+ // above) — it must never be replaced by the Pair persona with no
11167
+ // ticket attached, which silently no-ops and strands the ticket
11168
+ // in_progress (ISSUE-490, ISSUE-591). Setting this is what lets that
11169
+ // hook tell the two apart; it has nothing to do with the run itself.
11170
+ [CREW_LANE_ROLE_VAR]: o.role,
11171
+ // CREW-2783: the ship's forge credential — git helper config plus the
11172
+ // token as an env var for `gh` and friends. Empty without `forges:`.
11173
+ ...forgeRunEnv(o.ship.forges, { crewCommand: crewCommandLine(), configFile: process.env.CREW_CONFIG, stateDir: o.stateDir })
11174
+ },
11175
+ digestAttached: digest !== void 0,
11176
+ digestAgeSeconds: ageSeconds,
11177
+ model,
11178
+ streamPath: join26(streamsDir, `${base}.jsonl`),
11179
+ eventsPath: join26(streamsDir, `${base}.events.jsonl`),
11180
+ ticket: o.ticket,
11181
+ ticketRecordId: o.ticketRecordId ?? null,
11182
+ repo: o.repo ?? null,
11183
+ routeName: o.route.route,
11184
+ agentLog,
11185
+ promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
11186
+ promptSha
11187
+ };
11188
+ }
11189
+ function describePlan(p) {
11190
+ return [
11191
+ `role: ${p.role}`,
11192
+ ...p.resolution ? [`resolved: toolchain ${p.toolchain} \u2190 ${p.resolution.toolchain}; model ${p.model} \u2190 ${p.resolution.model}${p.provider ? `; provider ${p.provider}` : ""}`] : [],
11193
+ ...(p.warnings ?? []).map((w) => `warning: ${w}`),
11194
+ // Only for a non-default toolchain, so a plain Claude run's output is unchanged.
11195
+ ...p.toolchain && p.toolchain !== "claude" ? [`toolchain: ${p.toolchain} (${p.harness ?? "claude"})`] : [],
11196
+ `cwd: ${p.cwd}`,
11197
+ `command: ${p.bin} ${p.args.join(" ")}`,
11198
+ `prompt: ${p.promptBytes} bytes on stdin (never argv)`,
11199
+ `digest: ${p.digestAttached ? `attached, ${p.digestAgeSeconds}s old` : "absent \u2014 the agent will fetch the tracker itself"}`,
11200
+ `unset: ${p.unsetEnv.join(" ")}`,
11201
+ // The VALUE is deliberately absent: --dry-run output is pasted into
11202
+ // tickets and chat.
11203
+ `set: ${Object.keys(p.setEnv).join(" ") || "(nothing)"}`,
11204
+ `stream: ${p.streamPath}`
11205
+ ].join("\n");
11206
+ }
11207
+ var pidPathFor = (streamPath) => `${streamPath}.pid`;
11208
+ function readRunSidecar(streamPath) {
11209
+ try {
11210
+ const sc = JSON.parse(readFileSync21(pidPathFor(streamPath), "utf8"));
11211
+ return typeof sc.pid === "number" && typeof sc.spawnedAt === "string" && typeof sc.role === "string" ? sc : null;
11212
+ } catch {
11213
+ return null;
11214
+ }
11215
+ }
11216
+ function runIsAlive(sc) {
11217
+ try {
11218
+ process.kill(sc.pid, 0);
11219
+ } catch (e) {
11220
+ if (e.code !== "EPERM") return false;
11221
+ }
11222
+ const ps = spawnSync6("ps", ["-o", "lstart=", "-p", String(sc.pid)], { encoding: "utf8" });
11223
+ const started = Date.parse(ps.stdout.trim());
11224
+ if (ps.status !== 0 || Number.isNaN(started)) return ps.status === 0;
11225
+ return Math.abs(started - Date.parse(sc.spawnedAt)) < 15e3;
11226
+ }
11227
+ function identityOf(plan, adapterHarness) {
11228
+ return {
11229
+ role: plan.role,
11230
+ route: plan.route,
11231
+ ticket: plan.ticket ?? null,
11232
+ model: plan.model,
11233
+ toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
11234
+ harness: adapterHarness,
11235
+ provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER,
11236
+ eventsPath: plan.eventsPath,
11237
+ streamPath: plan.streamPath,
11238
+ agentLog: plan.agentLog ? withoutKey(plan.agentLog) : void 0,
11239
+ promptVersion: plan.promptVersion,
11240
+ promptSha: plan.promptSha
11241
+ };
11242
+ }
11243
+ async function killForDeadline(kill, pid, emit, role, ticket, spawnedAt, limitMinutes) {
11244
+ const elapsedMs = Date.now() - Date.parse(spawnedAt);
11245
+ const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
11246
+ emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
11247
+ role,
11248
+ ...ticket ? { ticket } : {},
11249
+ data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
11250
+ });
11251
+ await kill();
11252
+ return { limitMinutes: limit, elapsedMs };
11253
+ }
11254
+ var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
11255
+ function openSink(path) {
11256
+ try {
11257
+ mkdirSync13(dirname9(path), { recursive: true });
11258
+ return createWriteStream(path, { flags: "a" });
11259
+ } catch {
11260
+ return void 0;
11261
+ }
11262
+ }
11263
+ function endSink(sink) {
11264
+ if (!sink) return Promise.resolve();
11265
+ sink.end();
11266
+ return finished(sink).catch(() => void 0);
11267
+ }
11268
+ var FileTail = class {
11269
+ offset = 0;
11270
+ decoder = new StringDecoder("utf8");
11271
+ path;
11272
+ constructor(path) {
11273
+ this.path = path;
11274
+ }
11275
+ poll() {
11276
+ let fd;
11277
+ try {
11278
+ fd = openSync3(this.path, "r");
11279
+ } catch {
11280
+ return "";
11281
+ }
11282
+ try {
11283
+ let text2 = "";
11284
+ const chunk = Buffer.alloc(64 * 1024);
11285
+ for (; ; ) {
11286
+ const n = readSync(fd, chunk, 0, chunk.length, this.offset);
11287
+ if (n <= 0) break;
11288
+ this.offset += n;
11289
+ text2 += this.decoder.write(chunk.subarray(0, n));
11290
+ }
11291
+ return text2;
11292
+ } finally {
11293
+ closeSync3(fd);
11294
+ }
11295
+ }
11296
+ };
11297
+ var TAIL_POLL_MS = 200;
11298
+ var RunObserver = class {
11299
+ cycles = [];
11300
+ attribution = new TicketAttributionTracker();
11301
+ /** Hand-off writes and their outcomes, to tell a run whose hand-off never landed (CREW-2363). */
11302
+ handoff = new HandoffTranscriptTracker();
11303
+ result;
11304
+ /** Prefix tokens of the first assistant call that carried usage; kept once. */
11305
+ prefixTokens;
11306
+ /** Set once the stream carries the harness's final result (turn count or cost). */
11307
+ sawResult = false;
11308
+ muted = false;
11309
+ /**
11310
+ * The label stamped on events. Starts as the poll hint and switches to the
11311
+ * ticket the run actually claims (CREW-1776); `id.ticket` stays the hint.
11312
+ */
11313
+ liveTicket;
11314
+ /** Called after `liveTicket` changes, so the sidecar can be rewritten. */
11315
+ onClaim;
11316
+ buf = "";
11317
+ mapper;
11318
+ id;
11319
+ adapter;
11320
+ eventsSink;
11321
+ emitCycle;
11322
+ constructor(id, adapter, eventsSink, emitCycle) {
11323
+ this.id = id;
11324
+ this.adapter = adapter;
11325
+ this.eventsSink = eventsSink;
11326
+ this.emitCycle = emitCycle;
11327
+ this.liveTicket = id.ticket ?? void 0;
11328
+ this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
11329
+ }
11330
+ feed(text2) {
11331
+ this.buf += text2;
11332
+ const lines2 = this.buf.split("\n");
11333
+ this.buf = lines2.pop() ?? "";
11334
+ for (const line of lines2) this.handleLine(line);
11335
+ }
11336
+ end() {
11337
+ if (this.buf) this.handleLine(this.buf);
11338
+ this.buf = "";
11339
+ try {
11340
+ this.consume(void 0, this.mapper.flush());
11341
+ } catch {
11342
+ }
11343
+ }
11344
+ handleLine(line) {
11345
+ if (!line) return;
11346
+ let parsed;
11347
+ try {
11348
+ parsed = JSON.parse(line);
11349
+ } catch {
11350
+ return;
11351
+ }
11352
+ try {
11353
+ const got = this.adapter.extractResult(parsed);
11354
+ if (got) {
11355
+ this.result = { ...this.result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
11356
+ if (got.numTurns !== void 0 || got.costUsd !== void 0) this.sawResult = true;
11357
+ }
11358
+ this.handoff.feed(parsed);
11359
+ this.consume(parsed, this.mapper.map(parsed));
11360
+ } catch {
11361
+ }
10999
11362
  }
11000
- return "keychain unavailable (could not write to the OS keychain)";
11001
- }
11002
- async function mintMissingSeatKeys(route2, o) {
11003
- const out = [];
11004
- if (!route2.resolved) return out;
11005
- const resolved = route2.resolved;
11006
- const doFetch = o.fetchImpl ?? fetch;
11007
- const targets = [
11008
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
11009
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
11010
- ];
11011
- let store;
11363
+ // Turns mapped events (and, when there is a source line, its token usage)
11364
+ // into sink writes and buffered cycles. `parsed` is undefined for events
11365
+ // the mapper flushes at end of stream, which have no line of their own.
11366
+ consume(parsed, events) {
11367
+ const plan = this.id;
11368
+ let turnText = "";
11369
+ for (const ev of events) if ((ev.kind === "thought" || ev.kind === "text") && ev.text) this.attribution.note(ev.text);
11370
+ for (const ev of events) {
11371
+ if (ev.kind === "tool") this.attribution.noteWrite(ev.tool, ev.input);
11372
+ if (ev.kind === "tool" && isClaimWrite(ev.tool, ev.input)) {
11373
+ const claimed = this.attribution.current;
11374
+ if (claimed && claimed !== this.liveTicket) {
11375
+ if (!this.muted) {
11376
+ this.eventsSink?.write(`${JSON.stringify({
11377
+ at: (/* @__PURE__ */ new Date()).toISOString(),
11378
+ cycle: this.emitCycle,
11379
+ route: plan.route,
11380
+ step: "agent",
11381
+ level: "info",
11382
+ message: `ticket: ${this.liveTicket ?? "none"} \u2192 ${claimed}`,
11383
+ role: plan.role,
11384
+ ticket: claimed,
11385
+ data: { kind: "ticket", from: this.liveTicket ?? null, to: claimed }
11386
+ })}
11387
+ `);
11388
+ }
11389
+ this.liveTicket = claimed;
11390
+ if (!this.muted) this.onClaim?.();
11391
+ }
11392
+ }
11393
+ const e = {
11394
+ at: (/* @__PURE__ */ new Date()).toISOString(),
11395
+ cycle: this.emitCycle,
11396
+ route: plan.route,
11397
+ step: "agent",
11398
+ level: "info",
11399
+ message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
11400
+ role: plan.role,
11401
+ ...this.liveTicket ? { ticket: this.liveTicket } : {},
11402
+ data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
11403
+ };
11404
+ if (!this.muted) this.eventsSink?.write(`${JSON.stringify(e)}
11405
+ `);
11406
+ if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
11407
+ turnText += `${ev.text}
11408
+ `;
11409
+ const cycleIndex = this.cycles.length;
11410
+ this.cycles.push({
11411
+ cycleIndex,
11412
+ occurredAt: e.at,
11413
+ thinking: ev.text,
11414
+ // A conversational `text` turn is tagged 'assistant' so it's
11415
+ // distinguishable from a thinking block once both share this
11416
+ // buffer (ISSUE-594) — matching the precedent
11417
+ // `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
11418
+ // for the same column. A `thought` cycle stays untagged, same
11419
+ // as before this change.
11420
+ ...ev.kind === "text" ? { role: "assistant" } : {}
11421
+ });
11422
+ if (!this.muted) {
11423
+ this.eventsSink?.write(`${JSON.stringify({
11424
+ ...e,
11425
+ message: "cycle",
11426
+ data: { kind: "cycle", cycleIndex, text: ev.text }
11427
+ })}
11428
+ `);
11429
+ }
11430
+ }
11431
+ }
11432
+ if (this.prefixTokens === void 0 && parsed !== void 0) this.prefixTokens = this.adapter.extractPrefixTokens?.(parsed);
11433
+ const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
11434
+ if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
11435
+ }
11436
+ };
11437
+ function writeRunMeta(id, startedAt, liveTicket, extra) {
11012
11438
  try {
11013
- store = (o.getStore ?? getSessionStore)();
11014
- } catch (e) {
11015
- return targets.map(({ role }) => ({
11016
- role,
11017
- action: "failed",
11018
- detail: describeStoreError(e)
11439
+ writeFileSync15(`${id.streamPath}.meta.json`, JSON.stringify({
11440
+ route: id.route,
11441
+ role: id.role,
11442
+ hint: id.ticket,
11443
+ ticket: liveTicket ?? id.ticket,
11444
+ startedAt,
11445
+ model: id.model,
11446
+ toolchain: id.toolchain,
11447
+ harness: id.harness,
11448
+ provider: id.provider,
11449
+ exitCode: null,
11450
+ running: true,
11451
+ ...extra
11019
11452
  }));
11453
+ } catch {
11020
11454
  }
11021
- const host = hostFromUrl2(route2.baseUrl);
11022
- const base = route2.baseUrl.replace(/\/+$/, "");
11023
- if (!o.dryRun) {
11024
- const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
11455
+ }
11456
+ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved) {
11457
+ let moved = false;
11458
+ if (hintMoved) {
11025
11459
  try {
11026
- await store.set(host, probeSlug, {
11027
- apiKey: "probe",
11028
- workspaceId: resolved.workspaceId,
11029
- identityId: "_probe",
11030
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
11460
+ moved = await hintMoved();
11461
+ } catch {
11462
+ }
11463
+ }
11464
+ const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket, { hintMoved: moved });
11465
+ const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
11466
+ const { result } = obs;
11467
+ const pending = end.interrupted ? null : obs.handoff.pending();
11468
+ if (pending) {
11469
+ try {
11470
+ const now = /* @__PURE__ */ new Date();
11471
+ writePendingHandoff(stateDirOfStream(id.streamPath), {
11472
+ ...pending,
11473
+ version: 1,
11474
+ route: id.route,
11475
+ role: id.role,
11476
+ ...attributedTicket ? { ticket: attributedTicket } : {},
11477
+ streamPath: id.streamPath,
11478
+ createdAt: now.toISOString(),
11479
+ attempts: 0,
11480
+ nextAttemptAt: now.toISOString()
11031
11481
  });
11032
- await store.clear(host, probeSlug);
11033
11482
  } catch (e) {
11034
- const detail = `${describeStoreError(e)}; no keys minted`;
11035
- return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
11483
+ emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
11036
11484
  }
11037
11485
  }
11038
- for (const { role, agentId } of targets) {
11039
- if (!agentId) {
11040
- out.push({ role, action: "no_agent" });
11041
- continue;
11486
+ try {
11487
+ writeRunMeta(id, startedAt, obs.liveTicket, {
11488
+ attributedTicket: attributedTicket ?? null,
11489
+ attributedBy,
11490
+ running: void 0,
11491
+ exitCode: end.code,
11492
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
11493
+ ...pending ? { handoff: "pending" } : {},
11494
+ sessionId: result?.sessionId ?? null,
11495
+ numTurns: result?.numTurns ?? null,
11496
+ prefixTokens: obs.prefixTokens ?? null,
11497
+ totalCostUsd: result?.costUsd ?? null
11498
+ });
11499
+ } catch {
11500
+ }
11501
+ const ms3 = Date.now() - Date.parse(startedAt);
11502
+ emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (${pending ? "hand-off pending" : `exit ${end.code ?? "unknown"}`})`, {
11503
+ data: {
11504
+ ms: ms3,
11505
+ code: end.code,
11506
+ ...pending ? { handoff: "pending" } : {},
11507
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
11508
+ ...identity,
11509
+ attributedTicket: attributedTicket ?? null,
11510
+ attributedBy,
11511
+ ...result?.sessionId ? { sessionId: result.sessionId } : {},
11512
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
11513
+ ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
11042
11514
  }
11043
- const slug2 = slugFor(route2, agentId);
11515
+ });
11516
+ if (id.agentLog && agentLogKey) {
11517
+ const report = {
11518
+ ticketReference: attributedTicket,
11519
+ outcome: end.interrupted ? "interrupted" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
11520
+ startedAt,
11521
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
11522
+ cycles: obs.cycles,
11523
+ model: id.model,
11524
+ tokensIn: result?.inputTokens,
11525
+ tokensOut: result?.outputTokens,
11526
+ cacheReadTokens: result?.cacheReadTokens,
11527
+ cacheWriteTokens: result?.cacheWriteTokens,
11528
+ costUsd: result?.costUsd,
11529
+ // Both come from a finished run: without the result line (interrupted) neither is trustworthy.
11530
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
11531
+ ...result?.numTurns !== void 0 && obs.prefixTokens !== void 0 ? { prefixTokens: obs.prefixTokens } : {},
11532
+ harness: identity.harness,
11533
+ provider: identity.provider,
11534
+ promptVersion: id.promptVersion,
11535
+ promptSha: id.promptSha
11536
+ };
11044
11537
  try {
11045
- if ((await store.get(host, slug2))?.apiKey) {
11046
- out.push({ role, action: "already_stored" });
11047
- continue;
11048
- }
11049
- if (o.dryRun) {
11050
- out.push({ role, action: "minted", detail: "dry run" });
11051
- continue;
11052
- }
11053
- const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
11054
- method: "POST",
11055
- headers: {
11056
- Authorization: `Bearer ${resolveApiKey(route2)}`,
11057
- "Content-Type": "application/json",
11058
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
11059
- },
11060
- body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
11061
- });
11062
- if (res.status === 403) {
11063
- out.push({ role, action: "forbidden" });
11064
- break;
11065
- }
11066
- if (!res.ok) {
11067
- out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
11068
- continue;
11069
- }
11070
- const body = await res.json();
11071
- if (!body.key) {
11072
- out.push({ role, action: "failed", detail: "response carried no key" });
11073
- continue;
11074
- }
11538
+ const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
11075
11539
  try {
11076
- const session = {
11077
- apiKey: body.key,
11078
- workspaceId: resolved.workspaceId,
11079
- identityId: agentId,
11080
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
11081
- ...body.id ? { keyId: body.id } : {}
11082
- };
11083
- await store.set(host, slug2, session);
11084
- } catch (e) {
11085
- let revoked = false;
11086
- if (body.id) {
11087
- try {
11088
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
11089
- method: "DELETE",
11090
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11091
- });
11092
- revoked = del2.ok;
11093
- } catch {
11094
- revoked = false;
11095
- }
11540
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
11541
+ } catch {
11542
+ }
11543
+ if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
11544
+ if (sent.counterRetry) {
11545
+ try {
11546
+ queueTicketCounters(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
11547
+ version: 1,
11548
+ route: id.route,
11549
+ role: id.role,
11550
+ target: id.agentLog,
11551
+ write: sent.counterRetry,
11552
+ attempts: 0,
11553
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
11554
+ });
11555
+ } catch {
11096
11556
  }
11097
- out.push({
11098
- role,
11099
- action: "failed",
11100
- detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
11101
- });
11102
- continue;
11103
11557
  }
11104
- out.push({ role, action: "minted" });
11105
11558
  } catch (e) {
11106
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11559
+ if (e instanceof AgentLogUnreachableError) {
11560
+ try {
11561
+ queueAgentRun(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
11562
+ version: 1,
11563
+ route: id.route,
11564
+ role: id.role,
11565
+ target: id.agentLog,
11566
+ report,
11567
+ attempts: 0,
11568
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
11569
+ });
11570
+ emit.warn(`could not report agent log (${e.message}); queued to retry`, { step: "agent" });
11571
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
11572
+ } catch {
11573
+ }
11574
+ }
11575
+ if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
11576
+ try {
11577
+ await raiseShipAttention(
11578
+ { state: new State(stateDirOfStream(id.streamPath)), emit },
11579
+ agentLogRefusedItem(id.route, id.role, e.status)
11580
+ );
11581
+ } catch {
11582
+ }
11583
+ }
11584
+ emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
11107
11585
  }
11108
11586
  }
11109
- return out;
11587
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
11110
11588
  }
11111
- function describeMintOutcome(o) {
11112
- switch (o.action) {
11113
- case "minted":
11114
- return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
11115
- case "already_stored":
11116
- return `${o.role}: agent key already stored`;
11117
- case "no_agent":
11118
- return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
11119
- case "forbidden":
11120
- return FORBIDDEN_LINE;
11121
- case "failed":
11122
- return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
11589
+ function spawnAgent(plan, emit, hintMoved) {
11590
+ const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
11591
+ const runner = runnerFor(plan.runner, plan.sandboxConfig);
11592
+ const started = Date.now();
11593
+ const startedAt = new Date(started).toISOString();
11594
+ for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
11595
+ const eventsSink = openSink(plan.eventsPath);
11596
+ const id = identityOf(plan, adapter.harness);
11597
+ let streamFd;
11598
+ try {
11599
+ mkdirSync13(dirname9(plan.streamPath), { recursive: true });
11600
+ streamFd = openSync3(plan.streamPath, "a");
11601
+ } catch (e) {
11602
+ void endSink(eventsSink);
11603
+ return Promise.reject(new AgentSpawnError(`cannot open the stream file ${plan.streamPath}: ${e.message}`));
11123
11604
  }
11124
- }
11125
- async function revokeSeatKeys(route2, o) {
11126
- const out = [];
11127
- if (!route2.resolved) return out;
11128
- const resolved = route2.resolved;
11129
- const doFetch = o.fetchImpl ?? fetch;
11130
- const targets = [
11131
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
11132
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
11133
- ];
11134
- let store;
11605
+ const failed = (e) => {
11606
+ void endSink(eventsSink);
11607
+ return Promise.reject(new AgentSpawnError(`cannot run ${plan.bin}: ${e.message}`));
11608
+ };
11609
+ let begun;
11135
11610
  try {
11136
- store = (o.getStore ?? getSessionStore)();
11611
+ begun = runner.start(plan, { env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv }, streamFd, emit });
11137
11612
  } catch (e) {
11138
- return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
11613
+ closeSync3(streamFd);
11614
+ return failed(e);
11139
11615
  }
11140
- const host = hostFromUrl2(route2.baseUrl);
11141
- const base = route2.baseUrl.replace(/\/+$/, "");
11142
- for (const { role, agentId } of targets) {
11143
- if (!agentId) {
11144
- out.push({ role, action: "no_agent" });
11145
- continue;
11146
- }
11147
- const slug2 = slugFor(route2, agentId);
11148
- try {
11149
- const session = await store.get(host, slug2);
11150
- if (!session?.apiKey) {
11151
- out.push({ role, action: "not_stored" });
11152
- continue;
11153
- }
11154
- if (!session.keyId) {
11155
- out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
11156
- continue;
11616
+ if (begun instanceof Promise) {
11617
+ return begun.then(
11618
+ (run3) => {
11619
+ closeSync3(streamFd);
11620
+ return supervise(run3);
11621
+ },
11622
+ (e) => {
11623
+ closeSync3(streamFd);
11624
+ return failed(e);
11157
11625
  }
11158
- if (o.dryRun) {
11159
- out.push({ role, action: "revoked", detail: "dry run" });
11160
- continue;
11626
+ );
11627
+ }
11628
+ closeSync3(streamFd);
11629
+ return supervise(begun);
11630
+ function supervise({ child, handle, promptDelivered }) {
11631
+ return new Promise((resolve7, reject) => {
11632
+ const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
11633
+ const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
11634
+ if (child.pid !== void 0) {
11635
+ const sidecar = {
11636
+ pid: child.pid,
11637
+ ownerPid: process.pid,
11638
+ spawnedAt,
11639
+ role: plan.role,
11640
+ route: plan.route,
11641
+ ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
11642
+ ...handle.runner === "sandbox" ? { runner: "sandbox", sandbox: handle.sandbox, sandboxBin: handle.sandboxBin } : {},
11643
+ ticket: plan.ticket ?? null,
11644
+ ticketRecordId: plan.ticketRecordId ?? null,
11645
+ repo: plan.repo ?? null,
11646
+ routeName: plan.routeName,
11647
+ model: plan.model,
11648
+ toolchain: id.toolchain,
11649
+ harness: id.harness,
11650
+ provider: id.provider,
11651
+ eventsPath: plan.eventsPath,
11652
+ agentLog: id.agentLog,
11653
+ promptVersion: plan.promptVersion,
11654
+ promptSha: plan.promptSha
11655
+ };
11656
+ try {
11657
+ writeFileSync15(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
11658
+ } catch {
11659
+ }
11161
11660
  }
11162
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
11163
- method: "DELETE",
11164
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11661
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
11662
+ const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
11663
+ obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
11664
+ if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
11665
+ const tail3 = new FileTail(plan.streamPath);
11666
+ const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
11667
+ let killed;
11668
+ const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
11669
+ void killForDeadline(() => runner.kill(handle, WALL_CLOCK_REASON), child.pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
11670
+ killed = k;
11671
+ });
11672
+ }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
11673
+ let spawnFailed = false;
11674
+ child.on("error", (err3) => {
11675
+ spawnFailed = true;
11676
+ clearInterval(timer);
11677
+ clearTimeout(limitTimer);
11678
+ void runner.release(handle, emit);
11679
+ reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
11165
11680
  });
11166
- if (!del2.ok && del2.status !== 404) {
11167
- out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
11168
- continue;
11169
- }
11170
- await store.clear(host, slug2);
11171
- out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
11172
- } catch (e) {
11173
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11174
- }
11175
- }
11176
- return out;
11177
- }
11178
- function describeRevokeOutcome(o) {
11179
- switch (o.action) {
11180
- case "revoked":
11181
- return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
11182
- case "not_stored":
11183
- return `${o.role}: no agent key stored on this ship`;
11184
- case "no_agent":
11185
- return `${o.role}: no linked Agents row`;
11186
- case "failed":
11187
- return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
11681
+ child.on("close", async (code) => {
11682
+ clearInterval(timer);
11683
+ clearTimeout(limitTimer);
11684
+ obs.feed(tail3.poll());
11685
+ obs.end();
11686
+ await endSink(eventsSink);
11687
+ if (spawnFailed) return;
11688
+ await runner.release(handle, emit);
11689
+ if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
11690
+ killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
11691
+ }
11692
+ const { ms: ms3, handoffPending } = await finishRun(
11693
+ id,
11694
+ obs,
11695
+ emit,
11696
+ startedAt,
11697
+ { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
11698
+ plan.agentLog?.apiKey,
11699
+ hintMoved
11700
+ );
11701
+ resolve7({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
11702
+ });
11703
+ child.stdin?.on("error", (err3) => {
11704
+ emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
11705
+ });
11706
+ if (promptDelivered) child.stdin?.end();
11707
+ else child.stdin?.end(plan.prompt);
11708
+ });
11188
11709
  }
11189
11710
  }
11190
- async function canMintAgentKeys(route2, o = {}) {
11191
- const resolved = route2.resolved;
11192
- if (!resolved) return void 0;
11193
- const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
11194
- if (!agentId) return void 0;
11195
- try {
11196
- const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
11197
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11198
- });
11199
- if (res.ok) return true;
11200
- return res.status === 403 ? false : void 0;
11201
- } catch {
11202
- return void 0;
11711
+ async function reattachRun(streamPath, sc, emit, o = {}) {
11712
+ const adapter = adapterFor(sc.harness);
11713
+ const isAlive = o.isAlive ?? runIsAlive;
11714
+ const id = { ...sc, streamPath };
11715
+ const eventsSink = openSink(sc.eventsPath);
11716
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
11717
+ const tail3 = new FileTail(streamPath);
11718
+ obs.muted = true;
11719
+ obs.feed(tail3.poll());
11720
+ obs.muted = false;
11721
+ const runner = runnerOfSidecar(sc);
11722
+ const adopted = isAlive(sc);
11723
+ let killed;
11724
+ if (adopted) {
11725
+ o.onAdopted?.();
11726
+ const pollMs = o.pollMs ?? TAIL_POLL_MS;
11727
+ while (isAlive(sc)) {
11728
+ obs.feed(tail3.poll());
11729
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
11730
+ killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
11731
+ continue;
11732
+ }
11733
+ await new Promise((r) => setTimeout(r, pollMs));
11734
+ }
11203
11735
  }
11736
+ obs.feed(tail3.poll());
11737
+ obs.end();
11738
+ await endSink(eventsSink);
11739
+ await runner.release(runner.reattach(sc), emit);
11740
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
11741
+ const { ms: ms3, ticket } = await finishRun(
11742
+ id,
11743
+ obs,
11744
+ emit,
11745
+ sc.spawnedAt,
11746
+ { code: null, ...interrupted ? { interrupted } : {} },
11747
+ o.agentLogKey
11748
+ );
11749
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
11204
11750
  }
11205
11751
 
11206
11752
  // src/selftest.ts
@@ -11364,7 +11910,7 @@ async function runSelftest(i, deps = realDeps) {
11364
11910
  ...adapter.runEnv(spec),
11365
11911
  [KEY_VAR]: i.apiKey
11366
11912
  };
11367
- const cwd = mkdtempSync2(join25(tmpdir4(), "crew-selftest-"));
11913
+ const cwd = mkdtempSync2(join27(tmpdir4(), "crew-selftest-"));
11368
11914
  try {
11369
11915
  const run3 = await deps.run({
11370
11916
  bin: invocation.bin,
@@ -11397,11 +11943,11 @@ function formatResult(r) {
11397
11943
  const secs = `${(r.ms / 1e3).toFixed(1)}s`;
11398
11944
  return r.ok ? `${r.toolchain}: PASS ${r.detail} (${secs}${tok})` : `${r.toolchain}: FAIL ${r.failure} \u2014 ${r.detail} (${secs})`;
11399
11945
  }
11400
- var fileFor2 = (stateDir) => join25(stateDir, "selftest.json");
11946
+ var fileFor2 = (stateDir) => join27(stateDir, "selftest.json");
11401
11947
  function readSelftest(stateDir) {
11402
11948
  try {
11403
11949
  const p = fileFor2(stateDir);
11404
- return existsSync22(p) ? JSON.parse(readFileSync21(p, "utf8")) : {};
11950
+ return existsSync25(p) ? JSON.parse(readFileSync22(p, "utf8")) : {};
11405
11951
  } catch {
11406
11952
  return {};
11407
11953
  }
@@ -11422,7 +11968,7 @@ function doctorSelftestLines(stored, toolchains) {
11422
11968
  }
11423
11969
 
11424
11970
  // src/cli.ts
11425
- import { resolve as resolve6, dirname as dirname25, join as join49, basename as basename15 } from "path";
11971
+ import { resolve as resolve6, dirname as dirname26, join as join49, basename as basename15 } from "path";
11426
11972
  import { fileURLToPath } from "url";
11427
11973
  import { hostname as hostname4, homedir as homedir11 } from "os";
11428
11974
  import { spawnSync as spawnSync10 } from "child_process";
@@ -11619,14 +12165,14 @@ function rosterMarkdown(roster, meId) {
11619
12165
  }
11620
12166
 
11621
12167
  // src/connect-resume.ts
11622
- import { mkdirSync as mkdirSync15, readFileSync as readFileSync22, rmSync as rmSync15, writeFileSync as writeFileSync17 } from "fs";
11623
- import { dirname as dirname9, join as join26 } from "path";
12168
+ import { mkdirSync as mkdirSync15, readFileSync as readFileSync23, rmSync as rmSync15, writeFileSync as writeFileSync17 } from "fs";
12169
+ import { dirname as dirname10, join as join28 } from "path";
11624
12170
  function lastSessionFile(stateDir) {
11625
- return join26(stateDir, "connect-session.json");
12171
+ return join28(stateDir, "connect-session.json");
11626
12172
  }
11627
12173
  function readPointers(stateDir) {
11628
12174
  try {
11629
- const parsed = JSON.parse(readFileSync22(lastSessionFile(stateDir), "utf8"));
12175
+ const parsed = JSON.parse(readFileSync23(lastSessionFile(stateDir), "utf8"));
11630
12176
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
11631
12177
  const out = {};
11632
12178
  for (const [host, v] of Object.entries(parsed)) {
@@ -11640,7 +12186,7 @@ function readPointers(stateDir) {
11640
12186
  }
11641
12187
  function writePointers(stateDir, pointers) {
11642
12188
  const file = lastSessionFile(stateDir);
11643
- mkdirSync15(dirname9(file), { recursive: true });
12189
+ mkdirSync15(dirname10(file), { recursive: true });
11644
12190
  writeFileSync17(file, `${JSON.stringify(pointers, null, 2)}
11645
12191
  `, { mode: 384 });
11646
12192
  }
@@ -11726,15 +12272,15 @@ async function signOutSessions(o) {
11726
12272
  }
11727
12273
 
11728
12274
  // src/reset.ts
11729
- import { existsSync as existsSync23, mkdirSync as mkdirSync16, readFileSync as readFileSync23, readdirSync as readdirSync9, rmSync as rmSync16, statSync as statSync8, writeFileSync as writeFileSync18 } from "fs";
11730
- import { dirname as dirname10, join as join27 } from "path";
12275
+ import { existsSync as existsSync26, mkdirSync as mkdirSync16, readFileSync as readFileSync24, readdirSync as readdirSync12, rmSync as rmSync16, statSync as statSync8, writeFileSync as writeFileSync18 } from "fs";
12276
+ import { dirname as dirname11, join as join29 } from "path";
11731
12277
  import { hostFromUrl as hostFromUrl3 } from "@tablation/client";
11732
12278
  function clonedCheckoutsFile(stateDir) {
11733
- return join27(stateDir, "cloned-checkouts.json");
12279
+ return join29(stateDir, "cloned-checkouts.json");
11734
12280
  }
11735
12281
  function readClonedCheckouts(stateDir) {
11736
12282
  try {
11737
- const parsed = JSON.parse(readFileSync23(clonedCheckoutsFile(stateDir), "utf8"));
12283
+ const parsed = JSON.parse(readFileSync24(clonedCheckoutsFile(stateDir), "utf8"));
11738
12284
  return Array.isArray(parsed) ? parsed.filter((d) => typeof d === "string") : [];
11739
12285
  } catch {
11740
12286
  return [];
@@ -11744,7 +12290,7 @@ function recordClonedCheckout(stateDir, dir) {
11744
12290
  const known = readClonedCheckouts(stateDir);
11745
12291
  if (known.includes(dir)) return;
11746
12292
  const file = clonedCheckoutsFile(stateDir);
11747
- mkdirSync16(dirname10(file), { recursive: true });
12293
+ mkdirSync16(dirname11(file), { recursive: true });
11748
12294
  writeFileSync18(file, `${JSON.stringify([...known, dir], null, 2)}
11749
12295
  `);
11750
12296
  }
@@ -11797,407 +12343,137 @@ async function runReset(o, fx) {
11797
12343
  if (o.removeCheckouts) {
11798
12344
  for (const dir of readClonedCheckouts(o.stateDir)) steps.push(removeCheckout(dir, o.dryRun, fx));
11799
12345
  }
11800
- if (o.beforeState) steps.push(...await o.beforeState());
11801
- steps.push(...o.wipeState ? wipeStateDir(o.stateDir, o.dryRun) : removeState(o.stateDir, o.dryRun));
11802
- if (existsSync23(o.configFile)) {
11803
- if (!o.dryRun) rmSync16(o.configFile, { force: true });
11804
- steps.push({ step: "config", target: o.configFile, action: mark(o.dryRun) });
11805
- } else {
11806
- steps.push({ step: "config", target: o.configFile, action: "absent" });
11807
- }
11808
- return steps;
11809
- }
11810
- function removeCheckout(dir, dryRun, fx) {
11811
- if (!existsSync23(dir)) return { step: "checkout", target: dir, action: "absent", detail: "already gone" };
11812
- const { dirty, linkedWorktrees } = fx.inspectCheckout(dir);
11813
- if (dirty.length) {
11814
- return { step: "checkout", target: dir, action: "kept", detail: `has uncommitted changes (${dirty.length} path${dirty.length === 1 ? "" : "s"}: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ", \u2026" : ""})` };
11815
- }
11816
- if (linkedWorktrees.length) {
11817
- return { step: "checkout", target: dir, action: "kept", detail: `has linked worktrees (${linkedWorktrees.join(", ")}); remove them first` };
11818
- }
11819
- if (!dryRun) rmSync16(dir, { recursive: true, force: true });
11820
- return { step: "checkout", target: dir, action: mark(dryRun) };
11821
- }
11822
- function removeState(stateDir, dryRun) {
11823
- const out = [];
11824
- const targets = [join27(stateDir, "resolved"), join27(stateDir, "keys"), clonedCheckoutsFile(stateDir), join27(stateDir, "connect-session.json"), join27(stateDir, "reload-requested"), join27(stateDir, "daemon.started")];
11825
- try {
11826
- for (const name of readdirSync9(stateDir)) {
11827
- if (name.startsWith(".") && statSync8(join27(stateDir, name)).isFile()) targets.push(join27(stateDir, name));
11828
- }
11829
- } catch {
11830
- }
11831
- for (const t of targets) {
11832
- if (!existsSync23(t)) continue;
11833
- if (!dryRun) rmSync16(t, { recursive: true, force: true });
11834
- out.push({ step: "state", target: t, action: mark(dryRun) });
11835
- }
11836
- if (!out.length) out.push({ step: "state", target: stateDir, action: "absent", detail: "nothing to remove" });
11837
- return out;
11838
- }
11839
- function wipeStateDir(stateDir, dryRun) {
11840
- let names;
11841
- try {
11842
- names = readdirSync9(stateDir);
11843
- } catch {
11844
- return [{ step: "state", target: stateDir, action: "absent", detail: "no state directory" }];
11845
- }
11846
- if (!names.length) return [{ step: "state", target: stateDir, action: "absent", detail: "already empty" }];
11847
- if (!dryRun) for (const n of names) rmSync16(join27(stateDir, n), { recursive: true, force: true });
11848
- return [{ step: "state", target: stateDir, action: mark(dryRun), detail: `${names.length} entr${names.length === 1 ? "y" : "ies"}` }];
11849
- }
11850
- function describeResetStep(s) {
11851
- const verb = { removed: "removed", "would-remove": "would remove", absent: "nothing to remove", kept: "KEPT", failed: "FAILED" }[s.action];
11852
- return `${s.step}: ${verb}: ${s.target}${s.detail ? ` (${s.detail})` : ""}`;
11853
- }
11854
- function resetIncomplete(steps) {
11855
- return steps.some((s) => s.action === "failed" || s.action === "kept");
11856
- }
11857
-
11858
- // src/deprovision.ts
11859
- var mark2 = (dryRun) => dryRun ? "would-remove" : "removed";
11860
- async function runDeprovision(o, fx, deps) {
11861
- const attempt = (step, target, fn, dry) => {
11862
- try {
11863
- if (!dry) fn();
11864
- return { step, target, action: mark2(dry) };
11865
- } catch (e) {
11866
- return { step, target, action: "failed", detail: e.message };
11867
- }
11868
- };
11869
- const afterScheduler = async () => {
11870
- const steps2 = [];
11871
- for (const run3 of deps.liveRuns()) {
11872
- const target = `${run3.role} run on ${run3.route} (pid ${run3.pid})`;
11873
- if (o.dryRun) {
11874
- steps2.push({ step: "run", target, action: "would-remove" });
11875
- continue;
11876
- }
11877
- try {
11878
- await deps.killRun(run3);
11879
- deps.recordKill(run3);
11880
- } catch (e) {
11881
- steps2.push({ step: "run", target, action: "failed", detail: e.message });
11882
- continue;
11883
- }
11884
- try {
11885
- await deps.handBack(run3);
11886
- steps2.push({ step: "run", target, action: "removed", detail: "killed" });
11887
- } catch (e) {
11888
- steps2.push({ step: "run", target, action: "removed", detail: `killed; could not tell its ticket (${e.message})` });
11889
- }
11890
- }
11891
- for (const name of deps.sandboxes()) steps2.push(attempt("sandbox", name, () => {
11892
- deps.removeSandbox(name);
11893
- }, o.dryRun));
11894
- if (!o.dryRun) {
11895
- for (const r of await deps.clearShipRows()) {
11896
- steps2.push({ step: "ship-row", target: r.route, action: r.ok ? "removed" : "failed", detail: r.detail });
11897
- }
11898
- }
11899
- return steps2;
11900
- };
11901
- const beforeState = async () => {
11902
- const steps2 = [];
11903
- for (const host of deps.forgeHosts()) {
11904
- try {
11905
- const result = o.dryRun ? "erased" : deps.eraseForgeToken(host);
11906
- if (result === "env") steps2.push({ step: "forge", target: host, action: "kept", detail: "the token is read from the environment; crew does not own it" });
11907
- else if (result === "absent") steps2.push({ step: "forge", target: host, action: "absent" });
11908
- else steps2.push({ step: "forge", target: host, action: mark2(o.dryRun) });
11909
- } catch (e) {
11910
- steps2.push({ step: "forge", target: host, action: "failed", detail: e.message });
11911
- }
11912
- }
11913
- const { volumes, images } = deps.labeled();
11914
- for (const v of volumes) steps2.push(attempt("cache", `volume ${v}`, () => {
11915
- deps.removeVolume(v);
11916
- }, o.dryRun));
11917
- for (const i of images) steps2.push(attempt("cache", `image ${i}`, () => {
11918
- deps.removeImage(i);
11919
- }, o.dryRun));
11920
- return steps2;
11921
- };
11922
- const steps = await runReset({ ...o, removeCheckouts: true, wipeState: true, afterScheduler, beforeState }, fx);
11923
- const remaining = o.dryRun ? [] : deps.remaining();
11924
- return { steps, remaining, ok: !resetIncomplete(steps) && remaining.length === 0 };
11925
- }
11926
-
11927
- // src/deprovision-host.ts
11928
- import { spawnSync as spawnSync6 } from "child_process";
11929
- import { existsSync as existsSync26, readdirSync as readdirSync12 } from "fs";
11930
-
11931
- // src/interrupted.ts
11932
- import { existsSync as existsSync24, readdirSync as readdirSync10, readFileSync as readFileSync24 } from "fs";
11933
- import { join as join28 } from "path";
11934
- var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
11935
- function interruptedComment(b) {
11936
- const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
11937
- return `${INTERRUPTED_MARKER}
11938
-
11939
- The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
11940
- }
11941
- var handled = /* @__PURE__ */ new Set();
11942
- var pidAlive = (pid) => {
11943
- try {
11944
- process.kill(pid, 0);
11945
- return true;
11946
- } catch (e) {
11947
- return e.code === "EPERM";
11948
- }
11949
- };
11950
- function runFinished(streamPath) {
11951
- const path = `${streamPath}.meta.json`;
11952
- if (!existsSync24(path)) return false;
11953
- try {
11954
- return JSON.parse(readFileSync24(path, "utf8")).running !== true;
11955
- } catch {
11956
- return true;
11957
- }
11958
- }
11959
- function orphanedStreams(o) {
11960
- const dir = join28(o.stateDir, "streams");
11961
- if (!existsSync24(dir)) return [];
11962
- const ownerAlive = o.isOwnerAlive ?? pidAlive;
11963
- const out = [];
11964
- for (const name of readdirSync10(dir)) {
11965
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
11966
- const streamPath = join28(dir, name);
11967
- if (runFinished(streamPath)) continue;
11968
- const sidecar = readRunSidecar(streamPath);
11969
- if (!sidecar || sidecar.route !== o.routeLabel) continue;
11970
- if (ownerAlive(sidecar.ownerPid)) continue;
11971
- out.push({ streamPath, sidecar });
11972
- }
11973
- return out;
11974
- }
11975
- async function reconcileRuns(o) {
11976
- let adopted = 0;
11977
- let interrupted = 0;
11978
- for (const { streamPath, sidecar } of orphanedStreams(o)) {
11979
- if (handled.has(streamPath)) continue;
11980
- handled.add(streamPath);
11981
- const isAlive = o.isAlive ?? runIsAlive;
11982
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
11983
- const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
11984
- const work = (async () => {
11985
- try {
11986
- const result = await reattachRun(streamPath, sidecar, o.emit, {
11987
- agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
11988
- isAlive: o.isAlive,
11989
- pollMs: o.pollMs,
11990
- onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
11991
- });
11992
- if (result.adopted) adopted++;
11993
- else interrupted++;
11994
- try {
11995
- await o.afterRun?.(sidecar, result);
11996
- } catch (e) {
11997
- o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
11998
- }
11999
- } catch (e) {
12000
- o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12001
- } finally {
12002
- if (lock?.ok) lock.release();
12003
- if (ticketLock?.ok) ticketLock.release();
12004
- }
12005
- })();
12006
- if (isAlive(sidecar)) {
12007
- if (o.inFlight) {
12008
- o.inFlight.add(work);
12009
- void work.finally(() => o.inFlight.delete(work));
12010
- }
12011
- } else {
12012
- await work;
12013
- }
12014
- }
12015
- return { adopted, interrupted };
12016
- }
12017
-
12018
- // src/live-runs.ts
12019
- import { existsSync as existsSync25, readdirSync as readdirSync11 } from "fs";
12020
- import { join as join29 } from "path";
12021
- function liveRuns(stateDir, isAlive = runIsAlive) {
12022
- const dir = join29(stateDir, "streams");
12023
- if (!existsSync25(dir)) return [];
12024
- const out = [];
12025
- for (const name of readdirSync11(dir)) {
12026
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12027
- const streamPath = join29(dir, name);
12028
- if (runFinished(streamPath)) continue;
12029
- const sidecar = readRunSidecar(streamPath);
12030
- if (!sidecar || !isAlive(sidecar)) continue;
12031
- out.push({ ...sidecar, streamPath });
12032
- }
12033
- return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
12034
- }
12035
- function siblingRuns(runs, ownTicketRecordId) {
12036
- return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
12037
- }
12038
- async function settleRunEnd(o) {
12039
- const { tracker, emit } = o;
12040
- const all = o.runs ?? liveRuns(o.stateDir);
12041
- const slug2 = routeSlug(o.route.route);
12042
- const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
12043
- const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
12044
- const newest = (rs) => rs[rs.length - 1];
12045
- if (o.memberId) {
12046
- try {
12047
- if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
12048
- else await tracker.setCrewStatus(o.memberId, "idle");
12049
- } catch (e) {
12050
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
12051
- }
12052
- }
12346
+ if (o.beforeState) steps.push(...await o.beforeState());
12347
+ steps.push(...o.wipeState ? wipeStateDir(o.stateDir, o.dryRun) : removeState(o.stateDir, o.dryRun));
12348
+ if (existsSync26(o.configFile)) {
12349
+ if (!o.dryRun) rmSync16(o.configFile, { force: true });
12350
+ steps.push({ step: "config", target: o.configFile, action: mark(o.dryRun) });
12351
+ } else {
12352
+ steps.push({ step: "config", target: o.configFile, action: "absent" });
12353
+ }
12354
+ return steps;
12355
+ }
12356
+ function removeCheckout(dir, dryRun, fx) {
12357
+ if (!existsSync26(dir)) return { step: "checkout", target: dir, action: "absent", detail: "already gone" };
12358
+ const { dirty, linkedWorktrees } = fx.inspectCheckout(dir);
12359
+ if (dirty.length) {
12360
+ return { step: "checkout", target: dir, action: "kept", detail: `has uncommitted changes (${dirty.length} path${dirty.length === 1 ? "" : "s"}: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ", \u2026" : ""})` };
12361
+ }
12362
+ if (linkedWorktrees.length) {
12363
+ return { step: "checkout", target: dir, action: "kept", detail: `has linked worktrees (${linkedWorktrees.join(", ")}); remove them first` };
12364
+ }
12365
+ if (!dryRun) rmSync16(dir, { recursive: true, force: true });
12366
+ return { step: "checkout", target: dir, action: mark(dryRun) };
12367
+ }
12368
+ function removeState(stateDir, dryRun) {
12369
+ const out = [];
12370
+ const targets = [join29(stateDir, "resolved"), join29(stateDir, "keys"), clonedCheckoutsFile(stateDir), join29(stateDir, "connect-session.json"), join29(stateDir, "reload-requested"), join29(stateDir, "daemon.started")];
12053
12371
  try {
12054
- if (shipSiblings.length) {
12055
- const n = newest(shipSiblings);
12056
- await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
12057
- } else {
12058
- await tracker.beatIdle(o.shipName);
12372
+ for (const name of readdirSync12(stateDir)) {
12373
+ if (name.startsWith(".") && statSync8(join29(stateDir, name)).isFile()) targets.push(join29(stateDir, name));
12059
12374
  }
12060
- } catch (e) {
12061
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12375
+ } catch {
12376
+ }
12377
+ for (const t of targets) {
12378
+ if (!existsSync26(t)) continue;
12379
+ if (!dryRun) rmSync16(t, { recursive: true, force: true });
12380
+ out.push({ step: "state", target: t, action: mark(dryRun) });
12062
12381
  }
12382
+ if (!out.length) out.push({ step: "state", target: stateDir, action: "absent", detail: "nothing to remove" });
12383
+ return out;
12063
12384
  }
12064
- function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12385
+ function wipeStateDir(stateDir, dryRun) {
12386
+ let names;
12065
12387
  try {
12066
- return worktreeForNumber(d, n)?.path ?? null;
12388
+ names = readdirSync12(stateDir);
12067
12389
  } catch {
12068
- return null;
12390
+ return [{ step: "state", target: stateDir, action: "absent", detail: "no state directory" }];
12069
12391
  }
12070
- }) {
12071
- const ownRoute = run3.route === routeSlug(route2.route);
12072
- const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
12073
- const num = run3.ticket?.replace(/^\D+/, "") ?? "";
12074
- return {
12075
- role: run3.role,
12076
- seat: ownRoute ? seatIds[run3.role] ?? null : null,
12077
- ticket: run3.ticket,
12078
- ticketRecordId: run3.ticketRecordId,
12079
- repo: run3.repo ?? null,
12080
- worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
12081
- stream: run3.streamPath,
12082
- pid: run3.pid,
12083
- startedAt: run3.spawnedAt,
12084
- deadline: run3.deadline ?? null
12085
- };
12392
+ if (!names.length) return [{ step: "state", target: stateDir, action: "absent", detail: "already empty" }];
12393
+ if (!dryRun) for (const n of names) rmSync16(join29(stateDir, n), { recursive: true, force: true });
12394
+ return [{ step: "state", target: stateDir, action: mark(dryRun), detail: `${names.length} entr${names.length === 1 ? "y" : "ies"}` }];
12086
12395
  }
12087
-
12088
- // src/deprovision-host.ts
12089
- var DOCKER_LABEL_OWNER = "crew.owner=crew";
12090
- var DOCKER_LABEL_CACHE = "crew.kind=cache";
12091
- var DOCKER_LABEL_REPO_LAYER = "crew.layer=repo";
12092
- function docker(args) {
12093
- const res = spawnSync6("docker", args, { encoding: "utf8" });
12094
- if (res.error || res.status !== 0) throw new Error(`docker ${args[0]} ${args[1] ?? ""} failed: ${(res.stderr || String(res.error ?? "")).trim().split("\n").pop()}`);
12095
- return res.stdout;
12396
+ function describeResetStep(s) {
12397
+ const verb = { removed: "removed", "would-remove": "would remove", absent: "nothing to remove", kept: "KEPT", failed: "FAILED" }[s.action];
12398
+ return `${s.step}: ${verb}: ${s.target}${s.detail ? ` (${s.detail})` : ""}`;
12096
12399
  }
12097
- var lines = (s) => s.split("\n").map((l) => l.trim()).filter(Boolean);
12098
- function labeledDocker() {
12099
- if (!dockerAvailable()) return { volumes: [], images: [] };
12100
- return {
12101
- volumes: lines(docker(["volume", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_CACHE}`])),
12102
- images: lines(docker(["image", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_REPO_LAYER}`]))
12103
- };
12400
+ function resetIncomplete(steps) {
12401
+ return steps.some((s) => s.action === "failed" || s.action === "kept");
12104
12402
  }
12105
- function realDeprovisionDeps(cfg, emit, crewHome, getStore) {
12106
- const stateDir = cfg.ship.stateDir;
12107
- const forgeHosts = () => Object.keys(cfg.ship.forges ?? {});
12108
- const routeFor = (label) => cfg.routes.find((r) => routeSlug(r.route) === label);
12109
- const sandboxes = () => dockerAvailable() ? listPassengerContainers().map((c) => c.name) : [];
12110
- const unitPaths = () => {
12111
- const host = hostPlatform();
12112
- if (host === "windows") return [];
12113
- const ctx = installContext();
12114
- const systemd = detectSystemd();
12115
- return ["run", "release", "passengers"].flatMap((job) => planInstall(cfg.ship, crewHome, host, systemd, job, ctx).unitPaths).filter((p) => existsSync26(p));
12403
+
12404
+ // src/deprovision.ts
12405
+ var mark2 = (dryRun) => dryRun ? "would-remove" : "removed";
12406
+ async function runDeprovision(o, fx, deps) {
12407
+ const attempt = (step, target, fn, dry) => {
12408
+ try {
12409
+ if (!dry) fn();
12410
+ return { step, target, action: mark2(dry) };
12411
+ } catch (e) {
12412
+ return { step, target, action: "failed", detail: e.message };
12413
+ }
12116
12414
  };
12117
- return {
12118
- liveRuns: () => liveRuns(stateDir).map((r) => ({ role: r.role, route: r.route, pid: r.pid, ticketRecordId: r.ticketRecordId ?? void 0, streamPath: r.streamPath })),
12119
- killRun: (run3) => killRunGroup(run3.pid),
12120
- recordKill(run3) {
12121
- emit.emit(`run_killed: ${run3.role} run on ${run3.route} stopped by crew deprovision`, {
12122
- step: "agent",
12123
- role: run3.role,
12124
- data: { event: "run_killed", reason: "deprovision", pid: run3.pid }
12125
- });
12126
- },
12127
- async handBack(run3) {
12128
- const route2 = routeFor(run3.route);
12129
- if (!run3.ticketRecordId || !route2) return;
12130
- const memberId = route2.resolved?.seats[run3.role];
12131
- if (!memberId) return;
12132
- const identity = await resolveSeatKey(route2, run3.role, getStore);
12133
- const tracker = new Tracker(route2, cfg.ship, { apiKey: identity.apiKey });
12134
- const t = await tracker.ticket(run3.ticketRecordId);
12135
- const target = repoTargetFor(route2, t.repo_id);
12136
- const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
12137
- const wt = target && num ? worktreeForNumber(target.dir, num) : null;
12138
- let dirty = null;
12139
- try {
12140
- if (wt) dirty = status(wt.path).length > 0;
12141
- } catch {
12415
+ const afterScheduler = async () => {
12416
+ const steps2 = [];
12417
+ for (const run3 of deps.liveRuns()) {
12418
+ const target = `${run3.role} run on ${run3.route} (pid ${run3.pid})`;
12419
+ if (o.dryRun) {
12420
+ steps2.push({ step: "run", target, action: "would-remove" });
12421
+ continue;
12142
12422
  }
12143
- await tracker.postEvent(run3.ticketRecordId, interruptedComment({
12144
- branch: wt?.branch ?? null,
12145
- dirty,
12146
- reason: "the host was released and wiped"
12147
- }), memberId, "interrupted");
12148
- },
12149
- sandboxes,
12150
- removeSandbox: (name) => {
12151
- stopContainer(name);
12152
- },
12153
- async clearShipRows() {
12154
- const out = [];
12155
- for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
12156
- try {
12157
- const found = await new Tracker(r, cfg.ship).clearForRelease(cfg.ship.name);
12158
- out.push({ route: r.route, ok: true, detail: found ? "cleared engaged, tunnel_status, relay_url" : "no Ships row for this ship" });
12159
- } catch (e) {
12160
- out.push({ route: r.route, ok: false, detail: e.message });
12161
- }
12423
+ try {
12424
+ await deps.killRun(run3);
12425
+ deps.recordKill(run3);
12426
+ } catch (e) {
12427
+ steps2.push({ step: "run", target, action: "failed", detail: e.message });
12428
+ continue;
12162
12429
  }
12163
- return out;
12164
- },
12165
- forgeHosts,
12166
- eraseForgeToken: (host) => eraseForgeToken(cfg.ship.forges, host, stateDir),
12167
- labeled: labeledDocker,
12168
- removeVolume: (name) => {
12169
- docker(["volume", "rm", "-f", name]);
12170
- },
12171
- removeImage: (name) => {
12172
- docker(["image", "rm", "-f", name]);
12173
- },
12174
- remaining() {
12175
- const left = [];
12176
- if (existsSync26(cfg.configFile)) left.push(`config ${cfg.configFile}`);
12177
- for (const h of forgeHosts()) if (hasForgeToken(cfg.ship.forges, h, stateDir)) left.push(`forge token for ${h}`);
12178
12430
  try {
12179
- for (const k of readdirSync12(keysDir(stateDir))) left.push(`seat key ${k}`);
12180
- } catch {
12431
+ await deps.handBack(run3);
12432
+ steps2.push({ step: "run", target, action: "removed", detail: "killed" });
12433
+ } catch (e) {
12434
+ steps2.push({ step: "run", target, action: "removed", detail: `killed; could not tell its ticket (${e.message})` });
12181
12435
  }
12182
- for (const s of sandboxes()) left.push(`sandbox ${s}`);
12436
+ }
12437
+ for (const name of deps.sandboxes()) steps2.push(attempt("sandbox", name, () => {
12438
+ deps.removeSandbox(name);
12439
+ }, o.dryRun));
12440
+ if (!o.dryRun) {
12441
+ for (const r of await deps.clearShipRows()) {
12442
+ steps2.push({ step: "ship-row", target: r.route, action: r.ok ? "removed" : "failed", detail: r.detail });
12443
+ }
12444
+ }
12445
+ return steps2;
12446
+ };
12447
+ const beforeState = async () => {
12448
+ const steps2 = [];
12449
+ for (const host of deps.forgeHosts()) {
12183
12450
  try {
12184
- const { volumes, images } = labeledDocker();
12185
- for (const v of volumes) left.push(`volume ${v}`);
12186
- for (const i of images) left.push(`image ${i}`);
12451
+ const result = o.dryRun ? "erased" : deps.eraseForgeToken(host);
12452
+ if (result === "env") steps2.push({ step: "forge", target: host, action: "kept", detail: "the token is read from the environment; crew does not own it" });
12453
+ else if (result === "absent") steps2.push({ step: "forge", target: host, action: "absent" });
12454
+ else steps2.push({ step: "forge", target: host, action: mark2(o.dryRun) });
12187
12455
  } catch (e) {
12188
- left.push(`docker could not be checked (${e.message})`);
12456
+ steps2.push({ step: "forge", target: host, action: "failed", detail: e.message });
12189
12457
  }
12190
- for (const u of unitPaths()) left.push(`unit ${u}`);
12191
- if (loadedCrewLabels().length) left.push(`loaded units ${loadedCrewLabels().join(", ")}`);
12192
- return left;
12193
12458
  }
12459
+ const { volumes, images } = deps.labeled();
12460
+ for (const v of volumes) steps2.push(attempt("cache", `volume ${v}`, () => {
12461
+ deps.removeVolume(v);
12462
+ }, o.dryRun));
12463
+ for (const i of images) steps2.push(attempt("cache", `image ${i}`, () => {
12464
+ deps.removeImage(i);
12465
+ }, o.dryRun));
12466
+ return steps2;
12194
12467
  };
12468
+ const steps = await runReset({ ...o, removeCheckouts: true, wipeState: true, afterScheduler, beforeState }, fx);
12469
+ const remaining = o.dryRun ? [] : deps.remaining();
12470
+ return { steps, remaining, ok: !resetIncomplete(steps) && remaining.length === 0 };
12195
12471
  }
12196
12472
 
12197
12473
  // src/pool-member.ts
12198
12474
  import { mkdirSync as mkdirSync17, readFileSync as readFileSync25, renameSync as renameSync4, writeFileSync as writeFileSync19 } from "fs";
12199
12475
  import { homedir as homedir8 } from "os";
12200
- import { dirname as dirname11, join as join30 } from "path";
12476
+ import { dirname as dirname12, join as join30 } from "path";
12201
12477
  var POOL_EXIT = { ok: 0, failed: 1, credential: 2 };
12202
12478
  function backoffMs(attempt) {
12203
12479
  return Math.min(6e4, 5e3 * 2 ** Math.max(0, attempt));
@@ -12399,7 +12675,7 @@ function fileMemberStore(dir) {
12399
12675
  }
12400
12676
  },
12401
12677
  write(f) {
12402
- mkdirSync17(dirname11(file), { recursive: true, mode: 448 });
12678
+ mkdirSync17(dirname12(file), { recursive: true, mode: 448 });
12403
12679
  const tmp = `${file}.tmp`;
12404
12680
  writeFileSync19(tmp, `${JSON.stringify(f)}
12405
12681
  `, { mode: 384 });
@@ -12457,10 +12733,10 @@ import { existsSync as existsSync30 } from "fs";
12457
12733
  import { hostname as hostname3 } from "os";
12458
12734
 
12459
12735
  // src/machine-id.ts
12460
- import { execFileSync as execFileSync7 } from "child_process";
12736
+ import { execFileSync as execFileSync8 } from "child_process";
12461
12737
  import { createHash as createHash7 } from "crypto";
12462
12738
  import { existsSync as existsSync27, mkdirSync as mkdirSync18, readFileSync as readFileSync26, writeFileSync as writeFileSync20 } from "fs";
12463
- import { dirname as dirname12, join as join31 } from "path";
12739
+ import { dirname as dirname13, join as join31 } from "path";
12464
12740
  function machineIdPathFor(stateDir) {
12465
12741
  return join31(stateDir, "machine-id");
12466
12742
  }
@@ -12468,7 +12744,7 @@ var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
12468
12744
  function readRawMachineId(platform2 = process.platform) {
12469
12745
  try {
12470
12746
  if (platform2 === "darwin") {
12471
- const out = execFileSync7("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
12747
+ const out = execFileSync8("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
12472
12748
  encoding: "utf8",
12473
12749
  stdio: ["ignore", "pipe", "ignore"]
12474
12750
  });
@@ -12494,7 +12770,7 @@ function ensureMachineId(stateDir, opts = {}) {
12494
12770
  const raw = (opts.read ?? readRawMachineId)();
12495
12771
  if (!raw) return void 0;
12496
12772
  const hashed = sha256(raw);
12497
- mkdirSync18(dirname12(path), { recursive: true });
12773
+ mkdirSync18(dirname13(path), { recursive: true });
12498
12774
  writeFileSync20(path, `${hashed}
12499
12775
  `, { mode: 384 });
12500
12776
  return hashed;
@@ -14690,7 +14966,7 @@ async function resolveOne(tracker, t, seat, approvedStatus, buildingStatus, affi
14690
14966
 
14691
14967
  // src/repo-refresh.ts
14692
14968
  import { existsSync as existsSync31, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync24 } from "fs";
14693
- import { dirname as dirname14 } from "path";
14969
+ import { dirname as dirname15 } from "path";
14694
14970
  var REFRESH_COOLDOWN_MS = 2 * 6e4;
14695
14971
  var UNSERVABLE_PASSES_BEFORE_ATTENTION = 3;
14696
14972
  var lastAsked = /* @__PURE__ */ new Map();
@@ -14722,7 +14998,7 @@ function persistRepoRows(stateDir, routeName, rows) {
14722
14998
  if (!existsSync31(path)) return;
14723
14999
  const raw = JSON.parse(readFileSync29(path, "utf8"));
14724
15000
  applyRepoRows(raw, rows);
14725
- mkdirSync20(dirname14(path), { recursive: true });
15001
+ mkdirSync20(dirname15(path), { recursive: true });
14726
15002
  writeFileSync24(path, `${JSON.stringify(raw, null, 2)}
14727
15003
  `);
14728
15004
  }
@@ -15173,7 +15449,7 @@ Ship ${shipName2} found: ${problem}. The release refuses to squash a ref nobody
15173
15449
  var localRefSkipReported = (ticket, problem, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "local_ref_skip" && (e.message ?? "").includes(problem));
15174
15450
 
15175
15451
  // src/connect-wizard.ts
15176
- import { delimiter, dirname as dirname15, join as join35 } from "path";
15452
+ import { delimiter, dirname as dirname16, join as join35 } from "path";
15177
15453
  import { accessSync as accessSync3, constants as constants3, existsSync as existsSync32, mkdirSync as mkdirSync21, writeFileSync as writeFileSync25 } from "fs";
15178
15454
  import { stringify as stringify2 } from "yaml";
15179
15455
  var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
@@ -15222,9 +15498,9 @@ async function runWizard(p, env) {
15222
15498
  if (entered && isExec(entered)) agentBin = entered;
15223
15499
  else p.say(entered ? `${entered} is not an executable file.` : "A path is required.");
15224
15500
  }
15225
- const nodeDir = dirname15(env.nodePath);
15501
+ const nodeDir = dirname16(env.nodePath);
15226
15502
  const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
15227
- const baseDirs = [nodeDir, pnpmPath ? dirname15(pnpmPath) : void 0];
15503
+ const baseDirs = [nodeDir, pnpmPath ? dirname16(pnpmPath) : void 0];
15228
15504
  p.say(
15229
15505
  `
15230
15506
  Host Passengers lets this machine run the agent sessions that other people's
@@ -15235,7 +15511,7 @@ Tablation workspaces ask for, in a container. More: ${HOST_PASSENGERS_DOCS_URL}`
15235
15511
  p.say("Docker is not available here, so nothing will be hosted until it is. Your answer is recorded anyway.");
15236
15512
  }
15237
15513
  const dockerPath = hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
15238
- const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname15(dockerPath) : void 0]);
15514
+ const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname16(dockerPath) : void 0]);
15239
15515
  if (extraPath) {
15240
15516
  p.say(`Hooks will get ${extraPath} on their PATH (where node, pnpm${dockerPath ? " and docker" : ""} live).`);
15241
15517
  }
@@ -15257,7 +15533,7 @@ routes:
15257
15533
  ${routeBlock}`;
15258
15534
  }
15259
15535
  function writeNewConfig(path, text2) {
15260
- mkdirSync21(dirname15(path), { recursive: true });
15536
+ mkdirSync21(dirname16(path), { recursive: true });
15261
15537
  try {
15262
15538
  writeFileSync25(path, text2, { mode: 384, flag: "wx" });
15263
15539
  return true;
@@ -15293,7 +15569,7 @@ Wrote ${o.configPath}.
15293
15569
  // src/connect-json.ts
15294
15570
  import { accessSync as accessSync4, constants as constants4 } from "fs";
15295
15571
  import { homedir as homedir10 } from "os";
15296
- import { dirname as dirname16, join as join36 } from "path";
15572
+ import { dirname as dirname17, join as join36 } from "path";
15297
15573
  import { delimiter as delimiter2 } from "path";
15298
15574
  var CHOICE_EXIT_CODE = 3;
15299
15575
  function createEmitter(json2, write) {
@@ -15359,7 +15635,7 @@ function flagWizardAnswers(f, env) {
15359
15635
  const agentBin = f.agentBin ?? found?.bin;
15360
15636
  const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
15361
15637
  const dockerPath = f.hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
15362
- const extraPath = f.extraPath ?? deriveExtraPath([dirname16(env.nodePath), pnpmPath ? dirname16(pnpmPath) : void 0, dockerPath ? dirname16(dockerPath) : void 0]);
15638
+ const extraPath = f.extraPath ?? deriveExtraPath([dirname17(env.nodePath), pnpmPath ? dirname17(pnpmPath) : void 0, dockerPath ? dirname17(dockerPath) : void 0]);
15363
15639
  return {
15364
15640
  answers: {
15365
15641
  name: f.shipName?.trim() || env.hostname,
@@ -15374,7 +15650,7 @@ function flagWizardAnswers(f, env) {
15374
15650
 
15375
15651
  // src/release-run.ts
15376
15652
  import { readFileSync as readFileSync30, writeFileSync as writeFileSync26, existsSync as existsSync33, copyFileSync, mkdirSync as mkdirSync22, mkdtempSync as mkdtempSync3, rmSync as rmSync17 } from "fs";
15377
- import { dirname as dirname17, join as join37 } from "path";
15653
+ import { dirname as dirname18, join as join37 } from "path";
15378
15654
  import { tmpdir as tmpdir5 } from "os";
15379
15655
 
15380
15656
  // src/release.ts
@@ -15858,11 +16134,11 @@ ${h.prFailure.tail}
15858
16134
  emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
15859
16135
  }
15860
16136
  }
15861
- for (const q of outcome.requeues) {
15862
- const t = q.ticket;
15863
- const body = `The branch is no longer what was verified: QA verified ${q.verified.slice(0, 8)}, the branch is now at ${q.head.slice(0, 8)}. Returned to ${contract.statuses.handoff} for QA to check what changed.`;
16137
+ for (const q2 of outcome.requeues) {
16138
+ const t = q2.ticket;
16139
+ const body = `The branch is no longer what was verified: QA verified ${q2.verified.slice(0, 8)}, the branch is now at ${q2.head.slice(0, 8)}. Returned to ${contract.statuses.handoff} for QA to check what changed.`;
15864
16140
  if (dryRun) {
15865
- emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
16141
+ emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q2.verified.slice(0, 8)}`, { ticket: t.issue_id });
15866
16142
  continue;
15867
16143
  }
15868
16144
  try {
@@ -15870,11 +16146,11 @@ ${h.prFailure.tail}
15870
16146
  await tracker.postEvent(t.id, body, seat, "requeued");
15871
16147
  await tracker.postEvent(
15872
16148
  t.id,
15873
- pushedEventBody(q.head, remote, q.branch),
16149
+ pushedEventBody(q2.head, remote, q2.branch),
15874
16150
  seat,
15875
16151
  "pushed"
15876
16152
  );
15877
- emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
16153
+ emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q2.verified.slice(0, 8)}`, { ticket: t.issue_id });
15878
16154
  } catch (e) {
15879
16155
  emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
15880
16156
  }
@@ -16260,7 +16536,7 @@ function openReleaseCheckout(o) {
16260
16536
  for (const f of o.repo.worktrees.copy) {
16261
16537
  const src = join37(o.cwd, f);
16262
16538
  if (!existsSync33(src)) continue;
16263
- mkdirSync22(dirname17(join37(dir, f)), { recursive: true });
16539
+ mkdirSync22(dirname18(join37(dir, f)), { recursive: true });
16264
16540
  copyFileSync(src, join37(dir, f));
16265
16541
  }
16266
16542
  return { dir, branch, close };
@@ -17071,8 +17347,8 @@ async function buildWhoami(routes, deps = {}) {
17071
17347
  const credential = credentialSource(route2);
17072
17348
  try {
17073
17349
  const key = resolveApiKey(route2);
17074
- const q = route2.resolved ? `?workspaceId=${encodeURIComponent(route2.resolved.workspaceId)}` : "";
17075
- const res = await doFetch(`${route2.baseUrl.replace(/\/+$/, "")}/api/auth/me${q}`, {
17350
+ const q2 = route2.resolved ? `?workspaceId=${encodeURIComponent(route2.resolved.workspaceId)}` : "";
17351
+ const res = await doFetch(`${route2.baseUrl.replace(/\/+$/, "")}/api/auth/me${q2}`, {
17076
17352
  headers: { Authorization: `Bearer ${key}`, "User-Agent": deps.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
17077
17353
  });
17078
17354
  if (res.status === 401 || res.status === 403) {
@@ -17417,9 +17693,9 @@ function startWatch(o) {
17417
17693
 
17418
17694
  // src/inbox.ts
17419
17695
  function matchMembers(rows, query) {
17420
- const q = query.trim().toLowerCase();
17421
- const asEmail = q.includes("@");
17422
- return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q : (r.name ?? "").toLowerCase().includes(q)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
17696
+ const q2 = query.trim().toLowerCase();
17697
+ const asEmail = q2.includes("@");
17698
+ return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q2 : (r.name ?? "").toLowerCase().includes(q2)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
17423
17699
  }
17424
17700
  async function gatherInbox(routes, ship, opts = {}) {
17425
17701
  const items = [];
@@ -17580,7 +17856,7 @@ function nextRoles(decision, ran) {
17580
17856
 
17581
17857
  // src/ship-name.ts
17582
17858
  import { existsSync as existsSync35, mkdirSync as mkdirSync23, readFileSync as readFileSync31, renameSync as renameSync5, writeFileSync as writeFileSync27 } from "fs";
17583
- import { dirname as dirname18 } from "path";
17859
+ import { dirname as dirname19 } from "path";
17584
17860
  import { parseDocument as parseDocument5 } from "yaml";
17585
17861
  var ShipNameError = class extends Error {
17586
17862
  };
@@ -17659,7 +17935,7 @@ function writeSyncedName(resolvedFile, name) {
17659
17935
  }
17660
17936
  if (raw.shipName === name) return;
17661
17937
  raw.shipName = name;
17662
- mkdirSync23(dirname18(resolvedFile), { recursive: true });
17938
+ mkdirSync23(dirname19(resolvedFile), { recursive: true });
17663
17939
  const tmp = `${resolvedFile}.tmp${process.pid}`;
17664
17940
  writeFileSync27(tmp, `${JSON.stringify(raw, null, 2)}
17665
17941
  `);
@@ -18353,7 +18629,7 @@ function describeCapability(i) {
18353
18629
 
18354
18630
  // src/agents-sync-run.ts
18355
18631
  import { existsSync as existsSync36, mkdirSync as mkdirSync24, readFileSync as readFileSync32, writeFileSync as writeFileSync28 } from "fs";
18356
- import { dirname as dirname19 } from "path";
18632
+ import { dirname as dirname20 } from "path";
18357
18633
  async function runAgentsSync(target, o) {
18358
18634
  const dryRun = o.dryRun ?? false;
18359
18635
  const force = o.force ?? false;
@@ -18390,7 +18666,7 @@ async function runAgentsSync(target, o) {
18390
18666
  raw.agentPersonas = personaResult.agentPersonas;
18391
18667
  if (skillResult) raw.agentSkills = skillResult.agentSkills;
18392
18668
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
18393
- mkdirSync24(dirname19(resolvedPath), { recursive: true });
18669
+ mkdirSync24(dirname20(resolvedPath), { recursive: true });
18394
18670
  writeFileSync28(resolvedPath, `${JSON.stringify(raw, null, 2)}
18395
18671
  `);
18396
18672
  if (target.resolved) {
@@ -18811,7 +19087,7 @@ function existingRepoConfig(f) {
18811
19087
  }
18812
19088
 
18813
19089
  // src/repos-hooks-agent.ts
18814
- import { spawn as spawn7, execFileSync as execFileSync8 } from "child_process";
19090
+ import { spawn as spawn7, execFileSync as execFileSync9 } from "child_process";
18815
19091
  import { realpathSync as realpathSync2 } from "fs";
18816
19092
  import { isAbsolute as isAbsolute2, relative } from "path";
18817
19093
  var AgentSuggestError = class extends Error {
@@ -18914,7 +19190,7 @@ function classifyFailure(o) {
18914
19190
  }
18915
19191
  function porcelain(dir) {
18916
19192
  try {
18917
- return execFileSync8("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8" });
19193
+ return execFileSync9("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8" });
18918
19194
  } catch {
18919
19195
  return void 0;
18920
19196
  }
@@ -18983,7 +19259,7 @@ ${run3.err}` });
18983
19259
  }
18984
19260
 
18985
19261
  // src/sample-ticket.ts
18986
- import { basename as basename11, dirname as dirname20, join as join38 } from "path";
19262
+ import { basename as basename11, dirname as dirname21, join as join38 } from "path";
18987
19263
  var SAMPLE_REPO = "crew-sample";
18988
19264
  var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
18989
19265
  var SAMPLE_MARKER = "sample: true";
@@ -19006,7 +19282,7 @@ has something safe to work on: its test and build hooks always pass, it has no
19006
19282
  remote, and \`crew sample-ticket --remove\` deletes it.
19007
19283
  `;
19008
19284
  function sampleDir(existing, reposBase, workspace) {
19009
- if (existing.length > 0) return join38(dirname20(existing[0]), SAMPLE_REPO);
19285
+ if (existing.length > 0) return join38(dirname21(existing[0]), SAMPLE_REPO);
19010
19286
  return join38(reposBase, workspace, SAMPLE_REPO);
19011
19287
  }
19012
19288
  var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
@@ -19052,7 +19328,7 @@ function planSampleTicket(i) {
19052
19328
 
19053
19329
  // src/worktree-sweep.ts
19054
19330
  import { existsSync as existsSync37, readFileSync as readFileSync34, readdirSync as readdirSync14 } from "fs";
19055
- import { basename as basename12, dirname as dirname21, join as join39, resolve as resolve4 } from "path";
19331
+ import { basename as basename12, dirname as dirname22, join as join39, resolve as resolve4 } from "path";
19056
19332
  function planWorktreeSweep(target, tickets, contract) {
19057
19333
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
19058
19334
  const verified = contract.statuses.verified;
@@ -19116,7 +19392,7 @@ function worktreeTicketNumbers(target) {
19116
19392
  return [...nums];
19117
19393
  }
19118
19394
  function siblingWorktreeDirs(target) {
19119
- const parent = dirname21(target.dir.replace(/[/\\]+$/, ""));
19395
+ const parent = dirname22(target.dir.replace(/[/\\]+$/, ""));
19120
19396
  const stem = `${basename12(target.dir.replace(/[/\\]+$/, ""))}-`;
19121
19397
  const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
19122
19398
  let names;
@@ -19809,14 +20085,14 @@ ${tail2(preview.output)}`);
19809
20085
  }
19810
20086
 
19811
20087
  // src/periscope-idle.ts
19812
- import { execFileSync as execFileSync9 } from "child_process";
20088
+ import { execFileSync as execFileSync10 } from "child_process";
19813
20089
  import { statSync as statSync11 } from "fs";
19814
20090
  import { join as join42 } from "path";
19815
20091
  var MAX_SCANNED_FILES = 2e4;
19816
20092
  function newestWorktreeMtime(root) {
19817
20093
  let listing;
19818
20094
  try {
19819
- listing = execFileSync9("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
20095
+ listing = execFileSync10("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
19820
20096
  cwd: root,
19821
20097
  encoding: "utf8",
19822
20098
  stdio: ["ignore", "pipe", "ignore"],
@@ -19906,9 +20182,9 @@ async function runPeriscopeIdlePass(d) {
19906
20182
  import { dirname as dirOf2, resolve as resolvePath } from "path";
19907
20183
 
19908
20184
  // src/ssh-keys.ts
19909
- import { execFileSync as execFileSync10 } from "child_process";
20185
+ import { execFileSync as execFileSync11 } from "child_process";
19910
20186
  import { existsSync as existsSync40, mkdirSync as mkdirSync26, readFileSync as readFileSync36, chmodSync as chmodSync3 } from "fs";
19911
- import { dirname as dirname22, join as join43 } from "path";
20187
+ import { dirname as dirname23, join as join43 } from "path";
19912
20188
  var SshKeyError = class extends Error {
19913
20189
  };
19914
20190
  function shipSshKeyPathFor(stateDir) {
@@ -19918,9 +20194,9 @@ function ensureShipSshKeypair(stateDir) {
19918
20194
  const privateKeyPath = shipSshKeyPathFor(stateDir);
19919
20195
  const publicKeyPath = `${privateKeyPath}.pub`;
19920
20196
  if (!existsSync40(privateKeyPath)) {
19921
- mkdirSync26(dirname22(privateKeyPath), { recursive: true });
20197
+ mkdirSync26(dirname23(privateKeyPath), { recursive: true });
19922
20198
  try {
19923
- execFileSync10("ssh-keygen", [
20199
+ execFileSync11("ssh-keygen", [
19924
20200
  "-t",
19925
20201
  "ed25519",
19926
20202
  "-N",
@@ -19946,7 +20222,7 @@ function ensureShipSshKeypair(stateDir) {
19946
20222
  }
19947
20223
  function sshKeygenAvailable() {
19948
20224
  try {
19949
- execFileSync10("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
20225
+ execFileSync11("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
19950
20226
  return true;
19951
20227
  } catch (e) {
19952
20228
  return e.code !== "ENOENT";
@@ -20507,6 +20783,13 @@ async function runOnePassUnguarded(o) {
20507
20783
  const spawn11 = o.spawnRoleAgent ?? runRoleAgent;
20508
20784
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
20509
20785
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
20786
+ if (o.sandboxCache) {
20787
+ try {
20788
+ await o.sandboxCache();
20789
+ } catch (e) {
20790
+ emit.warn(`sandbox cache refresh failed: ${e instanceof Error ? e.message : String(e)}`);
20791
+ }
20792
+ }
20510
20793
  if (o.configFile) await reconcileRouteShipName({ route: o.route, ship: o.ship, configFile: o.configFile, emit });
20511
20794
  try {
20512
20795
  await (o.reconcile ?? reconcileForRoute)(ctx, o);
@@ -21009,8 +21292,8 @@ import {
21009
21292
  } from "fs";
21010
21293
  import { createConnection } from "net";
21011
21294
  import { createServer } from "http";
21012
- import { basename as basename13, dirname as dirname23, join as join46 } from "path";
21013
- import { execFile as execFile2, spawn as spawn10 } from "child_process";
21295
+ import { basename as basename13, dirname as dirname24, join as join46 } from "path";
21296
+ import { execFile as execFile3, spawn as spawn10 } from "child_process";
21014
21297
 
21015
21298
  // src/periscope-control.ts
21016
21299
  import { spawn as spawn9 } from "child_process";
@@ -21097,23 +21380,23 @@ async function execute(h, args, streaming, after) {
21097
21380
  };
21098
21381
  }
21099
21382
  var isPeriscopePath = (p) => p === "/periscopes" || p.startsWith("/periscopes/");
21100
- async function handlePeriscopes(h, q) {
21383
+ async function handlePeriscopes(h, q2) {
21101
21384
  if (!h || !h.routes.some((r) => r.hostPeriscopes)) return err(404, "not found");
21102
- if (!q.token) return err(401, "a bearer token is required");
21103
- const down = /^\/periscopes\/([^/]+)\/down$/.exec(q.path);
21104
- const isUp = q.path === "/periscopes" && q.method === "POST";
21105
- const isList = q.path === "/periscopes" && q.method === "GET";
21106
- if (q.path === "/periscopes" && !isUp && !isList) return err(405, "method not allowed");
21107
- if (down && q.method !== "POST") return err(405, "method not allowed");
21108
- if (q.path !== "/periscopes" && !down) return err(404, "not found");
21385
+ if (!q2.token) return err(401, "a bearer token is required");
21386
+ const down = /^\/periscopes\/([^/]+)\/down$/.exec(q2.path);
21387
+ const isUp = q2.path === "/periscopes" && q2.method === "POST";
21388
+ const isList = q2.path === "/periscopes" && q2.method === "GET";
21389
+ if (q2.path === "/periscopes" && !isUp && !isList) return err(405, "method not allowed");
21390
+ if (down && q2.method !== "POST") return err(405, "method not allowed");
21391
+ if (q2.path !== "/periscopes" && !down) return err(404, "not found");
21109
21392
  const member = async (workspaceId) => {
21110
- const v = await h.verifyIn(q.token, workspaceId);
21393
+ const v = await h.verifyIn(q2.token, workspaceId);
21111
21394
  if (v === "ok") return void 0;
21112
21395
  if (v === "unavailable") return err(503, "could not verify the key right now");
21113
21396
  return err(403, "this key does not belong to the workspace of this route");
21114
21397
  };
21115
21398
  if (isList) {
21116
- const named = q.query.get("route") ?? void 0;
21399
+ const named = q2.query.get("route") ?? void 0;
21117
21400
  const candidates2 = named === void 0 ? h.routes.filter((r) => r.hostPeriscopes) : [pickRoute(h, named)];
21118
21401
  const rows = [];
21119
21402
  const hosting = [];
@@ -21145,7 +21428,7 @@ async function handlePeriscopes(h, q) {
21145
21428
  }
21146
21429
  return reply(200, { periscopes: rows, hosting });
21147
21430
  }
21148
- const b = q.body;
21431
+ const b = q2.body;
21149
21432
  if (!b) return err(400, "body must be a small JSON object");
21150
21433
  const routeName = plainText(b.route, "route", false);
21151
21434
  if (isErr(routeName)) return err(400, routeName.error);
@@ -21166,7 +21449,7 @@ async function handlePeriscopes(h, q) {
21166
21449
  `--${b.outcome}`,
21167
21450
  ...b.allowEmpty === true ? ["--allow-empty"] : [],
21168
21451
  "--json"
21169
- ], q.wantsStream, h.mcp ? async (done) => {
21452
+ ], q2.wantsStream, h.mcp ? async (done) => {
21170
21453
  await h.mcp.onDown(picked, done);
21171
21454
  return done;
21172
21455
  } : void 0);
@@ -21200,7 +21483,7 @@ async function handlePeriscopes(h, q) {
21200
21483
  ...h.mcp ? ["--serve-mcp"] : [],
21201
21484
  "--no-open",
21202
21485
  "--json"
21203
- ], q.wantsStream, h.mcp ? async (done) => ({ ...done, ...await h.mcp.onUp(picked, done) }) : void 0);
21486
+ ], q2.wantsStream, h.mcp ? async (done) => ({ ...done, ...await h.mcp.onUp(picked, done) }) : void 0);
21204
21487
  }
21205
21488
  function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
21206
21489
  return new Promise((resolve7) => {
@@ -21325,7 +21608,7 @@ function candidates(live, rotated) {
21325
21608
  const out = [live];
21326
21609
  if (!rotated) return out;
21327
21610
  try {
21328
- for (const n of readdirSync18(dirname23(live))) if (ROTATED_EVENTS_RE.test(n)) out.push(join46(dirname23(live), n));
21611
+ for (const n of readdirSync18(dirname24(live))) if (ROTATED_EVENTS_RE.test(n)) out.push(join46(dirname24(live), n));
21329
21612
  } catch {
21330
21613
  }
21331
21614
  return out;
@@ -21475,17 +21758,17 @@ async function readJsonBody(req) {
21475
21758
  return null;
21476
21759
  }
21477
21760
  }
21478
- async function tailReply(path, q, rotated, abort) {
21479
- const after = q.get("after") ?? void 0;
21480
- const tail3 = q.has("tail") ? Number(q.get("tail")) : void 0;
21761
+ async function tailReply(path, q2, rotated, abort) {
21762
+ const after = q2.get("after") ?? void 0;
21763
+ const tail3 = q2.has("tail") ? Number(q2.get("tail")) : void 0;
21481
21764
  if (tail3 !== void 0 && !(Number.isInteger(tail3) && tail3 >= 0 && tail3 <= 1e3)) return err2(400, "tail must be an integer from 0 to 1000");
21482
- const wait = q.has("wait") ? Number(q.get("wait")) : 0;
21765
+ const wait = q2.has("wait") ? Number(q2.get("wait")) : 0;
21483
21766
  if (!(Number.isFinite(wait) && wait >= 0)) return err2(400, "wait must be a number of seconds");
21484
21767
  if (after !== void 0 && !decodeCursor(after)) return err2(400, "after is not a cursor this API issued");
21485
21768
  const deadline = Date.now() + Math.min(wait, MAX_WAIT_SECONDS) * 1e3;
21486
21769
  let r = readTail(path, { after, tail: tail3, rotated });
21487
21770
  while (after && !r.lines.length && !r.reset && Date.now() < deadline && !abort.aborted) {
21488
- await waitForChange(dirname23(path), deadline - Date.now(), abort);
21771
+ await waitForChange(dirname24(path), deadline - Date.now(), abort);
21489
21772
  r = readTail(path, { after: r.cursor, rotated });
21490
21773
  }
21491
21774
  return reply2(200, r);
@@ -21662,7 +21945,7 @@ function schedulerHandler(control, run3 = runDetached) {
21662
21945
  }
21663
21946
  function crewJson(args, timeoutMs = 6e4) {
21664
21947
  return new Promise((resolve7) => {
21665
- execFile2(process.execPath, [process.argv[1] ?? "", ...args], { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (_e, stdout) => {
21948
+ execFile3(process.execPath, [process.argv[1] ?? "", ...args], { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (_e, stdout) => {
21666
21949
  try {
21667
21950
  const v = JSON.parse(String(stdout));
21668
21951
  resolve7(v && typeof v === "object" && !Array.isArray(v) ? v : null);
@@ -21675,7 +21958,7 @@ function crewJson(args, timeoutMs = 6e4) {
21675
21958
 
21676
21959
  // src/periscope-mcp.ts
21677
21960
  import { lstat, mkdir, readdir, readFile, readlink, realpath, rm, stat, writeFile } from "fs/promises";
21678
- import { basename as basename14, dirname as dirname24, isAbsolute as isAbsolute3, join as join48, relative as relative2, resolve as resolve5, sep } from "path";
21961
+ import { basename as basename14, dirname as dirname25, isAbsolute as isAbsolute3, join as join48, relative as relative2, resolve as resolve5, sep } from "path";
21679
21962
  import { z } from "zod";
21680
21963
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21681
21964
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -21813,10 +22096,10 @@ async function realAncestor(p) {
21813
22096
  }
21814
22097
  if (isLink) {
21815
22098
  if (++hops > 40) throw new PathRefusal(`too many symbolic links resolving ${p}`);
21816
- cur = resolve5(dirname24(cur), await readlink(cur));
22099
+ cur = resolve5(dirname25(cur), await readlink(cur));
21817
22100
  continue;
21818
22101
  }
21819
- const parent = dirname24(cur);
22102
+ const parent = dirname25(cur);
21820
22103
  if (parent === cur) throw new PathRefusal(`cannot resolve ${p}`);
21821
22104
  tail3.push(basename14(cur));
21822
22105
  cur = parent;
@@ -21839,7 +22122,7 @@ async function resolveInWorktree(root, requested) {
21839
22122
  if (relative2(realRoot, final).split(sep).includes(".git")) throw new PathRefusal(".git is not reachable from a periscope");
21840
22123
  return final;
21841
22124
  }
21842
- function globToRegExp(glob) {
22125
+ function globToRegExp2(glob) {
21843
22126
  let re = "";
21844
22127
  for (let i = 0; i < glob.length; i++) {
21845
22128
  const c = glob[i];
@@ -21913,7 +22196,7 @@ function createPeriscopeMcpServer(d, sc) {
21913
22196
  inputSchema: { pattern: z.string() },
21914
22197
  annotations: { readOnlyHint: true, openWorldHint: false }
21915
22198
  }, async ({ pattern }) => {
21916
- const re = globToRegExp(pattern);
22199
+ const re = globToRegExp2(pattern);
21917
22200
  const listing = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
21918
22201
  const hits = listing.split("\0").filter((f) => f && re.test(f)).slice(0, MAX_SEARCH_RESULTS);
21919
22202
  return text(hits.length ? hits.join("\n") : "No matches found");
@@ -21927,7 +22210,7 @@ function createPeriscopeMcpServer(d, sc) {
21927
22210
  if (Buffer.byteLength(content) > MAX_WRITE_BYTES) return text(`content exceeds ${MAX_WRITE_BYTES} bytes`, true);
21928
22211
  const abs = await inTree(path);
21929
22212
  if (abs === await realpath(root)) return text("path must name a file", true);
21930
- await mkdir(dirname24(abs), { recursive: true });
22213
+ await mkdir(dirname25(abs), { recursive: true });
21931
22214
  await inTree(path);
21932
22215
  await writeFile(abs, content);
21933
22216
  return text(`Wrote ${Buffer.byteLength(content)} bytes to ${await rel(abs)}`);
@@ -21967,7 +22250,7 @@ function createPeriscopeMcpServer(d, sc) {
21967
22250
  inputSchema: { path: z.string() },
21968
22251
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
21969
22252
  }, async ({ path }) => {
21970
- const parent = await inTree(dirname24(path) === "" ? "." : dirname24(path));
22253
+ const parent = await inTree(dirname25(path) === "" ? "." : dirname25(path));
21971
22254
  const abs = join48(parent, basename14(path));
21972
22255
  if (basename14(path) === ".git" || abs === parent) return text("path must name a file", true);
21973
22256
  const st = await lstat(abs).catch(() => void 0);
@@ -22089,7 +22372,7 @@ function findPeriscope(sidecars, segment) {
22089
22372
  }
22090
22373
 
22091
22374
  // src/cli.ts
22092
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname25(process.execPath) : resolve6(dirname25(fileURLToPath(import.meta.url)), "..");
22375
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname26(process.execPath) : resolve6(dirname26(fileURLToPath(import.meta.url)), "..");
22093
22376
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
22094
22377
  var CYCLE_SECONDS2 = 120;
22095
22378
  var STALE_CYCLES = 3;
@@ -22138,12 +22421,12 @@ function terminalPrompter() {
22138
22421
  process.stderr.write(`${line}
22139
22422
  `);
22140
22423
  },
22141
- ask: async (q, fallback) => {
22142
- const a = (await rl.question(fallback ? `${q} [${fallback}]: ` : `${q}: `)).trim();
22424
+ ask: async (q2, fallback) => {
22425
+ const a = (await rl.question(fallback ? `${q2} [${fallback}]: ` : `${q2}: `)).trim();
22143
22426
  return a || fallback || "";
22144
22427
  },
22145
- confirm: async (q, fallback) => {
22146
- const a = (await rl.question(`${q} [${fallback ? "Y/n" : "y/N"}] `)).trim();
22428
+ confirm: async (q2, fallback) => {
22429
+ const a = (await rl.question(`${q2} [${fallback ? "Y/n" : "y/N"}] `)).trim();
22147
22430
  return a ? /^y/i.test(a) : fallback;
22148
22431
  }
22149
22432
  };
@@ -22268,6 +22551,7 @@ function usage(problem, requested = false) {
22268
22551
  crew shell-init [--shell zsh|bash|fish] print the periscope shell function (install: eval "$(crew shell-init)")
22269
22552
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
22270
22553
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
22554
+ crew sandbox cache [--prune|--reset] [--json] list the sandbox cache volume's mirrors; --prune drops unserved repositories, --reset recreates the volume
22271
22555
  crew sync [route] fast-forward the checkout and its worktrees from the remote
22272
22556
  crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
22273
22557
  resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
@@ -22584,6 +22868,31 @@ if (command === "passenger-sync-daemon") {
22584
22868
  const out = refreshBaseBranch(r.dir, remote, base, true);
22585
22869
  const unsafe = baseBranchUnsafe(out);
22586
22870
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
22871
+ }, sandboxCacheRepos = function(c, ship) {
22872
+ const out = [];
22873
+ for (const t of reposOf(c)) {
22874
+ const origin = originUrlOf(t.dir, "origin");
22875
+ const repo = origin ? parseRemote(origin) : null;
22876
+ if (!repo) continue;
22877
+ let token = null;
22878
+ try {
22879
+ token = readForgeToken(ship.forges, repo.host, ship.stateDir);
22880
+ } catch {
22881
+ }
22882
+ out.push({ ...repo, dir: t.dir, token, username: forgeFor(ship.forges, repo.host)?.entry.username });
22883
+ }
22884
+ return out;
22885
+ }, buildSandboxCachePass = function(c, ship, state2, emit2) {
22886
+ const settings = cacheSettingsOf(ship.sandbox);
22887
+ if (!settings) return void 0;
22888
+ return makeCachePass({
22889
+ cache: new SandboxCache(settings),
22890
+ volume: settings.volume,
22891
+ repos: () => sandboxCacheRepos(c, ship),
22892
+ lock: (dir) => acquireCheckoutLock(dir),
22893
+ fail: (key, message) => raiseShipAttention({ state: state2, emit: emit2, route: c, ship }, { kind: "sandbox_cache", key, message, since: (/* @__PURE__ */ new Date()).toISOString() }).then(() => void 0),
22894
+ ok: (key) => clearShipAttention(state2, key)
22895
+ });
22587
22896
  }, sandboxTickDeps = function(emit2, c) {
22588
22897
  return {
22589
22898
  stateDir: cfg.ship.stateDir,
@@ -22597,7 +22906,7 @@ if (command === "passenger-sync-daemon") {
22597
22906
  log: (m) => emit2.emit(m)
22598
22907
  };
22599
22908
  };
22600
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, periscopeEndpointDeps2 = periscopeEndpointDeps, periscopeMcpDeps2 = periscopeMcpDeps, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth, sandboxTickDeps2 = sandboxTickDeps;
22909
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, periscopeEndpointDeps2 = periscopeEndpointDeps, periscopeMcpDeps2 = periscopeMcpDeps, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth, sandboxCacheRepos2 = sandboxCacheRepos, buildSandboxCachePass2 = buildSandboxCachePass, sandboxTickDeps2 = sandboxTickDeps;
22601
22910
  let cfg;
22602
22911
  let firstRun;
22603
22912
  let firstRunFromFlags = false;
@@ -22702,7 +23011,7 @@ if (command === "passenger-sync-daemon") {
22702
23011
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
22703
23012
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
22704
23013
  const named = worktreeArgs ? worktreeArgs.route : command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
22705
- const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "deprovision" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "periscope" || command === "sandbox" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
23014
+ const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "deprovision" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "periscope" || command === "whoami" || command === "sandbox" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
22706
23015
  let route2;
22707
23016
  try {
22708
23017
  route2 = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -23051,7 +23360,14 @@ ${changed2.map((c) => ` - ${c}
23051
23360
  const out = /* @__PURE__ */ new Map();
23052
23361
  for (const c of routes) {
23053
23362
  const { settled } = await settleRepos(reposOf(c), (t) => t.name, (t) => resolveOneRepo(c, t));
23054
- for (const r of settled) if (r.config.sandbox && !out.has(r.name)) out.set(r.name, { name: r.name, decl: r.config.sandbox });
23363
+ for (const r of settled) if (r.config.sandbox && !out.has(r.name)) {
23364
+ let warm = {};
23365
+ try {
23366
+ warm = readWarmFiles(r.dir, `${r.config.branch.remote}/${r.config.branch.base}`, r.config.sandbox.warm ?? []);
23367
+ } catch {
23368
+ }
23369
+ out.set(r.name, { name: r.name, decl: r.config.sandbox, warm });
23370
+ }
23055
23371
  }
23056
23372
  return [...out.values()];
23057
23373
  }
@@ -24225,6 +24541,7 @@ ${cu.tail.trimEnd()}
24225
24541
  });
24226
24542
  if (controlServer) emit.emit(`control API listening on ${controlServer.path}`);
24227
24543
  const inFlight = /* @__PURE__ */ new Set();
24544
+ const sandboxCachePass = cfg.ship.runner === "sandbox" ? buildSandboxCachePass(route2, cfg.ship, state, emit) : void 0;
24228
24545
  let sandboxBuilding = false;
24229
24546
  const stopReason = await runDaemonLoop({
24230
24547
  inFlight,
@@ -24233,6 +24550,7 @@ ${cu.tail.trimEnd()}
24233
24550
  ),
24234
24551
  runPass: () => runOnePass({
24235
24552
  inFlight,
24553
+ sandboxCache: sandboxCachePass,
24236
24554
  route: route2,
24237
24555
  ship: cfg.ship,
24238
24556
  state,
@@ -24542,7 +24860,7 @@ ${cu.tail.trimEnd()}
24542
24860
  const found = cfg.routes.find((r) => r.route === routeArg);
24543
24861
  if (!found) throw new ReposError(`no route "${routeArg}" in the config (have: ${cfg.routes.map((r) => r.route).join(", ") || "none"})`);
24544
24862
  const named2 = value("route") ? void 0 : positional[3];
24545
- const dir = named2 ? repoDirOf(configText, dirname25(cfg.configFile), found.route, named2) : resolvePath(positional[value("route") ? 2 : 3] ?? ".");
24863
+ const dir = named2 ? repoDirOf(configText, dirname26(cfg.configFile), found.route, named2) : resolvePath(positional[value("route") ? 2 : 3] ?? ".");
24546
24864
  const target = reposOf(found).find((t) => resolvePath(t.dir) === dir || t.name === named2);
24547
24865
  const o = target ? found.repoOverrides[target.name] : void 0;
24548
24866
  runReposCheck(dir, {
@@ -24565,7 +24883,7 @@ ${cu.tail.trimEnd()}
24565
24883
  if (sub === "hooks") {
24566
24884
  try {
24567
24885
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
24568
- const dir = repoDirOf(configText, dirname25(cfg.configFile), a.route, a.name);
24886
+ const dir = repoDirOf(configText, dirname26(cfg.configFile), a.route, a.name);
24569
24887
  if (!existsSync45(dir)) throw new ReposError(`${dir} does not exist on disk`);
24570
24888
  const files = { exists: (r) => existsSync45(resolvePath(dir, r)), list: (r) => {
24571
24889
  try {
@@ -24732,7 +25050,7 @@ ${cu.tail.trimEnd()}
24732
25050
  }
24733
25051
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
24734
25052
  const text2 = configText;
24735
- const base = dirname25(cfg.configFile);
25053
+ const base = dirname26(cfg.configFile);
24736
25054
  const isCheckout = (d) => existsSync45(`${d}/.git`);
24737
25055
  try {
24738
25056
  if (sub === "list") {
@@ -25408,7 +25726,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
25408
25726
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
25409
25727
  `);
25410
25728
  } else {
25411
- mkdirSync30(dirname25(resolvedPath), { recursive: true });
25729
+ mkdirSync30(dirname26(resolvedPath), { recursive: true });
25412
25730
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
25413
25731
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
25414
25732
  writeFileSync33(tmpPath, `${JSON.stringify(resolved, null, 2)}
@@ -25457,7 +25775,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
25457
25775
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
25458
25776
  `);
25459
25777
  } else {
25460
- mkdirSync30(dirname25(apiKeyFile), { recursive: true });
25778
+ mkdirSync30(dirname26(apiKeyFile), { recursive: true });
25461
25779
  writeFileSync33(apiKeyFile, `${apiKeyVar}=${apiKey}
25462
25780
  `, { mode: 384 });
25463
25781
  process.stderr.write(`API key written to ${apiKeyFile}
@@ -26028,7 +26346,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
26028
26346
  if (subS === "images") {
26029
26347
  const stateS = readSandboxState(cfg.ship.stateDir);
26030
26348
  const rows = declared.map((r) => {
26031
- const plan = planSandbox(r.name, r.decl);
26349
+ const plan = planSandbox(r.name, r.decl, r.warm);
26032
26350
  return { repo: r.name, wants: plan.tag, tags: stateS[r.name]?.tags ?? [], ...sandboxImageStatus(stateS, plan) };
26033
26351
  });
26034
26352
  if (jsonS) {
@@ -26062,7 +26380,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
26062
26380
  for (const t of targets) {
26063
26381
  await sandboxBuildTick([t], depsS, true);
26064
26382
  const after = readSandboxState(cfg.ship.stateDir);
26065
- const st = sandboxImageStatus(after, planSandbox(t.name, t.decl));
26383
+ const st = sandboxImageStatus(after, planSandbox(t.name, t.decl, t.warm));
26066
26384
  if (st.status === "failed") failed = true;
26067
26385
  process.stdout.write(`${t.name}: ${st.status} \u2014 ${st.detail}
26068
26386
  `);
@@ -26781,7 +27099,7 @@ ${where.join("\n")}`);
26781
27099
  if (!route2.resolved) fail(`route ${route2.route} is not connected \u2014 run \`crew connect\` first`);
26782
27100
  const tracker = new Tracker(route2, cfg.ship);
26783
27101
  const configText = readFileSync40(cfg.configFile, "utf8");
26784
- const cfgBase = dirname25(cfg.configFile);
27102
+ const cfgBase = dirname26(cfg.configFile);
26785
27103
  const configured2 = configuredRepos(configText, route2.route, cfgBase);
26786
27104
  const dir = configured2.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured2.map((r) => r.dir), route2.reposBasePath, route2.route.split("/")[0]);
26787
27105
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, route2.route);
@@ -26943,6 +27261,45 @@ ${where.join("\n")}`);
26943
27261
  if (results.some((r) => !r.ok)) process.exitCode = 1;
26944
27262
  break;
26945
27263
  }
27264
+ case "sandbox": {
27265
+ const settings = cacheSettingsOf(cfg.ship.sandbox);
27266
+ if (positional[1] !== "cache") {
27267
+ process.stderr.write("crew sandbox cache [--prune|--reset] [--json]\n");
27268
+ process.exit(2);
27269
+ }
27270
+ if (!settings) {
27271
+ process.stderr.write("crew sandbox cache: ship.sandbox.cacheVolume is not set, so there is no cache volume\n");
27272
+ process.exit(2);
27273
+ }
27274
+ const cache = new SandboxCache(settings);
27275
+ const served = cfg.routes.flatMap((r) => sandboxCacheRepos(r, cfg.ship));
27276
+ try {
27277
+ if (flag("reset")) {
27278
+ await cache.reset();
27279
+ process.stdout.write(`recreated volume ${settings.volume}; the next daemon pass refreshes every mirror
27280
+ `);
27281
+ break;
27282
+ }
27283
+ const dropped = flag("prune") ? await cache.prune(served) : [];
27284
+ const mirrors = await cache.list();
27285
+ if (flag("json")) {
27286
+ process.stdout.write(`${JSON.stringify({ volume: settings.volume, mirrors, dropped }, null, 2)}
27287
+ `);
27288
+ } else {
27289
+ for (const d of dropped) process.stdout.write(`dropped ${d.host}/${d.path}
27290
+ `);
27291
+ if (mirrors.length === 0) process.stdout.write(`volume ${settings.volume}: no mirrors yet
27292
+ `);
27293
+ for (const m of mirrors) process.stdout.write(`${m.repo.host}/${m.repo.path} last refresh ${m.lastRefresh ?? "never"}
27294
+ `);
27295
+ }
27296
+ } catch (e) {
27297
+ process.stderr.write(`crew sandbox cache: ${e instanceof Error ? e.message : String(e)}
27298
+ `);
27299
+ process.exitCode = 1;
27300
+ }
27301
+ break;
27302
+ }
26946
27303
  case "whoami": {
26947
27304
  const only = value("route") ?? positional[1];
26948
27305
  const wanted = only ? cfg.routes.filter((r) => r.route === only) : cfg.routes;
@@ -27761,7 +28118,7 @@ ${foreign.join("\n")}
27761
28118
  const declaredS = await sandboxReposOf(cfg.routes.filter((r) => r.enabled));
27762
28119
  const stateS = readSandboxState(cfg.ship.stateDir);
27763
28120
  for (const r of declaredS) {
27764
- const st = sandboxImageStatus(stateS, planSandbox(r.name, r.decl));
28121
+ const st = sandboxImageStatus(stateS, planSandbox(r.name, r.decl, r.warm));
27765
28122
  process.stdout.write(`sandbox image: ${r.name}: ${st.status.toUpperCase()} \u2014 ${st.detail}
27766
28123
  `);
27767
28124
  }
@@ -27824,4 +28181,6 @@ var endpointReconcileDeps2;
27824
28181
  var releaseRefused2;
27825
28182
  var doctorMachineId2;
27826
28183
  var baseHealth2;
28184
+ var sandboxCacheRepos2;
28185
+ var buildSandboxCachePass2;
27827
28186
  var sandboxTickDeps2;