@tablation/crew 0.105.0 → 0.106.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 +1857 -1540
  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
  });
@@ -2719,7 +2719,22 @@ function parseShipRunner(v) {
2719
2719
  if (v === "host" || v === "sandbox") return v;
2720
2720
  throw new ConfigError(`ship.runner must be "host" or "sandbox", got ${JSON.stringify(v)}`);
2721
2721
  }
2722
- var SANDBOX_KEYS = ["bin", "image", "policy", "cacheVolume", "mirrorDir", "workDir", "agentBin", "forgeTokenVar"];
2722
+ var SANDBOX_KEYS = [
2723
+ "bin",
2724
+ "image",
2725
+ "policy",
2726
+ "cacheVolume",
2727
+ "mirrorDir",
2728
+ "workDir",
2729
+ "agentBin",
2730
+ "forgeTokenVar",
2731
+ "engine",
2732
+ "cacheWorkspace",
2733
+ "cacheImage",
2734
+ "depsImage",
2735
+ "depsCommand",
2736
+ "depsLockfile"
2737
+ ];
2723
2738
  function parseShipSandbox(v) {
2724
2739
  if (v === void 0 || v === null) return void 0;
2725
2740
  if (typeof v !== "object" || Array.isArray(v)) throw new ConfigError("ship.sandbox must be a map of settings");
@@ -5897,16 +5912,16 @@ function roleCapWarnings(ship) {
5897
5912
 
5898
5913
  // src/selftest.ts
5899
5914
  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";
5915
+ import { existsSync as existsSync25, mkdirSync as mkdirSync14, mkdtempSync as mkdtempSync2, readFileSync as readFileSync22, rmSync as rmSync14, writeFileSync as writeFileSync16 } from "fs";
5901
5916
  import { tmpdir as tmpdir4 } from "os";
5902
- import { join as join25 } from "path";
5917
+ import { join as join27 } from "path";
5903
5918
 
5904
5919
  // 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";
5920
+ import { spawnSync as spawnSync6 } from "child_process";
5921
+ 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
5922
  import { StringDecoder } from "string_decoder";
5908
5923
  import { finished } from "stream/promises";
5909
- import { join as join24, dirname as dirname8, basename as basename8 } from "path";
5924
+ import { join as join26, dirname as dirname8, basename as basename8 } from "path";
5910
5925
  import { createHash as createHash6 } from "crypto";
5911
5926
 
5912
5927
  // src/state.ts
@@ -7061,9 +7076,9 @@ async function addRunToTicket(target, t, recordId, run3) {
7061
7076
  }
7062
7077
  }
7063
7078
  var queueDir = (stateDir) => join17(stateDir, "pending-agent-logs");
7064
- function queueAgentRun(stateDir, name, q) {
7079
+ function queueAgentRun(stateDir, name, q2) {
7065
7080
  mkdirSync11(queueDir(stateDir), { recursive: true });
7066
- writeFileSync12(join17(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
7081
+ writeFileSync12(join17(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
7067
7082
  }
7068
7083
  async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
7069
7084
  const dir = queueDir(stateDir);
@@ -7071,23 +7086,23 @@ async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__
7071
7086
  let sent = 0;
7072
7087
  for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
7073
7088
  const path = join17(dir, f);
7074
- let q;
7089
+ let q2;
7075
7090
  try {
7076
- q = JSON.parse(readFileSync16(path, "utf8"));
7091
+ q2 = JSON.parse(readFileSync16(path, "utf8"));
7077
7092
  } catch {
7078
7093
  continue;
7079
7094
  }
7080
- if (q.version !== 1 || q.route !== route2 || now.toISOString() < q.nextAttemptAt) continue;
7095
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
7081
7096
  try {
7082
- const apiKey = await keyFor(q.role);
7097
+ const apiKey = await keyFor(q2.role);
7083
7098
  if (!apiKey) continue;
7084
- const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
7099
+ const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
7085
7100
  if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
7086
7101
  if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
7087
7102
  version: 1,
7088
- route: q.route,
7089
- role: q.role,
7090
- target: q.target,
7103
+ route: q2.route,
7104
+ role: q2.role,
7105
+ target: q2.target,
7091
7106
  write: sentRun.counterRetry,
7092
7107
  attempts: 0,
7093
7108
  nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -7101,22 +7116,22 @@ async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__
7101
7116
  log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
7102
7117
  continue;
7103
7118
  }
7104
- const attempts = q.attempts + 1;
7119
+ const attempts = q2.attempts + 1;
7105
7120
  writeFileSync12(path, JSON.stringify({
7106
- ...q,
7121
+ ...q2,
7107
7122
  attempts,
7108
7123
  warned: true,
7109
7124
  nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
7110
7125
  }));
7111
- if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
7126
+ if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
7112
7127
  }
7113
7128
  }
7114
7129
  return sent;
7115
7130
  }
7116
7131
  var countersDir = (stateDir) => join17(stateDir, "pending-ticket-counters");
7117
- function queueTicketCounters(stateDir, name, q) {
7132
+ function queueTicketCounters(stateDir, name, q2) {
7118
7133
  mkdirSync11(countersDir(stateDir), { recursive: true });
7119
- writeFileSync12(join17(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
7134
+ writeFileSync12(join17(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
7120
7135
  }
7121
7136
  async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
7122
7137
  const dir = countersDir(stateDir);
@@ -7124,34 +7139,34 @@ async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /
7124
7139
  let sent = 0;
7125
7140
  for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
7126
7141
  const path = join17(dir, f);
7127
- let q;
7142
+ let q2;
7128
7143
  try {
7129
- q = JSON.parse(readFileSync16(path, "utf8"));
7144
+ q2 = JSON.parse(readFileSync16(path, "utf8"));
7130
7145
  } catch {
7131
7146
  continue;
7132
7147
  }
7133
- if (q.version !== 1 || q.route !== route2 || now.toISOString() < q.nextAttemptAt) continue;
7148
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
7134
7149
  try {
7135
- const apiKey = await keyFor(q.role);
7150
+ const apiKey = await keyFor(q2.role);
7136
7151
  if (!apiKey) continue;
7137
- await writeCounters({ ...q.target, apiKey }, q.write);
7152
+ await writeCounters({ ...q2.target, apiKey }, q2.write);
7138
7153
  rmSync11(path, { force: true });
7139
7154
  sent += 1;
7140
- log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
7155
+ log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
7141
7156
  } catch (e) {
7142
7157
  if (!isRetryable(e)) {
7143
7158
  rmSync11(path, { force: true });
7144
7159
  log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
7145
7160
  continue;
7146
7161
  }
7147
- const attempts = q.attempts + 1;
7162
+ const attempts = q2.attempts + 1;
7148
7163
  writeFileSync12(path, JSON.stringify({
7149
- ...q,
7164
+ ...q2,
7150
7165
  attempts,
7151
7166
  warned: true,
7152
7167
  nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
7153
7168
  }));
7154
- if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
7169
+ if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
7155
7170
  }
7156
7171
  }
7157
7172
  return sent;
@@ -9936,11 +9951,11 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
9936
9951
  }
9937
9952
 
9938
9953
  // src/runner.ts
9939
- import { spawn as spawn3, execFile, execFileSync as execFileSync6 } from "child_process";
9954
+ import { spawn as spawn3, execFile as execFile2, execFileSync as execFileSync6 } from "child_process";
9940
9955
  import { mkdtempSync, writeFileSync as writeFileSync14, rmSync as rmSync13 } from "fs";
9941
9956
  import { tmpdir as tmpdir3 } from "os";
9942
- import { basename as basename7, join as join23 } from "path";
9943
- import { promisify } from "util";
9957
+ import { basename as basename7, join as join25 } from "path";
9958
+ import { promisify as promisify2 } from "util";
9944
9959
 
9945
9960
  // src/run-limit.ts
9946
9961
  var DEFAULT_MAX_RUN_MINUTES = 240;
@@ -9991,1216 +10006,1712 @@ function runKilledComment(b) {
9991
10006
  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
10007
  }
9993
10008
 
9994
- // src/runner.ts
9995
- var execFileP = promisify(execFile);
9996
- var DEFAULT_SANDBOX_CONFIG = {
9997
- bin: "openshell",
9998
- mirrorDir: "/cache",
9999
- workDir: "/sandbox/work"
10009
+ // src/sandbox-cache.ts
10010
+ import { execFile } from "child_process";
10011
+ import { promisify } from "util";
10012
+
10013
+ // src/deprovision-host.ts
10014
+ import { spawnSync as spawnSync5 } from "child_process";
10015
+ import { existsSync as existsSync23, readdirSync as readdirSync11 } from "fs";
10016
+
10017
+ // src/interrupted.ts
10018
+ import { existsSync as existsSync21, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
10019
+ import { join as join23 } from "path";
10020
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
10021
+ function interruptedComment(b) {
10022
+ 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.";
10023
+ return `${INTERRUPTED_MARKER}
10024
+
10025
+ 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.`;
10026
+ }
10027
+ var handled = /* @__PURE__ */ new Set();
10028
+ var pidAlive = (pid) => {
10029
+ try {
10030
+ process.kill(pid, 0);
10031
+ return true;
10032
+ } catch (e) {
10033
+ return e.code === "EPERM";
10034
+ }
10000
10035
  };
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 } };
10036
+ function runFinished(streamPath) {
10037
+ const path = `${streamPath}.meta.json`;
10038
+ if (!existsSync21(path)) return false;
10039
+ try {
10040
+ return JSON.parse(readFileSync20(path, "utf8")).running !== true;
10041
+ } catch {
10042
+ return true;
10013
10043
  }
10014
- async kill(handle) {
10015
- if (handle.pid !== void 0) await killRunGroup(handle.pid);
10044
+ }
10045
+ function orphanedStreams(o) {
10046
+ const dir = join23(o.stateDir, "streams");
10047
+ if (!existsSync21(dir)) return [];
10048
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
10049
+ const out = [];
10050
+ for (const name of readdirSync9(dir)) {
10051
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
10052
+ const streamPath = join23(dir, name);
10053
+ if (runFinished(streamPath)) continue;
10054
+ const sidecar = readRunSidecar(streamPath);
10055
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
10056
+ if (ownerAlive(sidecar.ownerPid)) continue;
10057
+ out.push({ streamPath, sidecar });
10016
10058
  }
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";
10059
+ return out;
10060
+ }
10061
+ async function reconcileRuns(o) {
10062
+ let adopted = 0;
10063
+ let interrupted = 0;
10064
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
10065
+ if (handled.has(streamPath)) continue;
10066
+ handled.add(streamPath);
10067
+ const isAlive = o.isAlive ?? runIsAlive;
10068
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
10069
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
10070
+ const work = (async () => {
10071
+ try {
10072
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
10073
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
10074
+ isAlive: o.isAlive,
10075
+ pollMs: o.pollMs,
10076
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
10077
+ });
10078
+ if (result.adopted) adopted++;
10079
+ else interrupted++;
10080
+ try {
10081
+ await o.afterRun?.(sidecar, result);
10082
+ } catch (e) {
10083
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
10084
+ }
10085
+ } catch (e) {
10086
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
10087
+ } finally {
10088
+ if (lock?.ok) lock.release();
10089
+ if (ticketLock?.ok) ticketLock.release();
10090
+ }
10091
+ })();
10092
+ if (isAlive(sidecar)) {
10093
+ if (o.inFlight) {
10094
+ o.inFlight.add(work);
10095
+ void work.finally(() => o.inFlight.delete(work));
10096
+ }
10097
+ } else {
10098
+ await work;
10024
10099
  }
10025
10100
  }
10026
- reattach(meta) {
10027
- return { runner: "host", pid: meta.pid };
10028
- }
10029
- async release() {
10101
+ return { adopted, interrupted };
10102
+ }
10103
+
10104
+ // src/live-runs.ts
10105
+ import { existsSync as existsSync22, readdirSync as readdirSync10 } from "fs";
10106
+ import { join as join24 } from "path";
10107
+ function liveRuns(stateDir, isAlive = runIsAlive) {
10108
+ const dir = join24(stateDir, "streams");
10109
+ if (!existsSync22(dir)) return [];
10110
+ const out = [];
10111
+ for (const name of readdirSync10(dir)) {
10112
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
10113
+ const streamPath = join24(dir, name);
10114
+ if (runFinished(streamPath)) continue;
10115
+ const sidecar = readRunSidecar(streamPath);
10116
+ if (!sidecar || !isAlive(sidecar)) continue;
10117
+ out.push({ ...sidecar, streamPath });
10030
10118
  }
10031
- };
10032
- function shQuote2(s) {
10033
- return `'${s.replace(/'/g, `'\\''`)}'`;
10119
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
10034
10120
  }
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, "-");
10121
+ function siblingRuns(runs, ownTicketRecordId) {
10122
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
10038
10123
  }
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 };
10124
+ async function settleRunEnd(o) {
10125
+ const { tracker, emit } = o;
10126
+ const all = o.runs ?? liveRuns(o.stateDir);
10127
+ const slug2 = routeSlug(o.route.route);
10128
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
10129
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
10130
+ const newest = (rs) => rs[rs.length - 1];
10131
+ if (o.memberId) {
10081
10132
  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 } : {} };
10133
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
10134
+ else await tracker.setCrewStatus(o.memberId, "idle");
10109
10135
  } catch (e) {
10110
- await this.release(handle, o.emit);
10111
- throw new Error(`could not prepare the sandbox ${name}: ${describeExecError(e)}`);
10136
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
10112
10137
  }
10113
10138
  }
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 {
10139
+ try {
10140
+ if (shipSiblings.length) {
10141
+ const n = newest(shipSiblings);
10142
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
10143
+ } else {
10144
+ await tracker.beatIdle(o.shipName);
10134
10145
  }
10146
+ } catch (e) {
10147
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
10135
10148
  }
10136
- };
10137
- function describeExecError(e) {
10138
- const err3 = e;
10139
- return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10140
10149
  }
10141
- function gitOrigin(cwd) {
10150
+ function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
10142
10151
  try {
10143
- return execFileSync6("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
10152
+ return worktreeForNumber(d, n)?.path ?? null;
10144
10153
  } catch {
10145
10154
  return null;
10146
10155
  }
10156
+ }) {
10157
+ const ownRoute = run3.route === routeSlug(route2.route);
10158
+ const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
10159
+ const num = run3.ticket?.replace(/^\D+/, "") ?? "";
10160
+ return {
10161
+ role: run3.role,
10162
+ seat: ownRoute ? seatIds[run3.role] ?? null : null,
10163
+ ticket: run3.ticket,
10164
+ ticketRecordId: run3.ticketRecordId,
10165
+ repo: run3.repo ?? null,
10166
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
10167
+ stream: run3.streamPath,
10168
+ pid: run3.pid,
10169
+ startedAt: run3.spawnedAt,
10170
+ deadline: run3.deadline ?? null
10171
+ };
10147
10172
  }
10148
- function runnerFor(kind, sandbox) {
10149
- return kind === "sandbox" ? new SandboxRunner(sandbox) : new HostRunner();
10173
+
10174
+ // src/seat-keys.ts
10175
+ import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
10176
+ var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
10177
+ function keyIsAdmin(route2) {
10178
+ const role = route2.resolved?.role;
10179
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
10150
10180
  }
10151
- function runnerOfSidecar(sc) {
10152
- return sc.runner === "sandbox" ? new SandboxRunner(sc.sandboxBin ? { bin: sc.sandboxBin } : {}) : new HostRunner();
10181
+ 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";
10182
+ var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
10183
+ 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)';
10184
+ function seatKeySlug(workspaceSlug, agentId) {
10185
+ return `${workspaceSlug}#agent:${agentId}`;
10153
10186
  }
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;
10187
+ function slugFor(route2, agentId) {
10188
+ const [workspaceSlug] = splitRoute(route2.route);
10189
+ return seatKeySlug(workspaceSlug, agentId);
10190
+ }
10191
+ function seatAgentId(route2, role) {
10192
+ return route2.resolved?.agentPersonas?.[role]?.agentId;
10193
+ }
10194
+ async function resolveReleaseKey(route2, getStore = getSessionStore) {
10195
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10196
+ const agentId = route2.resolved?.releaseAgentId;
10197
+ if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
10157
10198
  try {
10158
- version = run3(bin, ["--version"]).trim();
10199
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10200
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10201
+ return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10159
10202
  } catch (e) {
10160
- return { ok: false, detail: `${bin} is not installed or not on the scheduler PATH (${e.message.split("\n")[0]})` };
10203
+ const reason = describeReadError(e);
10204
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10161
10205
  }
10206
+ }
10207
+ async function resolveSeatKey(route2, role, getStore = getSessionStore) {
10208
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
10209
+ if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
10210
+ const agentId = seatAgentId(route2, role);
10211
+ if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
10162
10212
  try {
10163
- run3(bin, ["status"]);
10213
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
10214
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
10215
+ return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
10164
10216
  } 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
- };
10217
+ const reason = describeReadError(e);
10218
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10170
10219
  }
10171
- return { ok: true, detail: version };
10172
10220
  }
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;
10221
+ function safeStderrLine(e) {
10222
+ const err3 = e;
10223
+ const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
10224
+ for (const line of raw.split("\n")) {
10225
+ const t = line.trim();
10226
+ if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
10227
+ return t.slice(0, 120);
10228
+ }
10229
+ return void 0;
10182
10230
  }
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");
10231
+ function describeReadError(e) {
10232
+ const fsErr = e;
10233
+ if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
10234
+ const code = e?.code;
10235
+ const line = safeStderrLine(e);
10236
+ const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
10237
+ const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
10238
+ return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
10188
10239
  }
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;
10240
+ function keychainExitCode(reason) {
10241
+ const m = /security exited (\d+)/.exec(reason);
10242
+ return m ? Number(m[1]) : void 0;
10243
+ }
10244
+ function describeStoreError(e) {
10245
+ const msg = e instanceof Error ? e.message : String(e);
10246
+ if (/User interaction is not allowed/i.test(msg)) {
10247
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
10248
+ }
10249
+ return "keychain unavailable (could not write to the OS keychain)";
10250
+ }
10251
+ async function mintMissingSeatKeys(route2, o) {
10252
+ const out = [];
10253
+ if (!route2.resolved) return out;
10254
+ const resolved = route2.resolved;
10255
+ const doFetch = o.fetchImpl ?? fetch;
10256
+ const targets = [
10257
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
10258
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
10259
+ ];
10260
+ let store;
10200
10261
  try {
10201
- raw = readFileSync20(streamPath, "utf8");
10202
- } catch {
10203
- return false;
10262
+ store = (o.getStore ?? getSessionStore)();
10263
+ } catch (e) {
10264
+ return targets.map(({ role }) => ({
10265
+ role,
10266
+ action: "failed",
10267
+ detail: describeStoreError(e)
10268
+ }));
10204
10269
  }
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;
10270
+ const host = hostFromUrl2(route2.baseUrl);
10271
+ const base = route2.baseUrl.replace(/\/+$/, "");
10272
+ if (!o.dryRun) {
10273
+ const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
10209
10274
  try {
10210
- ev = JSON.parse(line);
10211
- } catch {
10275
+ await store.set(host, probeSlug, {
10276
+ apiKey: "probe",
10277
+ workspaceId: resolved.workspaceId,
10278
+ identityId: "_probe",
10279
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
10280
+ });
10281
+ await store.clear(host, probeSlug);
10282
+ } catch (e) {
10283
+ const detail = `${describeStoreError(e)}; no keys minted`;
10284
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
10285
+ }
10286
+ }
10287
+ for (const { role, agentId } of targets) {
10288
+ if (!agentId) {
10289
+ out.push({ role, action: "no_agent" });
10212
10290
  continue;
10213
10291
  }
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;
10292
+ const slug2 = slugFor(route2, agentId);
10293
+ try {
10294
+ if ((await store.get(host, slug2))?.apiKey) {
10295
+ out.push({ role, action: "already_stored" });
10296
+ continue;
10297
+ }
10298
+ if (o.dryRun) {
10299
+ out.push({ role, action: "minted", detail: "dry run" });
10300
+ continue;
10301
+ }
10302
+ const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
10303
+ method: "POST",
10304
+ headers: {
10305
+ Authorization: `Bearer ${resolveApiKey(route2)}`,
10306
+ "Content-Type": "application/json",
10307
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
10308
+ },
10309
+ body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
10310
+ });
10311
+ if (res.status === 403) {
10312
+ out.push({ role, action: "forbidden" });
10313
+ break;
10314
+ }
10315
+ if (!res.ok) {
10316
+ out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
10317
+ continue;
10318
+ }
10319
+ const body = await res.json();
10320
+ if (!body.key) {
10321
+ out.push({ role, action: "failed", detail: "response carried no key" });
10322
+ continue;
10323
+ }
10324
+ try {
10325
+ const session = {
10326
+ apiKey: body.key,
10327
+ workspaceId: resolved.workspaceId,
10328
+ identityId: agentId,
10329
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10330
+ ...body.id ? { keyId: body.id } : {}
10331
+ };
10332
+ await store.set(host, slug2, session);
10333
+ } catch (e) {
10334
+ let revoked = false;
10335
+ if (body.id) {
10336
+ try {
10337
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
10338
+ method: "DELETE",
10339
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10340
+ });
10341
+ revoked = del2.ok;
10342
+ } catch {
10343
+ revoked = false;
10344
+ }
10345
+ }
10346
+ out.push({
10347
+ role,
10348
+ action: "failed",
10349
+ 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`
10350
+ });
10351
+ continue;
10352
+ }
10353
+ out.push({ role, action: "minted" });
10354
+ } catch (e) {
10355
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
10220
10356
  }
10221
10357
  }
10222
- return false;
10358
+ return out;
10223
10359
  }
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`);
10229
- }
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");
10360
+ function describeMintOutcome(o) {
10361
+ switch (o.action) {
10362
+ case "minted":
10363
+ return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
10364
+ case "already_stored":
10365
+ return `${o.role}: agent key already stored`;
10366
+ case "no_agent":
10367
+ return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
10368
+ case "forbidden":
10369
+ return FORBIDDEN_LINE;
10370
+ case "failed":
10371
+ return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
10241
10372
  }
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;
10248
- try {
10249
- expanded = expandSkillMarkers(
10250
- `${effectiveCommon}${effectiveBrief}`,
10251
- (name) => o.skillOverrides?.[name] ?? localSkills.get(name)
10252
- );
10253
- } catch (e) {
10254
- if (e instanceof SkillIncludeError) throw new AgentError(`${e.message} \u2014 refusing to run an unscoped session`);
10255
- throw e;
10373
+ }
10374
+ async function revokeSeatKeys(route2, o) {
10375
+ const out = [];
10376
+ if (!route2.resolved) return out;
10377
+ const resolved = route2.resolved;
10378
+ const doFetch = o.fetchImpl ?? fetch;
10379
+ const targets = [
10380
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role) })),
10381
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
10382
+ ];
10383
+ let store;
10384
+ try {
10385
+ store = (o.getStore ?? getSessionStore)();
10386
+ } catch (e) {
10387
+ return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
10256
10388
  }
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\`)`);
10389
+ const host = hostFromUrl2(route2.baseUrl);
10390
+ const base = route2.baseUrl.replace(/\/+$/, "");
10391
+ for (const { role, agentId } of targets) {
10392
+ if (!agentId) {
10393
+ out.push({ role, action: "no_agent" });
10394
+ continue;
10273
10395
  }
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\`)`);
10396
+ const slug2 = slugFor(route2, agentId);
10397
+ try {
10398
+ const session = await store.get(host, slug2);
10399
+ if (!session?.apiKey) {
10400
+ out.push({ role, action: "not_stored" });
10401
+ continue;
10402
+ }
10403
+ if (!session.keyId) {
10404
+ 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" });
10405
+ continue;
10406
+ }
10407
+ if (o.dryRun) {
10408
+ out.push({ role, action: "revoked", detail: "dry run" });
10409
+ continue;
10410
+ }
10411
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
10412
+ method: "DELETE",
10413
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10414
+ });
10415
+ if (!del2.ok && del2.status !== 404) {
10416
+ out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
10417
+ continue;
10418
+ }
10419
+ await store.clear(host, slug2);
10420
+ out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
10421
+ } catch (e) {
10422
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
10276
10423
  }
10277
- toolchain = { ...toolchain, inference: { baseUrl: inferenceBaseUrl(o.route.baseUrl, o.route.resolved.workspaceId), token: o.apiKey } };
10278
10424
  }
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
- };
10425
+ return out;
10361
10426
  }
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");
10427
+ function describeRevokeOutcome(o) {
10428
+ switch (o.action) {
10429
+ case "revoked":
10430
+ return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
10431
+ case "not_stored":
10432
+ return `${o.role}: no agent key stored on this ship`;
10433
+ case "no_agent":
10434
+ return `${o.role}: no linked Agents row`;
10435
+ case "failed":
10436
+ return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
10437
+ }
10379
10438
  }
10380
- var pidPathFor = (streamPath) => `${streamPath}.pid`;
10381
- function readRunSidecar(streamPath) {
10439
+ async function canMintAgentKeys(route2, o = {}) {
10440
+ const resolved = route2.resolved;
10441
+ if (!resolved) return void 0;
10442
+ const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
10443
+ if (!agentId) return void 0;
10382
10444
  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;
10445
+ const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
10446
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
10447
+ });
10448
+ if (res.ok) return true;
10449
+ return res.status === 403 ? false : void 0;
10385
10450
  } catch {
10386
- return null;
10451
+ return void 0;
10387
10452
  }
10388
10453
  }
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;
10454
+
10455
+ // src/deprovision-host.ts
10456
+ var DOCKER_LABEL_OWNER = "crew.owner=crew";
10457
+ var DOCKER_LABEL_CACHE = "crew.kind=cache";
10458
+ var DOCKER_LABEL_REPO_LAYER = "crew.layer=repo";
10459
+ function docker(args) {
10460
+ const res = spawnSync5("docker", args, { encoding: "utf8" });
10461
+ if (res.error || res.status !== 0) throw new Error(`docker ${args[0]} ${args[1] ?? ""} failed: ${(res.stderr || String(res.error ?? "")).trim().split("\n").pop()}`);
10462
+ return res.stdout;
10399
10463
  }
10400
- function identityOf(plan, adapterHarness) {
10464
+ var lines = (s) => s.split("\n").map((l) => l.trim()).filter(Boolean);
10465
+ function labeledDocker() {
10466
+ if (!dockerAvailable()) return { volumes: [], images: [] };
10401
10467
  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
10468
+ volumes: lines(docker(["volume", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_CACHE}`])),
10469
+ images: lines(docker(["image", "ls", "-q", "--filter", `label=${DOCKER_LABEL_OWNER}`, "--filter", `label=${DOCKER_LABEL_REPO_LAYER}`]))
10414
10470
  };
10415
10471
  }
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);
10440
- }
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;
10450
- try {
10451
- fd = openSync3(this.path, "r");
10452
- } catch {
10453
- return "";
10472
+ function realDeprovisionDeps(cfg, emit, crewHome, getStore) {
10473
+ const stateDir = cfg.ship.stateDir;
10474
+ const forgeHosts = () => Object.keys(cfg.ship.forges ?? {});
10475
+ const routeFor = (label) => cfg.routes.find((r) => routeSlug(r.route) === label);
10476
+ const sandboxes = () => dockerAvailable() ? listPassengerContainers().map((c) => c.name) : [];
10477
+ const unitPaths = () => {
10478
+ const host = hostPlatform();
10479
+ if (host === "windows") return [];
10480
+ const ctx = installContext();
10481
+ const systemd = detectSystemd();
10482
+ return ["run", "release", "passengers"].flatMap((job) => planInstall(cfg.ship, crewHome, host, systemd, job, ctx).unitPaths).filter((p) => existsSync23(p));
10483
+ };
10484
+ return {
10485
+ liveRuns: () => liveRuns(stateDir).map((r) => ({ role: r.role, route: r.route, pid: r.pid, ticketRecordId: r.ticketRecordId ?? void 0, streamPath: r.streamPath })),
10486
+ killRun: (run3) => killRunGroup(run3.pid),
10487
+ recordKill(run3) {
10488
+ emit.emit(`run_killed: ${run3.role} run on ${run3.route} stopped by crew deprovision`, {
10489
+ step: "agent",
10490
+ role: run3.role,
10491
+ data: { event: "run_killed", reason: "deprovision", pid: run3.pid }
10492
+ });
10493
+ },
10494
+ async handBack(run3) {
10495
+ const route2 = routeFor(run3.route);
10496
+ if (!run3.ticketRecordId || !route2) return;
10497
+ const memberId = route2.resolved?.seats[run3.role];
10498
+ if (!memberId) return;
10499
+ const identity = await resolveSeatKey(route2, run3.role, getStore);
10500
+ const tracker = new Tracker(route2, cfg.ship, { apiKey: identity.apiKey });
10501
+ const t = await tracker.ticket(run3.ticketRecordId);
10502
+ const target = repoTargetFor(route2, t.repo_id);
10503
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
10504
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
10505
+ let dirty = null;
10506
+ try {
10507
+ if (wt) dirty = status(wt.path).length > 0;
10508
+ } catch {
10509
+ }
10510
+ await tracker.postEvent(run3.ticketRecordId, interruptedComment({
10511
+ branch: wt?.branch ?? null,
10512
+ dirty,
10513
+ reason: "the host was released and wiped"
10514
+ }), memberId, "interrupted");
10515
+ },
10516
+ sandboxes,
10517
+ removeSandbox: (name) => {
10518
+ stopContainer(name);
10519
+ },
10520
+ async clearShipRows() {
10521
+ const out = [];
10522
+ for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
10523
+ try {
10524
+ const found = await new Tracker(r, cfg.ship).clearForRelease(cfg.ship.name);
10525
+ out.push({ route: r.route, ok: true, detail: found ? "cleared engaged, tunnel_status, relay_url" : "no Ships row for this ship" });
10526
+ } catch (e) {
10527
+ out.push({ route: r.route, ok: false, detail: e.message });
10528
+ }
10529
+ }
10530
+ return out;
10531
+ },
10532
+ forgeHosts,
10533
+ eraseForgeToken: (host) => eraseForgeToken(cfg.ship.forges, host, stateDir),
10534
+ labeled: labeledDocker,
10535
+ removeVolume: (name) => {
10536
+ docker(["volume", "rm", "-f", name]);
10537
+ },
10538
+ removeImage: (name) => {
10539
+ docker(["image", "rm", "-f", name]);
10540
+ },
10541
+ remaining() {
10542
+ const left = [];
10543
+ if (existsSync23(cfg.configFile)) left.push(`config ${cfg.configFile}`);
10544
+ for (const h of forgeHosts()) if (hasForgeToken(cfg.ship.forges, h, stateDir)) left.push(`forge token for ${h}`);
10545
+ try {
10546
+ for (const k of readdirSync11(keysDir(stateDir))) left.push(`seat key ${k}`);
10547
+ } catch {
10548
+ }
10549
+ for (const s of sandboxes()) left.push(`sandbox ${s}`);
10550
+ try {
10551
+ const { volumes, images } = labeledDocker();
10552
+ for (const v of volumes) left.push(`volume ${v}`);
10553
+ for (const i of images) left.push(`image ${i}`);
10554
+ } catch (e) {
10555
+ left.push(`docker could not be checked (${e.message})`);
10556
+ }
10557
+ for (const u of unitPaths()) left.push(`unit ${u}`);
10558
+ if (loadedCrewLabels().length) left.push(`loaded units ${loadedCrewLabels().join(", ")}`);
10559
+ return left;
10454
10560
  }
10561
+ };
10562
+ }
10563
+
10564
+ // src/sandbox-cache.ts
10565
+ var execFileP = promisify(execFile);
10566
+ var realExec2 = (bin, args, o) => execFileP(bin, args, { env: o?.env ?? process.env, maxBuffer: 16 * 1024 * 1024 });
10567
+ var OPENSHELL_ATTACHABLE_LABEL = "openshell.ai/sandbox-attachable=true";
10568
+ var OPENSHELL_WORKSPACE_LABEL = "openshell.ai/sandbox-attachable-workspace";
10569
+ var VOLUME_ROOT = "/cache";
10570
+ var DEFAULT_CACHE_SETTINGS = {
10571
+ engine: "docker",
10572
+ workspace: "crew",
10573
+ helperImage: "alpine/git:latest",
10574
+ depsLockfile: "pnpm-lock.yaml"
10575
+ };
10576
+ function parseRemote(url) {
10577
+ const u = url.trim();
10578
+ const m = /^[a-z+]+:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(u) ?? /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+?)(?:\.git)?\/?$/.exec(u);
10579
+ if (!m) return null;
10580
+ return { host: normalizeForgeHost(m[1]) || DEFAULT_FORGE_HOST, path: m[2].replace(/^\/+/, "") };
10581
+ }
10582
+ function mirrorRelPath(r) {
10583
+ return `mirrors/${r.host}/${r.path}.git`;
10584
+ }
10585
+ function depsRelDir(r) {
10586
+ return `cache/deps/${r.host}/${r.path}`;
10587
+ }
10588
+ var q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
10589
+ function cloneScript(o) {
10590
+ const mirror = `${o.mirrorDir}/${mirrorRelPath(o.repo)}`;
10591
+ const depsRoot = `${o.mirrorDir}/${depsRelDir(o.repo)}`;
10592
+ return [
10593
+ `git clone --quiet --shared ${q(mirror)} ${q(o.workDir)}`,
10594
+ `cd ${q(o.workDir)}`,
10595
+ `git remote set-url origin ${q(o.origin)}`,
10596
+ `git checkout --quiet -b ${q(o.branch)}`,
10597
+ // A missing lockfile or tree is not an error: the run installs as it would without a cache.
10598
+ `{ 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; }`
10599
+ ].join(" && ");
10600
+ }
10601
+ var REFRESH_SCRIPT = `
10602
+ set -e
10603
+ m=${VOLUME_ROOT}/"$1"; url="$2"
10604
+ export GIT_TERMINAL_PROMPT=0
10605
+ git config --global --add safe.directory '*'
10606
+ if [ -n "$CREW_FORGE_TOKEN" ]; then
10607
+ H="Authorization: Basic $(printf '%s:%s' "$CREW_FORGE_USER" "$CREW_FORGE_TOKEN" | base64 | tr -d '\\n')"
10608
+ git() { command git -c "http.extraHeader=$H" "$@"; }
10609
+ fi
10610
+ if [ -d "$m" ]; then
10611
+ git -C "$m" fetch --prune --atomic --quiet origin
10612
+ else
10613
+ mkdir -p "$(dirname "$m")"; rm -rf "$m.tmp"
10614
+ git clone --mirror --quiet "$url" "$m.tmp"
10615
+ git -C "$m.tmp" config gc.auto 0
10616
+ mv "$m.tmp" "$m"
10617
+ fi
10618
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$m/crew-last-refresh"
10619
+ `;
10620
+ var DEPS_SCRIPT = `
10621
+ set -e
10622
+ m=${VOLUME_ROOT}/"$1"; droot=${VOLUME_ROOT}/"$2"; lock="$3"
10623
+ export GIT_TERMINAL_PROMPT=0
10624
+ git config --global --add safe.directory '*'
10625
+ git -C "$m" cat-file -e HEAD:"$lock" 2>/dev/null || exit 0
10626
+ h=$(git -C "$m" show HEAD:"$lock" | sha256sum | cut -c1-16)
10627
+ [ -d "$droot/$h" ] && exit 0
10628
+ w=$(mktemp -d); trap 'rm -rf "$w"' EXIT
10629
+ git clone --quiet --shared "$m" "$w/src"
10630
+ cd "$w/src"
10631
+ sh -c "$CREW_DEPS_COMMAND"
10632
+ mkdir -p "$droot/$h.tmp"
10633
+ find . -name node_modules -type d -prune | while read -r p; do
10634
+ mkdir -p "$droot/$h.tmp/$(dirname "$p")"; cp -R -P "$p" "$droot/$h.tmp/$p"
10635
+ done
10636
+ rm -rf "$droot/$h"; mv "$droot/$h.tmp" "$droot/$h"
10637
+ `;
10638
+ var SandboxCache = class {
10639
+ s;
10640
+ exec;
10641
+ constructor(s, exec = realExec2) {
10642
+ this.s = s;
10643
+ this.exec = exec;
10644
+ }
10645
+ engine(args, env) {
10646
+ return this.exec(this.s.engine, args, { env });
10647
+ }
10648
+ /** Creates the volume with the labels OpenShell and `crew deprovision` look for, if it is not there. */
10649
+ async ensureVolume() {
10455
10650
  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);
10651
+ await this.engine(["volume", "inspect", this.s.volume]);
10652
+ return false;
10653
+ } catch {
10467
10654
  }
10655
+ await this.engine([
10656
+ "volume",
10657
+ "create",
10658
+ "--label",
10659
+ OPENSHELL_ATTACHABLE_LABEL,
10660
+ "--label",
10661
+ `${OPENSHELL_WORKSPACE_LABEL}=${this.s.workspace}`,
10662
+ "--label",
10663
+ DOCKER_LABEL_OWNER,
10664
+ "--label",
10665
+ DOCKER_LABEL_CACHE,
10666
+ this.s.volume
10667
+ ]);
10668
+ return true;
10669
+ }
10670
+ helper(image, script, args, env = {}) {
10671
+ const flags = Object.keys(env).flatMap((k) => ["-e", k]);
10672
+ 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
10673
  }
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
10674
  /**
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.
10675
+ * Refreshes every mirror (and, where configured, its dependency tree). One
10676
+ * repository failing does not stop the others; the caller raises attention
10677
+ * for the failures and the runs keep using the previous mirror state.
10485
10678
  */
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);
10679
+ async refresh(repos) {
10680
+ await this.ensureVolume();
10681
+ const out = [];
10682
+ for (const r of repos) {
10683
+ const env = r.token ? { CREW_FORGE_TOKEN: r.token, CREW_FORGE_USER: r.username ?? DEFAULT_FORGE_USERNAME } : {};
10684
+ try {
10685
+ await this.helper(this.s.helperImage, REFRESH_SCRIPT, [mirrorRelPath(r), forgeDescriptorFor(r.host).httpsUrl(r.host, r.path)], env);
10686
+ } catch (e) {
10687
+ out.push({ repo: r, ok: false, detail: describe(e) });
10688
+ continue;
10689
+ }
10690
+ if (this.s.depsCommand && this.s.depsImage) {
10691
+ try {
10692
+ await this.helper(this.s.depsImage, DEPS_SCRIPT, [mirrorRelPath(r), depsRelDir(r), this.s.depsLockfile], { CREW_DEPS_COMMAND: this.s.depsCommand });
10693
+ } catch (e) {
10694
+ out.push({ repo: r, ok: false, detail: `dependency tree: ${describe(e)}` });
10695
+ continue;
10696
+ }
10697
+ }
10698
+ out.push({ repo: r, ok: true });
10699
+ }
10700
+ return out;
10508
10701
  }
10509
- end() {
10510
- if (this.buf) this.handleLine(this.buf);
10511
- this.buf = "";
10702
+ /** Mirrors in the volume with their last refresh; empty when the volume does not exist. */
10703
+ async list() {
10512
10704
  try {
10513
- this.consume(void 0, this.mapper.flush());
10705
+ await this.engine(["volume", "inspect", this.s.volume]);
10514
10706
  } catch {
10707
+ return [];
10708
+ }
10709
+ 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`;
10710
+ const { stdout } = await this.helper(this.s.helperImage, script, []);
10711
+ const rows = [];
10712
+ for (const line of stdout.split("\n").filter(Boolean)) {
10713
+ const [rel, when] = line.split(" ");
10714
+ const m = /^([^/]+)\/(.+)\.git$/.exec(rel ?? "");
10715
+ if (m) rows.push({ repo: { host: m[1], path: m[2] }, lastRefresh: when?.trim() || null });
10515
10716
  }
10717
+ return rows;
10516
10718
  }
10517
- handleLine(line) {
10518
- if (!line) return;
10519
- let parsed;
10520
- try {
10521
- parsed = JSON.parse(line);
10522
- } catch {
10523
- return;
10719
+ /** Drops mirrors (and their dependency trees) for repositories not in `keep`. Returns what was dropped. */
10720
+ async prune(keep) {
10721
+ const have = new Set(keep.map((r) => `${r.host}/${r.path}`));
10722
+ const drop = (await this.list()).map((m) => m.repo).filter((r) => !have.has(`${r.host}/${r.path}`));
10723
+ for (const r of drop) {
10724
+ await this.helper(this.s.helperImage, `rm -rf ${VOLUME_ROOT}/${q(mirrorRelPath(r))} ${VOLUME_ROOT}/${q(depsRelDir(r))}`, []);
10524
10725
  }
10726
+ return drop;
10727
+ }
10728
+ /** Recreates the volume empty; the next pass refreshes every mirror. */
10729
+ async reset() {
10525
10730
  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 {
10731
+ await this.engine(["volume", "rm", "--force", this.s.volume]);
10732
+ } catch (e) {
10733
+ throw new Error(`could not remove volume ${this.s.volume}: ${describe(e)}`);
10534
10734
  }
10735
+ await this.ensureVolume();
10535
10736
  }
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
- }
10737
+ };
10738
+ function describe(e) {
10739
+ const err3 = e;
10740
+ return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10741
+ }
10742
+ function cacheSettingsOf(cfg) {
10743
+ if (!cfg?.cacheVolume) return null;
10744
+ return {
10745
+ engine: cfg.engine ?? DEFAULT_CACHE_SETTINGS.engine,
10746
+ volume: cfg.cacheVolume,
10747
+ workspace: cfg.cacheWorkspace ?? DEFAULT_CACHE_SETTINGS.workspace,
10748
+ helperImage: cfg.cacheImage ?? DEFAULT_CACHE_SETTINGS.helperImage,
10749
+ depsImage: cfg.depsImage ?? cfg.image,
10750
+ depsCommand: cfg.depsCommand,
10751
+ depsLockfile: cfg.depsLockfile ?? DEFAULT_CACHE_SETTINGS.depsLockfile
10752
+ };
10753
+ }
10754
+ var sandboxCacheKey = (volume, r) => `sandbox_cache:${volume}:${r.host}/${r.path}`;
10755
+ var CACHE_REFRESH_INTERVAL_MS = 6e4;
10756
+ function makeCachePass(o) {
10757
+ const now = o.now ?? Date.now;
10758
+ let last = -Infinity;
10759
+ return async () => {
10760
+ if (now() - last < CACHE_REFRESH_INTERVAL_MS) return [];
10761
+ last = now();
10762
+ const all = [];
10763
+ for (const r of o.repos()) {
10764
+ let release;
10765
+ try {
10766
+ release = o.lock(r.dir);
10767
+ all.push(...await o.cache.refresh([r]));
10768
+ } catch (e) {
10769
+ all.push({ repo: r, ok: false, detail: describe(e) });
10770
+ } finally {
10771
+ release?.();
10603
10772
  }
10604
10773
  }
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
- }
10774
+ for (const out of all) {
10775
+ const key = sandboxCacheKey(o.volume, out.repo);
10776
+ if (out.ok) o.ok(key);
10777
+ 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`);
10778
+ }
10779
+ return all;
10780
+ };
10781
+ }
10782
+
10783
+ // src/runner.ts
10784
+ var execFileP2 = promisify2(execFile2);
10785
+ var DEFAULT_SANDBOX_CONFIG = {
10786
+ bin: "openshell",
10787
+ mirrorDir: "/cache",
10788
+ workDir: "/sandbox/work"
10609
10789
  };
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 {
10790
+ var SANDBOX_STDIN_LIMIT = 4 * 1024 * 1024 - 64 * 1024;
10791
+ var SANDBOX_PROMPT_PATH = "/sandbox/prompt.txt";
10792
+ var HostRunner = class {
10793
+ kind = "host";
10794
+ start(plan, o) {
10795
+ const child = spawn3(plan.bin, plan.args, {
10796
+ cwd: plan.cwd,
10797
+ env: o.env,
10798
+ stdio: ["pipe", o.streamFd, "inherit"],
10799
+ detached: true
10800
+ });
10801
+ return { child, handle: { runner: "host", pid: child.pid } };
10627
10802
  }
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
- }
10803
+ async kill(handle) {
10804
+ if (handle.pid !== void 0) await killRunGroup(handle.pid);
10636
10805
  }
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) {
10806
+ isAlive(handle) {
10807
+ if (handle.pid === void 0) return false;
10642
10808
  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
- });
10809
+ process.kill(handle.pid, 0);
10810
+ return true;
10655
10811
  } catch (e) {
10656
- emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
10812
+ return e.code === "EPERM";
10657
10813
  }
10658
10814
  }
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 {
10815
+ reattach(meta) {
10816
+ return { runner: "host", pid: meta.pid };
10673
10817
  }
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 } : {}
10818
+ async release() {
10819
+ }
10820
+ };
10821
+ function shQuote2(s) {
10822
+ return `'${s.replace(/'/g, `'\\''`)}'`;
10823
+ }
10824
+ function sandboxName(plan, now = Date.now()) {
10825
+ const base = [plan.route, plan.role, plan.ticket ?? ""].join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10826
+ return `crew-${base.slice(0, 36)}-${now.toString(36)}`.replace(/-+/g, "-");
10827
+ }
10828
+ var SandboxRunner = class {
10829
+ kind = "sandbox";
10830
+ cfg;
10831
+ /** Injected for tests; the real one reads the plan's checkout. */
10832
+ originOf;
10833
+ constructor(cfg = {}, originOf = gitOrigin) {
10834
+ this.cfg = { ...DEFAULT_SANDBOX_CONFIG, ...cfg };
10835
+ this.originOf = originOf;
10836
+ }
10837
+ os(args, o = {}) {
10838
+ return execFileP2(this.cfg.bin, args, { env: o.env ?? process.env, maxBuffer: 16 * 1024 * 1024 });
10839
+ }
10840
+ async start(plan, o) {
10841
+ const origin = this.originOf(plan.cwd);
10842
+ if (!origin) throw new Error(`cannot find the origin remote of ${plan.cwd}, so there is nothing to clone into the sandbox`);
10843
+ const repo = parseRemote(origin);
10844
+ if (!repo) throw new Error(`cannot read a forge host and path from the origin remote ${origin}`);
10845
+ const name = sandboxName(plan);
10846
+ const { cfg } = this;
10847
+ const secretKeys = Object.keys(plan.setEnv);
10848
+ if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0 && !secretKeys.includes(cfg.forgeTokenVar)) secretKeys.push(cfg.forgeTokenVar);
10849
+ const createEnv = { ...process.env, ...plan.setEnv };
10850
+ if (cfg.forgeTokenVar && o.env[cfg.forgeTokenVar] !== void 0) createEnv[cfg.forgeTokenVar] = o.env[cfg.forgeTokenVar];
10851
+ const create2 = ["sandbox", "create", "--name", name, "--detach"];
10852
+ if (cfg.policy) create2.push("--policy", cfg.policy);
10853
+ if (cfg.image) create2.push("--from", cfg.image);
10854
+ if (cfg.cacheVolume) {
10855
+ create2.push("--driver-config-json", JSON.stringify({
10856
+ docker: { mounts: [{ type: "volume", source: cfg.cacheVolume, target: cfg.mirrorDir, read_only: true }] }
10857
+ }));
10687
10858
  }
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
- };
10859
+ for (const k of secretKeys) create2.push("--env-from", k);
10860
+ const maxSeconds = Math.max(60, Math.ceil((plan.maxRunMinutes ?? 0) * 60) + 600);
10861
+ create2.push("--", "sleep", String(plan.maxRunMinutes ? maxSeconds : 86400));
10862
+ const t0 = Date.now();
10710
10863
  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
- }
10864
+ await this.os(create2, { env: createEnv });
10731
10865
  } 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) {
10866
+ await this.deleteQuietly(name);
10867
+ throw new Error(`openshell could not create the sandbox: ${describeExecError(e)}`);
10868
+ }
10869
+ o.emit.emit(`sandbox_created: ${name}`, { role: plan.role, data: { event: "sandbox_created", sandbox: name, ms: Date.now() - t0 } });
10870
+ const handle = { runner: "sandbox", sandbox: name, sandboxBin: cfg.bin };
10871
+ try {
10872
+ const branch = `crew-run-${name.slice(-8)}`;
10873
+ const clone = cloneScript({
10874
+ mirrorDir: cfg.mirrorDir,
10875
+ repo,
10876
+ workDir: cfg.workDir,
10877
+ origin,
10878
+ branch,
10879
+ lockfile: cfg.depsLockfile ?? DEFAULT_CACHE_SETTINGS.depsLockfile
10880
+ });
10881
+ await this.os(["sandbox", "exec", "-n", name, "--no-login-shell", "--", "sh", "-c", clone]);
10882
+ const harness = cfg.agentBin ?? basename7(plan.bin);
10883
+ const viaUpload = Buffer.byteLength(plan.prompt) > SANDBOX_STDIN_LIMIT;
10884
+ if (viaUpload) {
10885
+ const tmp = mkdtempSync(join25(tmpdir3(), "crew-prompt-"));
10749
10886
  try {
10750
- await raiseShipAttention(
10751
- { state: new State(stateDirOfStream(id.streamPath)), emit },
10752
- agentLogRefusedItem(id.route, id.role, e.status)
10753
- );
10754
- } catch {
10887
+ const file = join25(tmp, "prompt.txt");
10888
+ writeFileSync14(file, plan.prompt, { mode: 384 });
10889
+ await this.os(["sandbox", "upload", name, file, SANDBOX_PROMPT_PATH]);
10890
+ } finally {
10891
+ rmSync13(tmp, { recursive: true, force: true });
10755
10892
  }
10756
10893
  }
10757
- emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
10894
+ const command2 = viaUpload ? ["sh", "-c", `exec ${[harness, ...plan.args].map(shQuote2).join(" ")} < ${SANDBOX_PROMPT_PATH}`] : [harness, ...plan.args];
10895
+ const child = spawn3(cfg.bin, ["sandbox", "exec", "-n", name, "--no-login-shell", "--workdir", cfg.workDir, "--", ...command2], {
10896
+ env: process.env,
10897
+ stdio: ["pipe", o.streamFd, "inherit"],
10898
+ detached: true
10899
+ });
10900
+ return { child, handle: { ...handle, pid: child.pid }, ...viaUpload ? { promptDelivered: true } : {} };
10901
+ } catch (e) {
10902
+ await this.release(handle, o.emit);
10903
+ throw new Error(`could not prepare the sandbox ${name}: ${describeExecError(e)}`);
10758
10904
  }
10759
10905
  }
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}`));
10906
+ async kill(handle) {
10907
+ if (handle.sandbox) await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10908
+ if (handle.pid !== void 0) await killRunGroup(handle.pid, { graceMs: 2e3 });
10777
10909
  }
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);
10910
+ isAlive(handle) {
10911
+ return new HostRunner().isAlive(handle);
10788
10912
  }
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
- );
10913
+ reattach(meta) {
10914
+ return { runner: "sandbox", pid: meta.pid, sandbox: meta.sandbox, sandboxBin: meta.sandboxBin };
10800
10915
  }
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
- });
10916
+ async release(handle, emit) {
10917
+ if (!handle.sandbox) return;
10918
+ const t0 = Date.now();
10919
+ await this.deleteQuietly(handle.sandbox, handle.sandboxBin);
10920
+ emit.emit(`sandbox_deleted: ${handle.sandbox}`, { data: { event: "sandbox_deleted", sandbox: handle.sandbox, ms: Date.now() - t0 } });
10882
10921
  }
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));
10922
+ async deleteQuietly(name, bin = this.cfg.bin) {
10923
+ try {
10924
+ await execFileP2(bin, ["sandbox", "delete", name], { timeout: 6e4 });
10925
+ } catch {
10907
10926
  }
10908
10927
  }
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";
10928
+ };
10929
+ function describeExecError(e) {
10930
+ const err3 = e;
10931
+ return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
10931
10932
  }
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}`;
10933
+ function gitOrigin(cwd) {
10934
+ try {
10935
+ return execFileSync6("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
10936
+ } catch {
10937
+ return null;
10938
+ }
10937
10939
  }
10938
- function slugFor(route2, agentId) {
10939
- const [workspaceSlug] = splitRoute(route2.route);
10940
- return seatKeySlug(workspaceSlug, agentId);
10940
+ function runnerFor(kind, sandbox) {
10941
+ return kind === "sandbox" ? new SandboxRunner(sandbox) : new HostRunner();
10941
10942
  }
10942
- function seatAgentId(route2, role) {
10943
- return route2.resolved?.agentPersonas?.[role]?.agentId;
10943
+ function runnerOfSidecar(sc) {
10944
+ return sc.runner === "sandbox" ? new SandboxRunner(sc.sandboxBin ? { bin: sc.sandboxBin } : {}) : new HostRunner();
10944
10945
  }
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}`);
10946
+ function checkSandboxRunner(cfg = {}, run3 = (b, a) => execFileSync6(b, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15e3 })) {
10947
+ const bin = cfg.bin ?? DEFAULT_SANDBOX_CONFIG.bin;
10948
+ let version;
10949
10949
  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);
10950
+ version = run3(bin, ["--version"]).trim();
10953
10951
  } catch (e) {
10954
- const reason = describeReadError(e);
10955
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10952
+ return { ok: false, detail: `${bin} is not installed or not on the scheduler PATH (${e.message.split("\n")[0]})` };
10956
10953
  }
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
10954
  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);
10955
+ run3(bin, ["status"]);
10967
10956
  } catch (e) {
10968
- const reason = describeReadError(e);
10969
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
10957
+ const err3 = e;
10958
+ return {
10959
+ ok: false,
10960
+ 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.`
10961
+ };
10970
10962
  }
10963
+ return { ok: true, detail: version };
10971
10964
  }
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);
10979
- }
10980
- return void 0;
10965
+
10966
+ // src/agent.ts
10967
+ var DIGEST_MAX_AGE_SECONDS = 600;
10968
+ var LEGACY_TOOLCHAIN_HARNESS = "claude";
10969
+ var LEGACY_TOOLCHAIN_PROVIDER = "anthropic";
10970
+ function scrubbedEnv(env = process.env, harness = LEGACY_TOOLCHAIN_HARNESS) {
10971
+ const out = { ...env };
10972
+ for (const v of adapterFor(harness).unsetEnv) delete out[v];
10973
+ return out;
10981
10974
  }
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";
10975
+ var CREW_LANE_ROLE_VAR = "CREW_LANE_ROLE";
10976
+ function assemblePrompt(p) {
10977
+ const sections = [p.roster, p.environment, `${p.common}${p.brief}`];
10978
+ if (p.digest) sections.push(p.digest);
10979
+ return sections.join("\n\n");
10990
10980
  }
10991
- function keychainExitCode(reason) {
10992
- const m = /security exited (\d+)/.exec(reason);
10993
- return m ? Number(m[1]) : void 0;
10981
+ var AgentError = class extends Error {
10982
+ };
10983
+ var AgentSpawnError = class extends AgentError {
10984
+ };
10985
+ var WorkspaceAuthRefused = class extends AgentSpawnError {
10986
+ };
10987
+ var WorkspaceAllowanceExhausted = class extends AgentError {
10988
+ };
10989
+ var ALLOWANCE_REFUSAL_MARKER = "AI_ALLOWANCE_EXCEEDED";
10990
+ function streamShowsAllowanceRefusal(streamPath) {
10991
+ let raw;
10992
+ try {
10993
+ raw = readFileSync21(streamPath, "utf8");
10994
+ } catch {
10995
+ return false;
10996
+ }
10997
+ if (!raw.includes(ALLOWANCE_REFUSAL_MARKER)) return false;
10998
+ for (const line of raw.split("\n")) {
10999
+ if (!line.includes(ALLOWANCE_REFUSAL_MARKER)) continue;
11000
+ let ev;
11001
+ try {
11002
+ ev = JSON.parse(line);
11003
+ } catch {
11004
+ continue;
11005
+ }
11006
+ if (typeof ev !== "object" || ev === null) continue;
11007
+ const e = ev;
11008
+ if (e.type === "result" && e.is_error === true) {
11009
+ if (typeof e.result === "string" && e.result.includes(ALLOWANCE_REFUSAL_MARKER)) return true;
11010
+ } else if (e.type === "assistant" && (e.isApiErrorMessage === true || e.error !== void 0)) {
11011
+ if (JSON.stringify(e.message ?? "").includes(ALLOWANCE_REFUSAL_MARKER)) return true;
11012
+ }
11013
+ }
11014
+ return false;
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 digestPathFor = (stateDir, routeLabel, role, runId) => join26(stateDir, `digest-${routeLabel}-${role}${runId ? `-${runId}` : ""}.md`);
11017
+ function planAgentRun(o) {
11018
+ const briefPath = join26(o.route.promptsDir, "personas", `lane-${o.role}.md`);
11019
+ if (!existsSync24(briefPath)) {
11020
+ throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
11021
+ }
11022
+ const commonPath = join26(o.route.promptsDir, "personas", "common.md");
11023
+ const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync24(commonPath);
11024
+ const now = o.now ?? (() => Date.now());
11025
+ const routeLabel = routeSlug(o.route.route);
11026
+ const ownDigest = o.runId ? digestPathFor(o.stateDir, routeLabel, o.role, o.runId) : void 0;
11027
+ const digestPath2 = ownDigest && existsSync24(ownDigest) ? ownDigest : digestPathFor(o.stateDir, routeLabel, o.role);
11028
+ let digest;
11029
+ let ageSeconds;
11030
+ if (existsSync24(digestPath2)) {
11031
+ ageSeconds = Math.floor((now() - statSync7(digestPath2).mtimeMs) / 1e3);
11032
+ if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync21(digestPath2, "utf8");
11033
+ }
11034
+ const common = usesCommon ? readFileSync21(commonPath, "utf8") : "";
11035
+ const brief = readFileSync21(briefPath, "utf8");
11036
+ const effectiveCommon = o.divergedPrompt ? "" : common;
11037
+ const effectiveBrief = appendAdditions(o.divergedPrompt ?? brief, o.personaAdditions);
11038
+ const localSkills = new Map(listLocalSkills(o.route.promptsDir).map((s) => [s.name, s.prompt]));
11039
+ let expanded;
11040
+ try {
11041
+ expanded = expandSkillMarkers(
11042
+ `${effectiveCommon}${effectiveBrief}`,
11043
+ (name) => o.skillOverrides?.[name] ?? localSkills.get(name)
11044
+ );
11045
+ } catch (e) {
11046
+ if (e instanceof SkillIncludeError) throw new AgentError(`${e.message} \u2014 refusing to run an unscoped session`);
11047
+ throw e;
11048
+ }
11049
+ const prompt = assemblePrompt({
11050
+ roster: o.roster,
11051
+ environment: o.environment,
11052
+ common: "",
11053
+ brief: expanded.text,
11054
+ digest
11055
+ });
11056
+ const promptSha = createHash6("sha256").update(expanded.text).digest("hex");
11057
+ const streamsDir = join26(o.stateDir, "streams");
11058
+ const base = `${routeLabel}-${o.role}-${o.cycle}${o.runId ? `-${o.runId}` : ""}`;
11059
+ const seat = resolveSeat(o.ship, o.route, o.role, o.agentPersona ?? (o.agentModel ? { model: o.agentModel } : {}), o.repo);
11060
+ const { model } = seat;
11061
+ let toolchain = seat.toolchain;
11062
+ if (toolchain.auth === "workspace") {
11063
+ if (!o.apiKey || o.seatKind === "ship") {
11064
+ 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\`)`);
11065
+ }
11066
+ if (!o.route.resolved) {
11067
+ throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, but route ${o.route.route} is not connected (run \`crew connect\`)`);
11068
+ }
11069
+ toolchain = { ...toolchain, inference: { baseUrl: inferenceBaseUrl(o.route.baseUrl, o.route.resolved.workspaceId), token: o.apiKey } };
11070
+ }
11071
+ const adapter = adapterFor(toolchain.harness);
11072
+ const invocation = adapter.invocation(toolchain, {
11073
+ role: o.role,
11074
+ model,
11075
+ // Skipped when there's no key to send: a route `crew connect` hasn't
11076
+ // resolved yet has nothing to authenticate the MCP connection with either.
11077
+ mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
11078
+ });
11079
+ const columns = resolveContract(o.route.contract).columns;
11080
+ const agentLog = o.route.resolved && o.apiKey ? {
11081
+ baseUrl: o.route.baseUrl,
11082
+ workspaceId: o.route.resolved.workspaceId,
11083
+ apiKey: o.apiKey,
11084
+ userAgent: o.ship.userAgent,
11085
+ agentId: o.resolvedAgentId,
11086
+ ...o.route.resolved.models?.issues ? { tickets: {
11087
+ issuesModelId: o.route.resolved.models.issues,
11088
+ keyColumn: columns.key,
11089
+ tagColumn: columns.tag,
11090
+ counters: {
11091
+ agentRuns: columns.agentRuns,
11092
+ tokensIn: columns.tokensIn,
11093
+ tokensOut: columns.tokensOut,
11094
+ cacheReadTokens: columns.cacheReadTokens,
11095
+ cacheWriteTokens: columns.cacheWriteTokens,
11096
+ costUsd: columns.costUsd
11097
+ }
11098
+ } } : {}
11099
+ } : void 0;
11100
+ return {
11101
+ role: o.role,
11102
+ route: routeLabel,
11103
+ // The route's directory, which is a STARTING POINT and not the
11104
+ // ticket's repo: the session picks its own working directory even when
11105
+ // the runner already selected a ticket (`o.repo` is known then, and
11106
+ // only feeds the per-repo toolchain choice). Where an area spans
11107
+ // several repos this is whichever one `repos` happened to list first
11108
+ // (config.ts), and the digest's `repo` column is what actually places
11109
+ // the work. Kept deliberately rather than guessed at (ISSUE-349).
11110
+ cwd: o.route.dir,
11111
+ bin: invocation.bin,
11112
+ args: invocation.args,
11113
+ toolchain: toolchain.name,
11114
+ harness: toolchain.harness,
11115
+ workspaceAuth: toolchain.auth === "workspace",
11116
+ provider: toolchainProvider(toolchain),
11117
+ maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
11118
+ ...o.ship.runner === "sandbox" ? { runner: "sandbox", ...o.ship.sandbox ? { sandboxConfig: o.ship.sandbox } : {} } : {},
11119
+ resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
11120
+ warnings: [...seat.warnings ?? [], ...expanded.warnings],
11121
+ prompt,
11122
+ promptBytes: Buffer.byteLength(prompt, "utf8"),
11123
+ unsetEnv: adapter.unsetEnv,
11124
+ setEnv: {
11125
+ ...o.apiKey ? { [API_KEY_VAR]: o.apiKey } : {},
11126
+ ...adapter.runEnv(toolchain),
11127
+ // The operator's global SessionStart hook (pair-context-hook.sh) injects
11128
+ // the interactive Pair persona's prompt for any session it can't tell
11129
+ // apart from a person's own terminal. Every headless lane run this
11130
+ // planner produces carries a real ticket brief already (`prompt`
11131
+ // above) — it must never be replaced by the Pair persona with no
11132
+ // ticket attached, which silently no-ops and strands the ticket
11133
+ // in_progress (ISSUE-490, ISSUE-591). Setting this is what lets that
11134
+ // hook tell the two apart; it has nothing to do with the run itself.
11135
+ [CREW_LANE_ROLE_VAR]: o.role,
11136
+ // CREW-2783: the ship's forge credential — git helper config plus the
11137
+ // token as an env var for `gh` and friends. Empty without `forges:`.
11138
+ ...forgeRunEnv(o.ship.forges, { crewCommand: crewCommandLine(), configFile: process.env.CREW_CONFIG, stateDir: o.stateDir })
11139
+ },
11140
+ digestAttached: digest !== void 0,
11141
+ digestAgeSeconds: ageSeconds,
11142
+ model,
11143
+ streamPath: join26(streamsDir, `${base}.jsonl`),
11144
+ eventsPath: join26(streamsDir, `${base}.events.jsonl`),
11145
+ ticket: o.ticket,
11146
+ ticketRecordId: o.ticketRecordId ?? null,
11147
+ repo: o.repo ?? null,
11148
+ routeName: o.route.route,
11149
+ agentLog,
11150
+ promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
11151
+ promptSha
11152
+ };
11153
+ }
11154
+ function describePlan(p) {
11155
+ return [
11156
+ `role: ${p.role}`,
11157
+ ...p.resolution ? [`resolved: toolchain ${p.toolchain} \u2190 ${p.resolution.toolchain}; model ${p.model} \u2190 ${p.resolution.model}${p.provider ? `; provider ${p.provider}` : ""}`] : [],
11158
+ ...(p.warnings ?? []).map((w) => `warning: ${w}`),
11159
+ // Only for a non-default toolchain, so a plain Claude run's output is unchanged.
11160
+ ...p.toolchain && p.toolchain !== "claude" ? [`toolchain: ${p.toolchain} (${p.harness ?? "claude"})`] : [],
11161
+ `cwd: ${p.cwd}`,
11162
+ `command: ${p.bin} ${p.args.join(" ")}`,
11163
+ `prompt: ${p.promptBytes} bytes on stdin (never argv)`,
11164
+ `digest: ${p.digestAttached ? `attached, ${p.digestAgeSeconds}s old` : "absent \u2014 the agent will fetch the tracker itself"}`,
11165
+ `unset: ${p.unsetEnv.join(" ")}`,
11166
+ // The VALUE is deliberately absent: --dry-run output is pasted into
11167
+ // tickets and chat.
11168
+ `set: ${Object.keys(p.setEnv).join(" ") || "(nothing)"}`,
11169
+ `stream: ${p.streamPath}`
11170
+ ].join("\n");
11171
+ }
11172
+ var pidPathFor = (streamPath) => `${streamPath}.pid`;
11173
+ function readRunSidecar(streamPath) {
11174
+ try {
11175
+ const sc = JSON.parse(readFileSync21(pidPathFor(streamPath), "utf8"));
11176
+ return typeof sc.pid === "number" && typeof sc.spawnedAt === "string" && typeof sc.role === "string" ? sc : null;
11177
+ } catch {
11178
+ return null;
11179
+ }
11180
+ }
11181
+ function runIsAlive(sc) {
11182
+ try {
11183
+ process.kill(sc.pid, 0);
11184
+ } catch (e) {
11185
+ if (e.code !== "EPERM") return false;
11186
+ }
11187
+ const ps = spawnSync6("ps", ["-o", "lstart=", "-p", String(sc.pid)], { encoding: "utf8" });
11188
+ const started = Date.parse(ps.stdout.trim());
11189
+ if (ps.status !== 0 || Number.isNaN(started)) return ps.status === 0;
11190
+ return Math.abs(started - Date.parse(sc.spawnedAt)) < 15e3;
11191
+ }
11192
+ function identityOf(plan, adapterHarness) {
11193
+ return {
11194
+ role: plan.role,
11195
+ route: plan.route,
11196
+ ticket: plan.ticket ?? null,
11197
+ model: plan.model,
11198
+ toolchain: plan.toolchain ?? LEGACY_TOOLCHAIN_HARNESS,
11199
+ harness: adapterHarness,
11200
+ provider: plan.provider ?? LEGACY_TOOLCHAIN_PROVIDER,
11201
+ eventsPath: plan.eventsPath,
11202
+ streamPath: plan.streamPath,
11203
+ agentLog: plan.agentLog ? withoutKey(plan.agentLog) : void 0,
11204
+ promptVersion: plan.promptVersion,
11205
+ promptSha: plan.promptSha
11206
+ };
11207
+ }
11208
+ async function killForDeadline(kill, pid, emit, role, ticket, spawnedAt, limitMinutes) {
11209
+ const elapsedMs = Date.now() - Date.parse(spawnedAt);
11210
+ const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
11211
+ emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
11212
+ role,
11213
+ ...ticket ? { ticket } : {},
11214
+ data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
11215
+ });
11216
+ await kill();
11217
+ return { limitMinutes: limit, elapsedMs };
11218
+ }
11219
+ var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
11220
+ function openSink(path) {
11221
+ try {
11222
+ mkdirSync13(dirname8(path), { recursive: true });
11223
+ return createWriteStream(path, { flags: "a" });
11224
+ } catch {
11225
+ return void 0;
11226
+ }
11227
+ }
11228
+ function endSink(sink) {
11229
+ if (!sink) return Promise.resolve();
11230
+ sink.end();
11231
+ return finished(sink).catch(() => void 0);
11232
+ }
11233
+ var FileTail = class {
11234
+ offset = 0;
11235
+ decoder = new StringDecoder("utf8");
11236
+ path;
11237
+ constructor(path) {
11238
+ this.path = path;
11239
+ }
11240
+ poll() {
11241
+ let fd;
11242
+ try {
11243
+ fd = openSync3(this.path, "r");
11244
+ } catch {
11245
+ return "";
11246
+ }
11247
+ try {
11248
+ let text2 = "";
11249
+ const chunk = Buffer.alloc(64 * 1024);
11250
+ for (; ; ) {
11251
+ const n = readSync(fd, chunk, 0, chunk.length, this.offset);
11252
+ if (n <= 0) break;
11253
+ this.offset += n;
11254
+ text2 += this.decoder.write(chunk.subarray(0, n));
11255
+ }
11256
+ return text2;
11257
+ } finally {
11258
+ closeSync3(fd);
11259
+ }
11260
+ }
11261
+ };
11262
+ var TAIL_POLL_MS = 200;
11263
+ var RunObserver = class {
11264
+ cycles = [];
11265
+ attribution = new TicketAttributionTracker();
11266
+ /** Hand-off writes and their outcomes, to tell a run whose hand-off never landed (CREW-2363). */
11267
+ handoff = new HandoffTranscriptTracker();
11268
+ result;
11269
+ /** Prefix tokens of the first assistant call that carried usage; kept once. */
11270
+ prefixTokens;
11271
+ /** Set once the stream carries the harness's final result (turn count or cost). */
11272
+ sawResult = false;
11273
+ muted = false;
11274
+ /**
11275
+ * The label stamped on events. Starts as the poll hint and switches to the
11276
+ * ticket the run actually claims (CREW-1776); `id.ticket` stays the hint.
11277
+ */
11278
+ liveTicket;
11279
+ /** Called after `liveTicket` changes, so the sidecar can be rewritten. */
11280
+ onClaim;
11281
+ buf = "";
11282
+ mapper;
11283
+ id;
11284
+ adapter;
11285
+ eventsSink;
11286
+ emitCycle;
11287
+ constructor(id, adapter, eventsSink, emitCycle) {
11288
+ this.id = id;
11289
+ this.adapter = adapter;
11290
+ this.eventsSink = eventsSink;
11291
+ this.emitCycle = emitCycle;
11292
+ this.liveTicket = id.ticket ?? void 0;
11293
+ this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
11294
+ }
11295
+ feed(text2) {
11296
+ this.buf += text2;
11297
+ const lines2 = this.buf.split("\n");
11298
+ this.buf = lines2.pop() ?? "";
11299
+ for (const line of lines2) this.handleLine(line);
11300
+ }
11301
+ end() {
11302
+ if (this.buf) this.handleLine(this.buf);
11303
+ this.buf = "";
11304
+ try {
11305
+ this.consume(void 0, this.mapper.flush());
11306
+ } catch {
11307
+ }
11308
+ }
11309
+ handleLine(line) {
11310
+ if (!line) return;
11311
+ let parsed;
11312
+ try {
11313
+ parsed = JSON.parse(line);
11314
+ } catch {
11315
+ return;
11316
+ }
11317
+ try {
11318
+ const got = this.adapter.extractResult(parsed);
11319
+ if (got) {
11320
+ this.result = { ...this.result, ...Object.fromEntries(Object.entries(got).filter(([, v]) => v !== void 0)) };
11321
+ if (got.numTurns !== void 0 || got.costUsd !== void 0) this.sawResult = true;
11322
+ }
11323
+ this.handoff.feed(parsed);
11324
+ this.consume(parsed, this.mapper.map(parsed));
11325
+ } catch {
11326
+ }
11327
+ }
11328
+ // Turns mapped events (and, when there is a source line, its token usage)
11329
+ // into sink writes and buffered cycles. `parsed` is undefined for events
11330
+ // the mapper flushes at end of stream, which have no line of their own.
11331
+ consume(parsed, events) {
11332
+ const plan = this.id;
11333
+ let turnText = "";
11334
+ for (const ev of events) if ((ev.kind === "thought" || ev.kind === "text") && ev.text) this.attribution.note(ev.text);
11335
+ for (const ev of events) {
11336
+ if (ev.kind === "tool") this.attribution.noteWrite(ev.tool, ev.input);
11337
+ if (ev.kind === "tool" && isClaimWrite(ev.tool, ev.input)) {
11338
+ const claimed = this.attribution.current;
11339
+ if (claimed && claimed !== this.liveTicket) {
11340
+ if (!this.muted) {
11341
+ this.eventsSink?.write(`${JSON.stringify({
11342
+ at: (/* @__PURE__ */ new Date()).toISOString(),
11343
+ cycle: this.emitCycle,
11344
+ route: plan.route,
11345
+ step: "agent",
11346
+ level: "info",
11347
+ message: `ticket: ${this.liveTicket ?? "none"} \u2192 ${claimed}`,
11348
+ role: plan.role,
11349
+ ticket: claimed,
11350
+ data: { kind: "ticket", from: this.liveTicket ?? null, to: claimed }
11351
+ })}
11352
+ `);
11353
+ }
11354
+ this.liveTicket = claimed;
11355
+ if (!this.muted) this.onClaim?.();
11356
+ }
11357
+ }
11358
+ const e = {
11359
+ at: (/* @__PURE__ */ new Date()).toISOString(),
11360
+ cycle: this.emitCycle,
11361
+ route: plan.route,
11362
+ step: "agent",
11363
+ level: "info",
11364
+ message: ev.kind === "tool" ? `${ev.tool ?? "tool"}${ev.target ? ` ${ev.target}` : ""}` : ev.kind,
11365
+ role: plan.role,
11366
+ ...this.liveTicket ? { ticket: this.liveTicket } : {},
11367
+ data: { kind: ev.kind, ...ev.text ? { text: ev.text } : {}, ...ev.tool ? { tool: ev.tool } : {}, ...ev.target ? { target: ev.target } : {} }
11368
+ };
11369
+ if (!this.muted) this.eventsSink?.write(`${JSON.stringify(e)}
11370
+ `);
11371
+ if ((ev.kind === "thought" || ev.kind === "text") && ev.text) {
11372
+ turnText += `${ev.text}
11373
+ `;
11374
+ const cycleIndex = this.cycles.length;
11375
+ this.cycles.push({
11376
+ cycleIndex,
11377
+ occurredAt: e.at,
11378
+ thinking: ev.text,
11379
+ // A conversational `text` turn is tagged 'assistant' so it's
11380
+ // distinguishable from a thinking block once both share this
11381
+ // buffer (ISSUE-594) — matching the precedent
11382
+ // `agent-chat.service.ts`'s logChatRun (ISSUE-504) already set
11383
+ // for the same column. A `thought` cycle stays untagged, same
11384
+ // as before this change.
11385
+ ...ev.kind === "text" ? { role: "assistant" } : {}
11386
+ });
11387
+ if (!this.muted) {
11388
+ this.eventsSink?.write(`${JSON.stringify({
11389
+ ...e,
11390
+ message: "cycle",
11391
+ data: { kind: "cycle", cycleIndex, text: ev.text }
11392
+ })}
11393
+ `);
11394
+ }
11395
+ }
11396
+ }
11397
+ if (this.prefixTokens === void 0 && parsed !== void 0) this.prefixTokens = this.adapter.extractPrefixTokens?.(parsed);
11398
+ const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
11399
+ if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
10999
11400
  }
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;
11401
+ };
11402
+ function writeRunMeta(id, startedAt, liveTicket, extra) {
11012
11403
  try {
11013
- store = (o.getStore ?? getSessionStore)();
11014
- } catch (e) {
11015
- return targets.map(({ role }) => ({
11016
- role,
11017
- action: "failed",
11018
- detail: describeStoreError(e)
11404
+ writeFileSync15(`${id.streamPath}.meta.json`, JSON.stringify({
11405
+ route: id.route,
11406
+ role: id.role,
11407
+ hint: id.ticket,
11408
+ ticket: liveTicket ?? id.ticket,
11409
+ startedAt,
11410
+ model: id.model,
11411
+ toolchain: id.toolchain,
11412
+ harness: id.harness,
11413
+ provider: id.provider,
11414
+ exitCode: null,
11415
+ running: true,
11416
+ ...extra
11019
11417
  }));
11418
+ } catch {
11020
11419
  }
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");
11420
+ }
11421
+ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved) {
11422
+ let moved = false;
11423
+ if (hintMoved) {
11025
11424
  try {
11026
- await store.set(host, probeSlug, {
11027
- apiKey: "probe",
11028
- workspaceId: resolved.workspaceId,
11029
- identityId: "_probe",
11030
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
11425
+ moved = await hintMoved();
11426
+ } catch {
11427
+ }
11428
+ }
11429
+ const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket, { hintMoved: moved });
11430
+ const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
11431
+ const { result } = obs;
11432
+ const pending = end.interrupted ? null : obs.handoff.pending();
11433
+ if (pending) {
11434
+ try {
11435
+ const now = /* @__PURE__ */ new Date();
11436
+ writePendingHandoff(stateDirOfStream(id.streamPath), {
11437
+ ...pending,
11438
+ version: 1,
11439
+ route: id.route,
11440
+ role: id.role,
11441
+ ...attributedTicket ? { ticket: attributedTicket } : {},
11442
+ streamPath: id.streamPath,
11443
+ createdAt: now.toISOString(),
11444
+ attempts: 0,
11445
+ nextAttemptAt: now.toISOString()
11031
11446
  });
11032
- await store.clear(host, probeSlug);
11033
11447
  } 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" });
11448
+ emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
11036
11449
  }
11037
11450
  }
11038
- for (const { role, agentId } of targets) {
11039
- if (!agentId) {
11040
- out.push({ role, action: "no_agent" });
11041
- continue;
11451
+ try {
11452
+ writeRunMeta(id, startedAt, obs.liveTicket, {
11453
+ attributedTicket: attributedTicket ?? null,
11454
+ attributedBy,
11455
+ running: void 0,
11456
+ exitCode: end.code,
11457
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
11458
+ ...pending ? { handoff: "pending" } : {},
11459
+ sessionId: result?.sessionId ?? null,
11460
+ numTurns: result?.numTurns ?? null,
11461
+ prefixTokens: obs.prefixTokens ?? null,
11462
+ totalCostUsd: result?.costUsd ?? null
11463
+ });
11464
+ } catch {
11465
+ }
11466
+ const ms3 = Date.now() - Date.parse(startedAt);
11467
+ emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (${pending ? "hand-off pending" : `exit ${end.code ?? "unknown"}`})`, {
11468
+ data: {
11469
+ ms: ms3,
11470
+ code: end.code,
11471
+ ...pending ? { handoff: "pending" } : {},
11472
+ ...end.interrupted ? { interrupted: end.interrupted } : {},
11473
+ ...identity,
11474
+ attributedTicket: attributedTicket ?? null,
11475
+ attributedBy,
11476
+ ...result?.sessionId ? { sessionId: result.sessionId } : {},
11477
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
11478
+ ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
11042
11479
  }
11043
- const slug2 = slugFor(route2, agentId);
11480
+ });
11481
+ if (id.agentLog && agentLogKey) {
11482
+ const report = {
11483
+ ticketReference: attributedTicket,
11484
+ outcome: end.interrupted ? "interrupted" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
11485
+ startedAt,
11486
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
11487
+ cycles: obs.cycles,
11488
+ model: id.model,
11489
+ tokensIn: result?.inputTokens,
11490
+ tokensOut: result?.outputTokens,
11491
+ cacheReadTokens: result?.cacheReadTokens,
11492
+ cacheWriteTokens: result?.cacheWriteTokens,
11493
+ costUsd: result?.costUsd,
11494
+ // Both come from a finished run: without the result line (interrupted) neither is trustworthy.
11495
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
11496
+ ...result?.numTurns !== void 0 && obs.prefixTokens !== void 0 ? { prefixTokens: obs.prefixTokens } : {},
11497
+ harness: identity.harness,
11498
+ provider: identity.provider,
11499
+ promptVersion: id.promptVersion,
11500
+ promptSha: id.promptSha
11501
+ };
11044
11502
  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
- }
11503
+ const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
11075
11504
  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
- }
11505
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
11506
+ } catch {
11507
+ }
11508
+ if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
11509
+ if (sent.counterRetry) {
11510
+ try {
11511
+ queueTicketCounters(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
11512
+ version: 1,
11513
+ route: id.route,
11514
+ role: id.role,
11515
+ target: id.agentLog,
11516
+ write: sent.counterRetry,
11517
+ attempts: 0,
11518
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
11519
+ });
11520
+ } catch {
11096
11521
  }
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
11522
  }
11104
- out.push({ role, action: "minted" });
11105
11523
  } catch (e) {
11106
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11524
+ if (e instanceof AgentLogUnreachableError) {
11525
+ try {
11526
+ queueAgentRun(stateDirOfStream(id.streamPath), basename8(id.streamPath, ".jsonl"), {
11527
+ version: 1,
11528
+ route: id.route,
11529
+ role: id.role,
11530
+ target: id.agentLog,
11531
+ report,
11532
+ attempts: 0,
11533
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
11534
+ });
11535
+ emit.warn(`could not report agent log (${e.message}); queued to retry`, { step: "agent" });
11536
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
11537
+ } catch {
11538
+ }
11539
+ }
11540
+ if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
11541
+ try {
11542
+ await raiseShipAttention(
11543
+ { state: new State(stateDirOfStream(id.streamPath)), emit },
11544
+ agentLogRefusedItem(id.route, id.role, e.status)
11545
+ );
11546
+ } catch {
11547
+ }
11548
+ }
11549
+ emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
11107
11550
  }
11108
11551
  }
11109
- return out;
11552
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
11110
11553
  }
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`;
11554
+ function spawnAgent(plan, emit, hintMoved) {
11555
+ const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
11556
+ const runner = runnerFor(plan.runner, plan.sandboxConfig);
11557
+ const started = Date.now();
11558
+ const startedAt = new Date(started).toISOString();
11559
+ for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
11560
+ const eventsSink = openSink(plan.eventsPath);
11561
+ const id = identityOf(plan, adapter.harness);
11562
+ let streamFd;
11563
+ try {
11564
+ mkdirSync13(dirname8(plan.streamPath), { recursive: true });
11565
+ streamFd = openSync3(plan.streamPath, "a");
11566
+ } catch (e) {
11567
+ void endSink(eventsSink);
11568
+ return Promise.reject(new AgentSpawnError(`cannot open the stream file ${plan.streamPath}: ${e.message}`));
11123
11569
  }
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;
11570
+ const failed = (e) => {
11571
+ void endSink(eventsSink);
11572
+ return Promise.reject(new AgentSpawnError(`cannot run ${plan.bin}: ${e.message}`));
11573
+ };
11574
+ let begun;
11135
11575
  try {
11136
- store = (o.getStore ?? getSessionStore)();
11576
+ begun = runner.start(plan, { env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv }, streamFd, emit });
11137
11577
  } catch (e) {
11138
- return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
11578
+ closeSync3(streamFd);
11579
+ return failed(e);
11139
11580
  }
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;
11581
+ if (begun instanceof Promise) {
11582
+ return begun.then(
11583
+ (run3) => {
11584
+ closeSync3(streamFd);
11585
+ return supervise(run3);
11586
+ },
11587
+ (e) => {
11588
+ closeSync3(streamFd);
11589
+ return failed(e);
11157
11590
  }
11158
- if (o.dryRun) {
11159
- out.push({ role, action: "revoked", detail: "dry run" });
11160
- continue;
11591
+ );
11592
+ }
11593
+ closeSync3(streamFd);
11594
+ return supervise(begun);
11595
+ function supervise({ child, handle, promptDelivered }) {
11596
+ return new Promise((resolve7, reject) => {
11597
+ const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
11598
+ const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
11599
+ if (child.pid !== void 0) {
11600
+ const sidecar = {
11601
+ pid: child.pid,
11602
+ ownerPid: process.pid,
11603
+ spawnedAt,
11604
+ role: plan.role,
11605
+ route: plan.route,
11606
+ ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
11607
+ ...handle.runner === "sandbox" ? { runner: "sandbox", sandbox: handle.sandbox, sandboxBin: handle.sandboxBin } : {},
11608
+ ticket: plan.ticket ?? null,
11609
+ ticketRecordId: plan.ticketRecordId ?? null,
11610
+ repo: plan.repo ?? null,
11611
+ routeName: plan.routeName,
11612
+ model: plan.model,
11613
+ toolchain: id.toolchain,
11614
+ harness: id.harness,
11615
+ provider: id.provider,
11616
+ eventsPath: plan.eventsPath,
11617
+ agentLog: id.agentLog,
11618
+ promptVersion: plan.promptVersion,
11619
+ promptSha: plan.promptSha
11620
+ };
11621
+ try {
11622
+ writeFileSync15(pidPathFor(plan.streamPath), JSON.stringify(sidecar));
11623
+ } catch {
11624
+ }
11161
11625
  }
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" }
11626
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
11627
+ const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
11628
+ obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
11629
+ if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
11630
+ const tail3 = new FileTail(plan.streamPath);
11631
+ const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
11632
+ let killed;
11633
+ const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
11634
+ void killForDeadline(() => runner.kill(handle, WALL_CLOCK_REASON), child.pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
11635
+ killed = k;
11636
+ });
11637
+ }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
11638
+ let spawnFailed = false;
11639
+ child.on("error", (err3) => {
11640
+ spawnFailed = true;
11641
+ clearInterval(timer);
11642
+ clearTimeout(limitTimer);
11643
+ void runner.release(handle, emit);
11644
+ reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
11165
11645
  });
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`;
11646
+ child.on("close", async (code) => {
11647
+ clearInterval(timer);
11648
+ clearTimeout(limitTimer);
11649
+ obs.feed(tail3.poll());
11650
+ obs.end();
11651
+ await endSink(eventsSink);
11652
+ if (spawnFailed) return;
11653
+ await runner.release(handle, emit);
11654
+ if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
11655
+ killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
11656
+ }
11657
+ const { ms: ms3, handoffPending } = await finishRun(
11658
+ id,
11659
+ obs,
11660
+ emit,
11661
+ startedAt,
11662
+ { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
11663
+ plan.agentLog?.apiKey,
11664
+ hintMoved
11665
+ );
11666
+ resolve7({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
11667
+ });
11668
+ child.stdin?.on("error", (err3) => {
11669
+ emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
11670
+ });
11671
+ if (promptDelivered) child.stdin?.end();
11672
+ else child.stdin?.end(plan.prompt);
11673
+ });
11188
11674
  }
11189
11675
  }
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;
11676
+ async function reattachRun(streamPath, sc, emit, o = {}) {
11677
+ const adapter = adapterFor(sc.harness);
11678
+ const isAlive = o.isAlive ?? runIsAlive;
11679
+ const id = { ...sc, streamPath };
11680
+ const eventsSink = openSink(sc.eventsPath);
11681
+ const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
11682
+ const tail3 = new FileTail(streamPath);
11683
+ obs.muted = true;
11684
+ obs.feed(tail3.poll());
11685
+ obs.muted = false;
11686
+ const runner = runnerOfSidecar(sc);
11687
+ const adopted = isAlive(sc);
11688
+ let killed;
11689
+ if (adopted) {
11690
+ o.onAdopted?.();
11691
+ const pollMs = o.pollMs ?? TAIL_POLL_MS;
11692
+ while (isAlive(sc)) {
11693
+ obs.feed(tail3.poll());
11694
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
11695
+ killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
11696
+ continue;
11697
+ }
11698
+ await new Promise((r) => setTimeout(r, pollMs));
11699
+ }
11203
11700
  }
11701
+ obs.feed(tail3.poll());
11702
+ obs.end();
11703
+ await endSink(eventsSink);
11704
+ await runner.release(runner.reattach(sc), emit);
11705
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
11706
+ const { ms: ms3, ticket } = await finishRun(
11707
+ id,
11708
+ obs,
11709
+ emit,
11710
+ sc.spawnedAt,
11711
+ { code: null, ...interrupted ? { interrupted } : {} },
11712
+ o.agentLogKey
11713
+ );
11714
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
11204
11715
  }
11205
11716
 
11206
11717
  // src/selftest.ts
@@ -11364,7 +11875,7 @@ async function runSelftest(i, deps = realDeps) {
11364
11875
  ...adapter.runEnv(spec),
11365
11876
  [KEY_VAR]: i.apiKey
11366
11877
  };
11367
- const cwd = mkdtempSync2(join25(tmpdir4(), "crew-selftest-"));
11878
+ const cwd = mkdtempSync2(join27(tmpdir4(), "crew-selftest-"));
11368
11879
  try {
11369
11880
  const run3 = await deps.run({
11370
11881
  bin: invocation.bin,
@@ -11397,11 +11908,11 @@ function formatResult(r) {
11397
11908
  const secs = `${(r.ms / 1e3).toFixed(1)}s`;
11398
11909
  return r.ok ? `${r.toolchain}: PASS ${r.detail} (${secs}${tok})` : `${r.toolchain}: FAIL ${r.failure} \u2014 ${r.detail} (${secs})`;
11399
11910
  }
11400
- var fileFor2 = (stateDir) => join25(stateDir, "selftest.json");
11911
+ var fileFor2 = (stateDir) => join27(stateDir, "selftest.json");
11401
11912
  function readSelftest(stateDir) {
11402
11913
  try {
11403
11914
  const p = fileFor2(stateDir);
11404
- return existsSync22(p) ? JSON.parse(readFileSync21(p, "utf8")) : {};
11915
+ return existsSync25(p) ? JSON.parse(readFileSync22(p, "utf8")) : {};
11405
11916
  } catch {
11406
11917
  return {};
11407
11918
  }
@@ -11619,14 +12130,14 @@ function rosterMarkdown(roster, meId) {
11619
12130
  }
11620
12131
 
11621
12132
  // 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";
12133
+ import { mkdirSync as mkdirSync15, readFileSync as readFileSync23, rmSync as rmSync15, writeFileSync as writeFileSync17 } from "fs";
12134
+ import { dirname as dirname9, join as join28 } from "path";
11624
12135
  function lastSessionFile(stateDir) {
11625
- return join26(stateDir, "connect-session.json");
12136
+ return join28(stateDir, "connect-session.json");
11626
12137
  }
11627
12138
  function readPointers(stateDir) {
11628
12139
  try {
11629
- const parsed = JSON.parse(readFileSync22(lastSessionFile(stateDir), "utf8"));
12140
+ const parsed = JSON.parse(readFileSync23(lastSessionFile(stateDir), "utf8"));
11630
12141
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
11631
12142
  const out = {};
11632
12143
  for (const [host, v] of Object.entries(parsed)) {
@@ -11726,15 +12237,15 @@ async function signOutSessions(o) {
11726
12237
  }
11727
12238
 
11728
12239
  // 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";
12240
+ import { existsSync as existsSync26, mkdirSync as mkdirSync16, readFileSync as readFileSync24, readdirSync as readdirSync12, rmSync as rmSync16, statSync as statSync8, writeFileSync as writeFileSync18 } from "fs";
12241
+ import { dirname as dirname10, join as join29 } from "path";
11731
12242
  import { hostFromUrl as hostFromUrl3 } from "@tablation/client";
11732
12243
  function clonedCheckoutsFile(stateDir) {
11733
- return join27(stateDir, "cloned-checkouts.json");
12244
+ return join29(stateDir, "cloned-checkouts.json");
11734
12245
  }
11735
12246
  function readClonedCheckouts(stateDir) {
11736
12247
  try {
11737
- const parsed = JSON.parse(readFileSync23(clonedCheckoutsFile(stateDir), "utf8"));
12248
+ const parsed = JSON.parse(readFileSync24(clonedCheckoutsFile(stateDir), "utf8"));
11738
12249
  return Array.isArray(parsed) ? parsed.filter((d) => typeof d === "string") : [];
11739
12250
  } catch {
11740
12251
  return [];
@@ -11787,411 +12298,141 @@ async function runReset(o, fx) {
11787
12298
  }
11788
12299
  if (!o.dryRun) await s.clear();
11789
12300
  steps.push({ step: "credential", target, action: mark(o.dryRun), detail: "host session stored by a dry-run connect" });
11790
- } catch (e) {
11791
- steps.push({ step: "credential", target, action: "failed", detail: e.message });
11792
- }
11793
- }
11794
- } catch (e) {
11795
- steps.push({ step: "credential", target: "host sessions", action: "failed", detail: e.message });
11796
- }
11797
- if (o.removeCheckouts) {
11798
- for (const dir of readClonedCheckouts(o.stateDir)) steps.push(removeCheckout(dir, o.dryRun, fx));
11799
- }
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" });
12301
+ } catch (e) {
12302
+ steps.push({ step: "credential", target, action: "failed", detail: e.message });
12303
+ }
12051
12304
  }
12305
+ } catch (e) {
12306
+ steps.push({ step: "credential", target: "host sessions", action: "failed", detail: e.message });
12307
+ }
12308
+ if (o.removeCheckouts) {
12309
+ for (const dir of readClonedCheckouts(o.stateDir)) steps.push(removeCheckout(dir, o.dryRun, fx));
12310
+ }
12311
+ if (o.beforeState) steps.push(...await o.beforeState());
12312
+ steps.push(...o.wipeState ? wipeStateDir(o.stateDir, o.dryRun) : removeState(o.stateDir, o.dryRun));
12313
+ if (existsSync26(o.configFile)) {
12314
+ if (!o.dryRun) rmSync16(o.configFile, { force: true });
12315
+ steps.push({ step: "config", target: o.configFile, action: mark(o.dryRun) });
12316
+ } else {
12317
+ steps.push({ step: "config", target: o.configFile, action: "absent" });
12318
+ }
12319
+ return steps;
12320
+ }
12321
+ function removeCheckout(dir, dryRun, fx) {
12322
+ if (!existsSync26(dir)) return { step: "checkout", target: dir, action: "absent", detail: "already gone" };
12323
+ const { dirty, linkedWorktrees } = fx.inspectCheckout(dir);
12324
+ if (dirty.length) {
12325
+ 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" : ""})` };
12326
+ }
12327
+ if (linkedWorktrees.length) {
12328
+ return { step: "checkout", target: dir, action: "kept", detail: `has linked worktrees (${linkedWorktrees.join(", ")}); remove them first` };
12052
12329
  }
12330
+ if (!dryRun) rmSync16(dir, { recursive: true, force: true });
12331
+ return { step: "checkout", target: dir, action: mark(dryRun) };
12332
+ }
12333
+ function removeState(stateDir, dryRun) {
12334
+ const out = [];
12335
+ const targets = [join29(stateDir, "resolved"), join29(stateDir, "keys"), clonedCheckoutsFile(stateDir), join29(stateDir, "connect-session.json"), join29(stateDir, "reload-requested"), join29(stateDir, "daemon.started")];
12053
12336
  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);
12337
+ for (const name of readdirSync12(stateDir)) {
12338
+ if (name.startsWith(".") && statSync8(join29(stateDir, name)).isFile()) targets.push(join29(stateDir, name));
12059
12339
  }
12060
- } catch (e) {
12061
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12340
+ } catch {
12341
+ }
12342
+ for (const t of targets) {
12343
+ if (!existsSync26(t)) continue;
12344
+ if (!dryRun) rmSync16(t, { recursive: true, force: true });
12345
+ out.push({ step: "state", target: t, action: mark(dryRun) });
12062
12346
  }
12347
+ if (!out.length) out.push({ step: "state", target: stateDir, action: "absent", detail: "nothing to remove" });
12348
+ return out;
12063
12349
  }
12064
- function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12350
+ function wipeStateDir(stateDir, dryRun) {
12351
+ let names;
12065
12352
  try {
12066
- return worktreeForNumber(d, n)?.path ?? null;
12353
+ names = readdirSync12(stateDir);
12067
12354
  } catch {
12068
- return null;
12355
+ return [{ step: "state", target: stateDir, action: "absent", detail: "no state directory" }];
12069
12356
  }
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
- };
12357
+ if (!names.length) return [{ step: "state", target: stateDir, action: "absent", detail: "already empty" }];
12358
+ if (!dryRun) for (const n of names) rmSync16(join29(stateDir, n), { recursive: true, force: true });
12359
+ return [{ step: "state", target: stateDir, action: mark(dryRun), detail: `${names.length} entr${names.length === 1 ? "y" : "ies"}` }];
12086
12360
  }
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;
12361
+ function describeResetStep(s) {
12362
+ const verb = { removed: "removed", "would-remove": "would remove", absent: "nothing to remove", kept: "KEPT", failed: "FAILED" }[s.action];
12363
+ return `${s.step}: ${verb}: ${s.target}${s.detail ? ` (${s.detail})` : ""}`;
12096
12364
  }
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
- };
12365
+ function resetIncomplete(steps) {
12366
+ return steps.some((s) => s.action === "failed" || s.action === "kept");
12104
12367
  }
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));
12368
+
12369
+ // src/deprovision.ts
12370
+ var mark2 = (dryRun) => dryRun ? "would-remove" : "removed";
12371
+ async function runDeprovision(o, fx, deps) {
12372
+ const attempt = (step, target, fn, dry) => {
12373
+ try {
12374
+ if (!dry) fn();
12375
+ return { step, target, action: mark2(dry) };
12376
+ } catch (e) {
12377
+ return { step, target, action: "failed", detail: e.message };
12378
+ }
12116
12379
  };
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 {
12380
+ const afterScheduler = async () => {
12381
+ const steps2 = [];
12382
+ for (const run3 of deps.liveRuns()) {
12383
+ const target = `${run3.role} run on ${run3.route} (pid ${run3.pid})`;
12384
+ if (o.dryRun) {
12385
+ steps2.push({ step: "run", target, action: "would-remove" });
12386
+ continue;
12142
12387
  }
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
- }
12388
+ try {
12389
+ await deps.killRun(run3);
12390
+ deps.recordKill(run3);
12391
+ } catch (e) {
12392
+ steps2.push({ step: "run", target, action: "failed", detail: e.message });
12393
+ continue;
12162
12394
  }
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
12395
  try {
12179
- for (const k of readdirSync12(keysDir(stateDir))) left.push(`seat key ${k}`);
12180
- } catch {
12396
+ await deps.handBack(run3);
12397
+ steps2.push({ step: "run", target, action: "removed", detail: "killed" });
12398
+ } catch (e) {
12399
+ steps2.push({ step: "run", target, action: "removed", detail: `killed; could not tell its ticket (${e.message})` });
12181
12400
  }
12182
- for (const s of sandboxes()) left.push(`sandbox ${s}`);
12401
+ }
12402
+ for (const name of deps.sandboxes()) steps2.push(attempt("sandbox", name, () => {
12403
+ deps.removeSandbox(name);
12404
+ }, o.dryRun));
12405
+ if (!o.dryRun) {
12406
+ for (const r of await deps.clearShipRows()) {
12407
+ steps2.push({ step: "ship-row", target: r.route, action: r.ok ? "removed" : "failed", detail: r.detail });
12408
+ }
12409
+ }
12410
+ return steps2;
12411
+ };
12412
+ const beforeState = async () => {
12413
+ const steps2 = [];
12414
+ for (const host of deps.forgeHosts()) {
12183
12415
  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}`);
12416
+ const result = o.dryRun ? "erased" : deps.eraseForgeToken(host);
12417
+ if (result === "env") steps2.push({ step: "forge", target: host, action: "kept", detail: "the token is read from the environment; crew does not own it" });
12418
+ else if (result === "absent") steps2.push({ step: "forge", target: host, action: "absent" });
12419
+ else steps2.push({ step: "forge", target: host, action: mark2(o.dryRun) });
12187
12420
  } catch (e) {
12188
- left.push(`docker could not be checked (${e.message})`);
12421
+ steps2.push({ step: "forge", target: host, action: "failed", detail: e.message });
12189
12422
  }
12190
- for (const u of unitPaths()) left.push(`unit ${u}`);
12191
- if (loadedCrewLabels().length) left.push(`loaded units ${loadedCrewLabels().join(", ")}`);
12192
- return left;
12193
12423
  }
12424
+ const { volumes, images } = deps.labeled();
12425
+ for (const v of volumes) steps2.push(attempt("cache", `volume ${v}`, () => {
12426
+ deps.removeVolume(v);
12427
+ }, o.dryRun));
12428
+ for (const i of images) steps2.push(attempt("cache", `image ${i}`, () => {
12429
+ deps.removeImage(i);
12430
+ }, o.dryRun));
12431
+ return steps2;
12194
12432
  };
12433
+ const steps = await runReset({ ...o, removeCheckouts: true, wipeState: true, afterScheduler, beforeState }, fx);
12434
+ const remaining = o.dryRun ? [] : deps.remaining();
12435
+ return { steps, remaining, ok: !resetIncomplete(steps) && remaining.length === 0 };
12195
12436
  }
12196
12437
 
12197
12438
  // src/pool-member.ts
@@ -15858,11 +16099,11 @@ ${h.prFailure.tail}
15858
16099
  emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
15859
16100
  }
15860
16101
  }
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.`;
16102
+ for (const q2 of outcome.requeues) {
16103
+ const t = q2.ticket;
16104
+ 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
16105
  if (dryRun) {
15865
- emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
16106
+ emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q2.verified.slice(0, 8)}`, { ticket: t.issue_id });
15866
16107
  continue;
15867
16108
  }
15868
16109
  try {
@@ -15870,11 +16111,11 @@ ${h.prFailure.tail}
15870
16111
  await tracker.postEvent(t.id, body, seat, "requeued");
15871
16112
  await tracker.postEvent(
15872
16113
  t.id,
15873
- pushedEventBody(q.head, remote, q.branch),
16114
+ pushedEventBody(q2.head, remote, q2.branch),
15874
16115
  seat,
15875
16116
  "pushed"
15876
16117
  );
15877
- emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
16118
+ emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q2.verified.slice(0, 8)}`, { ticket: t.issue_id });
15878
16119
  } catch (e) {
15879
16120
  emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
15880
16121
  }
@@ -17071,8 +17312,8 @@ async function buildWhoami(routes, deps = {}) {
17071
17312
  const credential = credentialSource(route2);
17072
17313
  try {
17073
17314
  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}`, {
17315
+ const q2 = route2.resolved ? `?workspaceId=${encodeURIComponent(route2.resolved.workspaceId)}` : "";
17316
+ const res = await doFetch(`${route2.baseUrl.replace(/\/+$/, "")}/api/auth/me${q2}`, {
17076
17317
  headers: { Authorization: `Bearer ${key}`, "User-Agent": deps.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
17077
17318
  });
17078
17319
  if (res.status === 401 || res.status === 403) {
@@ -17417,9 +17658,9 @@ function startWatch(o) {
17417
17658
 
17418
17659
  // src/inbox.ts
17419
17660
  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" }));
17661
+ const q2 = query.trim().toLowerCase();
17662
+ const asEmail = q2.includes("@");
17663
+ 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
17664
  }
17424
17665
  async function gatherInbox(routes, ship, opts = {}) {
17425
17666
  const items = [];
@@ -20507,6 +20748,13 @@ async function runOnePassUnguarded(o) {
20507
20748
  const spawn11 = o.spawnRoleAgent ?? runRoleAgent;
20508
20749
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
20509
20750
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
20751
+ if (o.sandboxCache) {
20752
+ try {
20753
+ await o.sandboxCache();
20754
+ } catch (e) {
20755
+ emit.warn(`sandbox cache refresh failed: ${e instanceof Error ? e.message : String(e)}`);
20756
+ }
20757
+ }
20510
20758
  if (o.configFile) await reconcileRouteShipName({ route: o.route, ship: o.ship, configFile: o.configFile, emit });
20511
20759
  try {
20512
20760
  await (o.reconcile ?? reconcileForRoute)(ctx, o);
@@ -21010,7 +21258,7 @@ import {
21010
21258
  import { createConnection } from "net";
21011
21259
  import { createServer } from "http";
21012
21260
  import { basename as basename13, dirname as dirname23, join as join46 } from "path";
21013
- import { execFile as execFile2, spawn as spawn10 } from "child_process";
21261
+ import { execFile as execFile3, spawn as spawn10 } from "child_process";
21014
21262
 
21015
21263
  // src/periscope-control.ts
21016
21264
  import { spawn as spawn9 } from "child_process";
@@ -21097,23 +21345,23 @@ async function execute(h, args, streaming, after) {
21097
21345
  };
21098
21346
  }
21099
21347
  var isPeriscopePath = (p) => p === "/periscopes" || p.startsWith("/periscopes/");
21100
- async function handlePeriscopes(h, q) {
21348
+ async function handlePeriscopes(h, q2) {
21101
21349
  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");
21350
+ if (!q2.token) return err(401, "a bearer token is required");
21351
+ const down = /^\/periscopes\/([^/]+)\/down$/.exec(q2.path);
21352
+ const isUp = q2.path === "/periscopes" && q2.method === "POST";
21353
+ const isList = q2.path === "/periscopes" && q2.method === "GET";
21354
+ if (q2.path === "/periscopes" && !isUp && !isList) return err(405, "method not allowed");
21355
+ if (down && q2.method !== "POST") return err(405, "method not allowed");
21356
+ if (q2.path !== "/periscopes" && !down) return err(404, "not found");
21109
21357
  const member = async (workspaceId) => {
21110
- const v = await h.verifyIn(q.token, workspaceId);
21358
+ const v = await h.verifyIn(q2.token, workspaceId);
21111
21359
  if (v === "ok") return void 0;
21112
21360
  if (v === "unavailable") return err(503, "could not verify the key right now");
21113
21361
  return err(403, "this key does not belong to the workspace of this route");
21114
21362
  };
21115
21363
  if (isList) {
21116
- const named = q.query.get("route") ?? void 0;
21364
+ const named = q2.query.get("route") ?? void 0;
21117
21365
  const candidates2 = named === void 0 ? h.routes.filter((r) => r.hostPeriscopes) : [pickRoute(h, named)];
21118
21366
  const rows = [];
21119
21367
  const hosting = [];
@@ -21145,7 +21393,7 @@ async function handlePeriscopes(h, q) {
21145
21393
  }
21146
21394
  return reply(200, { periscopes: rows, hosting });
21147
21395
  }
21148
- const b = q.body;
21396
+ const b = q2.body;
21149
21397
  if (!b) return err(400, "body must be a small JSON object");
21150
21398
  const routeName = plainText(b.route, "route", false);
21151
21399
  if (isErr(routeName)) return err(400, routeName.error);
@@ -21166,7 +21414,7 @@ async function handlePeriscopes(h, q) {
21166
21414
  `--${b.outcome}`,
21167
21415
  ...b.allowEmpty === true ? ["--allow-empty"] : [],
21168
21416
  "--json"
21169
- ], q.wantsStream, h.mcp ? async (done) => {
21417
+ ], q2.wantsStream, h.mcp ? async (done) => {
21170
21418
  await h.mcp.onDown(picked, done);
21171
21419
  return done;
21172
21420
  } : void 0);
@@ -21200,7 +21448,7 @@ async function handlePeriscopes(h, q) {
21200
21448
  ...h.mcp ? ["--serve-mcp"] : [],
21201
21449
  "--no-open",
21202
21450
  "--json"
21203
- ], q.wantsStream, h.mcp ? async (done) => ({ ...done, ...await h.mcp.onUp(picked, done) }) : void 0);
21451
+ ], q2.wantsStream, h.mcp ? async (done) => ({ ...done, ...await h.mcp.onUp(picked, done) }) : void 0);
21204
21452
  }
21205
21453
  function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
21206
21454
  return new Promise((resolve7) => {
@@ -21475,11 +21723,11 @@ async function readJsonBody(req) {
21475
21723
  return null;
21476
21724
  }
21477
21725
  }
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;
21726
+ async function tailReply(path, q2, rotated, abort) {
21727
+ const after = q2.get("after") ?? void 0;
21728
+ const tail3 = q2.has("tail") ? Number(q2.get("tail")) : void 0;
21481
21729
  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;
21730
+ const wait = q2.has("wait") ? Number(q2.get("wait")) : 0;
21483
21731
  if (!(Number.isFinite(wait) && wait >= 0)) return err2(400, "wait must be a number of seconds");
21484
21732
  if (after !== void 0 && !decodeCursor(after)) return err2(400, "after is not a cursor this API issued");
21485
21733
  const deadline = Date.now() + Math.min(wait, MAX_WAIT_SECONDS) * 1e3;
@@ -21662,7 +21910,7 @@ function schedulerHandler(control, run3 = runDetached) {
21662
21910
  }
21663
21911
  function crewJson(args, timeoutMs = 6e4) {
21664
21912
  return new Promise((resolve7) => {
21665
- execFile2(process.execPath, [process.argv[1] ?? "", ...args], { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (_e, stdout) => {
21913
+ execFile3(process.execPath, [process.argv[1] ?? "", ...args], { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (_e, stdout) => {
21666
21914
  try {
21667
21915
  const v = JSON.parse(String(stdout));
21668
21916
  resolve7(v && typeof v === "object" && !Array.isArray(v) ? v : null);
@@ -22138,12 +22386,12 @@ function terminalPrompter() {
22138
22386
  process.stderr.write(`${line}
22139
22387
  `);
22140
22388
  },
22141
- ask: async (q, fallback) => {
22142
- const a = (await rl.question(fallback ? `${q} [${fallback}]: ` : `${q}: `)).trim();
22389
+ ask: async (q2, fallback) => {
22390
+ const a = (await rl.question(fallback ? `${q2} [${fallback}]: ` : `${q2}: `)).trim();
22143
22391
  return a || fallback || "";
22144
22392
  },
22145
- confirm: async (q, fallback) => {
22146
- const a = (await rl.question(`${q} [${fallback ? "Y/n" : "y/N"}] `)).trim();
22393
+ confirm: async (q2, fallback) => {
22394
+ const a = (await rl.question(`${q2} [${fallback ? "Y/n" : "y/N"}] `)).trim();
22147
22395
  return a ? /^y/i.test(a) : fallback;
22148
22396
  }
22149
22397
  };
@@ -22268,6 +22516,7 @@ function usage(problem, requested = false) {
22268
22516
  crew shell-init [--shell zsh|bash|fish] print the periscope shell function (install: eval "$(crew shell-init)")
22269
22517
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
22270
22518
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
22519
+ crew sandbox cache [--prune|--reset] [--json] list the sandbox cache volume's mirrors; --prune drops unserved repositories, --reset recreates the volume
22271
22520
  crew sync [route] fast-forward the checkout and its worktrees from the remote
22272
22521
  crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
22273
22522
  resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
@@ -22584,6 +22833,31 @@ if (command === "passenger-sync-daemon") {
22584
22833
  const out = refreshBaseBranch(r.dir, remote, base, true);
22585
22834
  const unsafe = baseBranchUnsafe(out);
22586
22835
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
22836
+ }, sandboxCacheRepos = function(c, ship) {
22837
+ const out = [];
22838
+ for (const t of reposOf(c)) {
22839
+ const origin = originUrlOf(t.dir, "origin");
22840
+ const repo = origin ? parseRemote(origin) : null;
22841
+ if (!repo) continue;
22842
+ let token = null;
22843
+ try {
22844
+ token = readForgeToken(ship.forges, repo.host, ship.stateDir);
22845
+ } catch {
22846
+ }
22847
+ out.push({ ...repo, dir: t.dir, token, username: forgeFor(ship.forges, repo.host)?.entry.username });
22848
+ }
22849
+ return out;
22850
+ }, buildSandboxCachePass = function(c, ship, state2, emit2) {
22851
+ const settings = cacheSettingsOf(ship.sandbox);
22852
+ if (!settings) return void 0;
22853
+ return makeCachePass({
22854
+ cache: new SandboxCache(settings),
22855
+ volume: settings.volume,
22856
+ repos: () => sandboxCacheRepos(c, ship),
22857
+ lock: (dir) => acquireCheckoutLock(dir),
22858
+ fail: (key, message) => raiseShipAttention({ state: state2, emit: emit2, route: c, ship }, { kind: "sandbox_cache", key, message, since: (/* @__PURE__ */ new Date()).toISOString() }).then(() => void 0),
22859
+ ok: (key) => clearShipAttention(state2, key)
22860
+ });
22587
22861
  }, sandboxTickDeps = function(emit2, c) {
22588
22862
  return {
22589
22863
  stateDir: cfg.ship.stateDir,
@@ -22597,7 +22871,7 @@ if (command === "passenger-sync-daemon") {
22597
22871
  log: (m) => emit2.emit(m)
22598
22872
  };
22599
22873
  };
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;
22874
+ 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
22875
  let cfg;
22602
22876
  let firstRun;
22603
22877
  let firstRunFromFlags = false;
@@ -22702,7 +22976,7 @@ if (command === "passenger-sync-daemon") {
22702
22976
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
22703
22977
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
22704
22978
  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;
22979
+ 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
22980
  let route2;
22707
22981
  try {
22708
22982
  route2 = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -24225,6 +24499,7 @@ ${cu.tail.trimEnd()}
24225
24499
  });
24226
24500
  if (controlServer) emit.emit(`control API listening on ${controlServer.path}`);
24227
24501
  const inFlight = /* @__PURE__ */ new Set();
24502
+ const sandboxCachePass = cfg.ship.runner === "sandbox" ? buildSandboxCachePass(route2, cfg.ship, state, emit) : void 0;
24228
24503
  let sandboxBuilding = false;
24229
24504
  const stopReason = await runDaemonLoop({
24230
24505
  inFlight,
@@ -24233,6 +24508,7 @@ ${cu.tail.trimEnd()}
24233
24508
  ),
24234
24509
  runPass: () => runOnePass({
24235
24510
  inFlight,
24511
+ sandboxCache: sandboxCachePass,
24236
24512
  route: route2,
24237
24513
  ship: cfg.ship,
24238
24514
  state,
@@ -26943,6 +27219,45 @@ ${where.join("\n")}`);
26943
27219
  if (results.some((r) => !r.ok)) process.exitCode = 1;
26944
27220
  break;
26945
27221
  }
27222
+ case "sandbox": {
27223
+ const settings = cacheSettingsOf(cfg.ship.sandbox);
27224
+ if (positional[1] !== "cache") {
27225
+ process.stderr.write("crew sandbox cache [--prune|--reset] [--json]\n");
27226
+ process.exit(2);
27227
+ }
27228
+ if (!settings) {
27229
+ process.stderr.write("crew sandbox cache: ship.sandbox.cacheVolume is not set, so there is no cache volume\n");
27230
+ process.exit(2);
27231
+ }
27232
+ const cache = new SandboxCache(settings);
27233
+ const served = cfg.routes.flatMap((r) => sandboxCacheRepos(r, cfg.ship));
27234
+ try {
27235
+ if (flag("reset")) {
27236
+ await cache.reset();
27237
+ process.stdout.write(`recreated volume ${settings.volume}; the next daemon pass refreshes every mirror
27238
+ `);
27239
+ break;
27240
+ }
27241
+ const dropped = flag("prune") ? await cache.prune(served) : [];
27242
+ const mirrors = await cache.list();
27243
+ if (flag("json")) {
27244
+ process.stdout.write(`${JSON.stringify({ volume: settings.volume, mirrors, dropped }, null, 2)}
27245
+ `);
27246
+ } else {
27247
+ for (const d of dropped) process.stdout.write(`dropped ${d.host}/${d.path}
27248
+ `);
27249
+ if (mirrors.length === 0) process.stdout.write(`volume ${settings.volume}: no mirrors yet
27250
+ `);
27251
+ for (const m of mirrors) process.stdout.write(`${m.repo.host}/${m.repo.path} last refresh ${m.lastRefresh ?? "never"}
27252
+ `);
27253
+ }
27254
+ } catch (e) {
27255
+ process.stderr.write(`crew sandbox cache: ${e instanceof Error ? e.message : String(e)}
27256
+ `);
27257
+ process.exitCode = 1;
27258
+ }
27259
+ break;
27260
+ }
26946
27261
  case "whoami": {
26947
27262
  const only = value("route") ?? positional[1];
26948
27263
  const wanted = only ? cfg.routes.filter((r) => r.route === only) : cfg.routes;
@@ -27824,4 +28139,6 @@ var endpointReconcileDeps2;
27824
28139
  var releaseRefused2;
27825
28140
  var doctorMachineId2;
27826
28141
  var baseHealth2;
28142
+ var sandboxCacheRepos2;
28143
+ var buildSandboxCachePass2;
27827
28144
  var sandboxTickDeps2;