@tablation/crew 0.93.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/crew.example.yaml CHANGED
@@ -56,6 +56,7 @@ ship:
56
56
  # deep: { model: claude-opus-5-5 }
57
57
  # args: [] # appended to the harness argv
58
58
  # env: {} # added to the session environment
59
+ # maxRunMinutes: 240 # wall-clock limit per agent run (default 240; 0 = none); a route's maxRunMinutes overrides it (CREW-2786)
59
60
  # gemini: { harness: gemini, bin: gemini, model: gemini-2.5-pro }
60
61
 
61
62
  # shell: bash # defaults per platform
package/dist/cli.js CHANGED
@@ -363,11 +363,11 @@ var Emitter = class _Emitter {
363
363
  const result = await fn();
364
364
  this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
365
365
  return result;
366
- } catch (err2) {
367
- this.error(`${message} \u2014 ${err2.message}`, {
366
+ } catch (err3) {
367
+ this.error(`${message} \u2014 ${err3.message}`, {
368
368
  data: { ms: this.now().getTime() - started, ok: false }
369
369
  });
370
- throw err2;
370
+ throw err3;
371
371
  }
372
372
  }
373
373
  };
@@ -2665,7 +2665,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
2665
2665
  "promptSet",
2666
2666
  "hostPassengers",
2667
2667
  "hostPeriscopes",
2668
- "toolchain"
2668
+ "toolchain",
2669
+ "maxRunMinutes"
2669
2670
  ]);
2670
2671
  function parseWorktreeName(v, where, file) {
2671
2672
  const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
@@ -3075,7 +3076,21 @@ Create the first of those:
3075
3076
  function expandCommand(cmd, base) {
3076
3077
  return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
3077
3078
  }
3078
- var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth"]);
3079
+ var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth", "maxRunMinutes"]);
3080
+ var MAX_RUN_MINUTES_CEILING = Math.floor((2 ** 31 - 1) / 6e4);
3081
+ function parseMaxRunMinutes(raw, where, missing) {
3082
+ if (raw === void 0) return void 0;
3083
+ const n = Number(raw);
3084
+ if (!Number.isInteger(n) || n < 0) {
3085
+ missing.add(`${where} must be a non-negative integer number of minutes, 0 for no limit (got "${String(raw)}")`);
3086
+ return void 0;
3087
+ }
3088
+ if (n > MAX_RUN_MINUTES_CEILING) {
3089
+ missing.add(`${where} is too large (${n}); the most it can be is ${MAX_RUN_MINUTES_CEILING} minutes \u2014 use 0 for no limit`);
3090
+ return void 0;
3091
+ }
3092
+ return n;
3093
+ }
3079
3094
  function parsePair(raw, missing) {
3080
3095
  if (raw === void 0) return void 0;
3081
3096
  if (!Array.isArray(raw)) {
@@ -3132,6 +3147,7 @@ function parseToolchains(raw, base, missing, file) {
3132
3147
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
3133
3148
  missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
3134
3149
  }
3150
+ const maxRunMinutes = parseMaxRunMinutes(entryRaw.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3135
3151
  let auth;
3136
3152
  if (entryRaw.auth !== void 0) {
3137
3153
  if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit") {
@@ -3153,7 +3169,8 @@ function parseToolchains(raw, base, missing, file) {
3153
3169
  tiers,
3154
3170
  args: Array.isArray(args) ? args.map(String) : [],
3155
3171
  env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
3156
- maxThinkingTokens
3172
+ maxThinkingTokens,
3173
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {}
3157
3174
  });
3158
3175
  }
3159
3176
  return out;
@@ -3161,6 +3178,7 @@ function parseToolchains(raw, base, missing, file) {
3161
3178
  function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
3162
3179
  rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
3163
3180
  const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
3181
+ const maxRunMinutes = parseMaxRunMinutes(c?.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3164
3182
  if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
3165
3183
  missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
3166
3184
  }
@@ -3224,6 +3242,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
3224
3242
  repoOverrides,
3225
3243
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
3226
3244
  ...toolchain !== void 0 ? { toolchain } : {},
3245
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {},
3227
3246
  promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
3228
3247
  weight,
3229
3248
  baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -3549,10 +3568,10 @@ function runScript(script, opts) {
3549
3568
  child.stdout.on("data", absorb);
3550
3569
  child.stderr.on("data", absorb);
3551
3570
  const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : void 0;
3552
- child.on("error", (err2) => {
3571
+ child.on("error", (err3) => {
3553
3572
  if (timer) clearTimeout(timer);
3554
3573
  resolvePromise({ code: 127, output: `${output}
3555
- crew: cannot run ${shell.bin}: ${err2.message}` });
3574
+ crew: cannot run ${shell.bin}: ${err3.message}` });
3556
3575
  });
3557
3576
  child.on("close", (code) => {
3558
3577
  if (timer) clearTimeout(timer);
@@ -4248,18 +4267,18 @@ function gitRaw(cwd, args) {
4248
4267
  try {
4249
4268
  return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4250
4269
  } catch (e) {
4251
- const err2 = e;
4252
- const why = [err2.stderr, err2.stdout].map(streamText).filter(Boolean).join(" / ");
4253
- throw new GitError(`git ${args.join(" ")}: ${why || err2.message.trim()}`);
4270
+ const err3 = e;
4271
+ const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
4272
+ throw new GitError(`git ${args.join(" ")}: ${why || err3.message.trim()}`);
4254
4273
  }
4255
4274
  }
4256
4275
  function git(cwd, args) {
4257
4276
  try {
4258
4277
  return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
4259
4278
  } catch (e) {
4260
- const err2 = e;
4261
- const why = [err2.stderr, err2.stdout].map(streamText).filter(Boolean).join(" / ");
4262
- throw new GitError(`git ${args.join(" ")}: ${why || err2.message.trim()}`);
4279
+ const err3 = e;
4280
+ const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
4281
+ throw new GitError(`git ${args.join(" ")}: ${why || err3.message.trim()}`);
4263
4282
  }
4264
4283
  }
4265
4284
  function gitOk(cwd, args) {
@@ -4701,9 +4720,9 @@ function ensureRepoCheckout(dir, remote, opts = {}) {
4701
4720
  try {
4702
4721
  execFileSync2("git", ["clone", url, dir], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4703
4722
  } catch (e) {
4704
- const err2 = e;
4705
- const why = [err2.stderr, err2.stdout].map(streamText).filter(Boolean).join(" / ");
4706
- throw new GitError(`git clone ${url} ${dir}: ${why || err2.message.trim()}`);
4723
+ const err3 = e;
4724
+ const why = [err3.stderr, err3.stdout].map(streamText).filter(Boolean).join(" / ");
4725
+ throw new GitError(`git clone ${url} ${dir}: ${why || err3.message.trim()}`);
4707
4726
  }
4708
4727
  cloneRecorder?.(dir);
4709
4728
  return true;
@@ -4736,8 +4755,8 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
4736
4755
  }
4737
4756
  };
4738
4757
  } catch (e) {
4739
- const err2 = e;
4740
- if (err2.code !== "EEXIST") throw err2;
4758
+ const err3 = e;
4759
+ if (err3.code !== "EEXIST") throw err3;
4741
4760
  try {
4742
4761
  if (Date.now() - statSync5(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
4743
4762
  try {
@@ -5129,8 +5148,8 @@ function probePushAuth(o, run2 = realGit, baseEnv = process.env) {
5129
5148
  run2(o.dir, ["push", "--dry-run", o.remote, `HEAD:refs/heads/${PROBE_BRANCH}`], env);
5130
5149
  return { state: "ok" };
5131
5150
  } catch (e) {
5132
- const err2 = e;
5133
- const raw = (err2.stderr ? String(err2.stderr) : err2.message).trim().split("\n").filter(Boolean).slice(-2).join(" / ");
5151
+ const err3 = e;
5152
+ const raw = (err3.stderr ? String(err3.stderr) : err3.message).trim().split("\n").filter(Boolean).slice(-2).join(" / ");
5134
5153
  const detail = raw || "push dry-run failed";
5135
5154
  const f = o.forge;
5136
5155
  if (!f) return { state: "failed", detail, hint: pushAuthHint(o.remoteUrl) };
@@ -7359,8 +7378,8 @@ var Tracker = class {
7359
7378
  if (res.status === 404) return [];
7360
7379
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
7361
7380
  rows = await res.json();
7362
- } catch (err2) {
7363
- this.onReadFailure?.(`reading ${type} events failed: ${err2 instanceof Error ? err2.message : String(err2)}`);
7381
+ } catch (err3) {
7382
+ this.onReadFailure?.(`reading ${type} events failed: ${err3 instanceof Error ? err3.message : String(err3)}`);
7364
7383
  return [];
7365
7384
  }
7366
7385
  return rows.filter((e) => e.event_type === type).sort((a, b) => Date.parse(b.occurred_at ?? b.created_at ?? "") - Date.parse(a.occurred_at ?? a.created_at ?? ""));
@@ -7563,6 +7582,28 @@ var Tracker = class {
7563
7582
  if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
7564
7583
  return missing;
7565
7584
  }
7585
+ /**
7586
+ * The runtime-map columns (ISSUE-2791) this workspace's template has not
7587
+ * taken yet, as `table.column` names for `crew doctor`. Each is read as its
7588
+ * default when absent, so this is informational: `Repos.forge_host`
7589
+ * (default `github.com`), `Ships.kind` (`developer_machine`),
7590
+ * `Ships.instance_class` and `Ships.relay_url` (blank). A table the route
7591
+ * does not have is skipped, not reported.
7592
+ */
7593
+ async missingRuntimeColumns() {
7594
+ const wanted = [
7595
+ [this.route.resolved?.reposModelId, "Repos", ["forge_host"]],
7596
+ [this.route.resolved?.shipsModelId, "Ships", ["kind", "instance_class", "relay_url"]]
7597
+ ];
7598
+ const missing = [];
7599
+ for (const [modelId, label, columns] of wanted) {
7600
+ if (!modelId) continue;
7601
+ const have = await this.columnNames(modelId);
7602
+ if (have.size === 0) continue;
7603
+ for (const c of columns) if (!have.has(c)) missing.push(`${label}.${c}`);
7604
+ }
7605
+ return missing;
7606
+ }
7566
7607
  /**
7567
7608
  * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
7568
7609
  * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
@@ -9190,6 +9231,55 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
9190
9231
  return out;
9191
9232
  }
9192
9233
 
9234
+ // src/run-limit.ts
9235
+ var DEFAULT_MAX_RUN_MINUTES = 240;
9236
+ var KILL_GRACE_MS = 5e3;
9237
+ var WALL_CLOCK_REASON = "wall-clock";
9238
+ function resolveMaxRunMinutes(route2, toolchain) {
9239
+ return route2 ?? toolchain ?? DEFAULT_MAX_RUN_MINUTES;
9240
+ }
9241
+ function deadlineFor(spawnedAt, maxRunMinutes) {
9242
+ if (!maxRunMinutes || maxRunMinutes <= 0) return void 0;
9243
+ const t = Date.parse(spawnedAt);
9244
+ return Number.isNaN(t) ? void 0 : new Date(t + maxRunMinutes * 6e4).toISOString();
9245
+ }
9246
+ function msUntilDeadline(deadline, now = Date.now()) {
9247
+ if (!deadline) return void 0;
9248
+ const t = Date.parse(deadline);
9249
+ return Number.isNaN(t) ? void 0 : t - now;
9250
+ }
9251
+ async function killRunGroup(pid, o = {}) {
9252
+ const alive = o.isAlive ?? ((p) => {
9253
+ try {
9254
+ process.kill(p, 0);
9255
+ return true;
9256
+ } catch (e) {
9257
+ return e.code === "EPERM";
9258
+ }
9259
+ });
9260
+ const signal = (sig) => {
9261
+ try {
9262
+ process.kill(-pid, sig);
9263
+ } catch {
9264
+ try {
9265
+ process.kill(pid, sig);
9266
+ } catch {
9267
+ }
9268
+ }
9269
+ };
9270
+ signal("SIGTERM");
9271
+ const end = Date.now() + (o.graceMs ?? KILL_GRACE_MS);
9272
+ while (alive(pid) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
9273
+ if (alive(pid)) signal("SIGKILL");
9274
+ }
9275
+ var RUN_KILLED_MARKER = "<!-- crew:run-killed -->";
9276
+ function runKilledComment(b) {
9277
+ 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.";
9278
+ return `${RUN_KILLED_MARKER}
9279
+
9280
+ 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.`;
9281
+ }
9282
+
9193
9283
  // src/agent.ts
9194
9284
  var DIGEST_MAX_AGE_SECONDS = 600;
9195
9285
  var LEGACY_TOOLCHAIN_HARNESS = "claude";
@@ -9299,6 +9389,7 @@ function planAgentRun(o) {
9299
9389
  toolchain: toolchain.name,
9300
9390
  harness: toolchain.harness,
9301
9391
  provider: toolchainProvider(toolchain),
9392
+ maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
9302
9393
  resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
9303
9394
  warnings: [...seat.warnings ?? [], ...expanded.warnings],
9304
9395
  prompt,
@@ -9388,6 +9479,17 @@ function identityOf(plan, adapterHarness) {
9388
9479
  promptSha: plan.promptSha
9389
9480
  };
9390
9481
  }
9482
+ async function killForDeadline(pid, emit, role, ticket, spawnedAt, limitMinutes) {
9483
+ const elapsedMs = Date.now() - Date.parse(spawnedAt);
9484
+ const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
9485
+ emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
9486
+ role,
9487
+ ...ticket ? { ticket } : {},
9488
+ data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
9489
+ });
9490
+ await killRunGroup(pid);
9491
+ return { limitMinutes: limit, elapsedMs };
9492
+ }
9391
9493
  var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
9392
9494
  function openSink(path) {
9393
9495
  try {
@@ -9747,13 +9849,16 @@ function spawnAgent(plan, emit, hintMoved) {
9747
9849
  detached: true
9748
9850
  });
9749
9851
  closeSync3(streamFd);
9852
+ const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
9853
+ const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
9750
9854
  if (child.pid !== void 0) {
9751
9855
  const sidecar = {
9752
9856
  pid: child.pid,
9753
9857
  ownerPid: process.pid,
9754
- spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
9858
+ spawnedAt,
9755
9859
  role: plan.role,
9756
9860
  route: plan.route,
9861
+ ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
9757
9862
  ticket: plan.ticket ?? null,
9758
9863
  ticketRecordId: plan.ticketRecordId ?? null,
9759
9864
  repo: plan.repo ?? null,
@@ -9773,28 +9878,48 @@ function spawnAgent(plan, emit, hintMoved) {
9773
9878
  }
9774
9879
  }
9775
9880
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9776
- const meta = { pid: child.pid ?? null };
9881
+ const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
9777
9882
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9778
9883
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9779
9884
  const tail3 = new FileTail(plan.streamPath);
9780
9885
  const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
9886
+ let killed;
9887
+ const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
9888
+ const pid = child.pid;
9889
+ void killForDeadline(pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
9890
+ killed = k;
9891
+ });
9892
+ }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
9781
9893
  let spawnFailed = false;
9782
- child.on("error", (err2) => {
9894
+ child.on("error", (err3) => {
9783
9895
  spawnFailed = true;
9784
9896
  clearInterval(timer);
9785
- reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err2.message}`));
9897
+ clearTimeout(limitTimer);
9898
+ reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
9786
9899
  });
9787
9900
  child.on("close", async (code) => {
9788
9901
  clearInterval(timer);
9902
+ clearTimeout(limitTimer);
9789
9903
  obs.feed(tail3.poll());
9790
9904
  obs.end();
9791
9905
  await endSink(eventsSink);
9792
9906
  if (spawnFailed) return;
9793
- const { ms: ms3, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
9794
- resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {} });
9907
+ if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
9908
+ killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
9909
+ }
9910
+ const { ms: ms3, handoffPending } = await finishRun(
9911
+ id,
9912
+ obs,
9913
+ emit,
9914
+ startedAt,
9915
+ { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
9916
+ plan.agentLog?.apiKey,
9917
+ hintMoved
9918
+ );
9919
+ resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
9795
9920
  });
9796
- child.stdin?.on("error", (err2) => {
9797
- emit.warn(`agent closed stdin before the prompt was delivered: ${err2.message}`, { step: "agent" });
9921
+ child.stdin?.on("error", (err3) => {
9922
+ emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
9798
9923
  });
9799
9924
  child.stdin?.end(plan.prompt);
9800
9925
  });
@@ -9810,18 +9935,23 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9810
9935
  obs.feed(tail3.poll());
9811
9936
  obs.muted = false;
9812
9937
  const adopted = isAlive(sc);
9938
+ let killed;
9813
9939
  if (adopted) {
9814
9940
  o.onAdopted?.();
9815
9941
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9816
9942
  while (isAlive(sc)) {
9817
9943
  obs.feed(tail3.poll());
9944
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
9945
+ killed = await killForDeadline(sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
9946
+ continue;
9947
+ }
9818
9948
  await new Promise((r) => setTimeout(r, pollMs));
9819
9949
  }
9820
9950
  }
9821
9951
  obs.feed(tail3.poll());
9822
9952
  obs.end();
9823
9953
  await endSink(eventsSink);
9824
- const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9954
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9825
9955
  const { ms: ms3, ticket } = await finishRun(
9826
9956
  id,
9827
9957
  obs,
@@ -9830,7 +9960,7 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9830
9960
  { code: null, ...interrupted ? { interrupted } : {} },
9831
9961
  o.agentLogKey
9832
9962
  );
9833
- return { adopted, ...interrupted ? { interrupted } : {}, ms: ms3, ticket };
9963
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
9834
9964
  }
9835
9965
 
9836
9966
  // src/seat-keys.ts
@@ -9881,8 +10011,8 @@ async function resolveSeatKey(route2, role, getStore = getSessionStore) {
9881
10011
  }
9882
10012
  }
9883
10013
  function safeStderrLine(e) {
9884
- const err2 = e;
9885
- const raw = typeof err2?.stderr === "string" && err2.stderr.trim() ? err2.stderr : typeof err2?.message === "string" ? err2.message : "";
10014
+ const err3 = e;
10015
+ const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
9886
10016
  for (const line of raw.split("\n")) {
9887
10017
  const t = line.trim();
9888
10018
  if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
@@ -10378,6 +10508,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
10378
10508
  "service",
10379
10509
  "topic",
10380
10510
  "assignee",
10511
+ "requested-by",
10381
10512
  "issue",
10382
10513
  "shell",
10383
10514
  "resume"
@@ -10806,12 +10937,12 @@ async function checkCrewFloor(o, deps = {}) {
10806
10937
  function openOutdated(state) {
10807
10938
  return state.shipAttention().previous().find((i) => i.key === CREW_OUTDATED_KEY)?.message ?? null;
10808
10939
  }
10809
- function isUpgradeRequired(err2) {
10810
- return err2 instanceof TablationApiError3 && err2.status === 426;
10940
+ function isUpgradeRequired(err3) {
10941
+ return err3 instanceof TablationApiError3 && err3.status === 426;
10811
10942
  }
10812
- async function raiseFromUpgradeRequired(o, err2) {
10943
+ async function raiseFromUpgradeRequired(o, err3) {
10813
10944
  const have = sentVersion(o.ship) ?? "unknown";
10814
- const floor = /(\d+\.\d+\.\d+)/.exec(err2.message.replace(have, ""))?.[1] ?? "a newer version";
10945
+ const floor = /(\d+\.\d+\.\d+)/.exec(err3.message.replace(have, ""))?.[1] ?? "a newer version";
10815
10946
  await raiseShipAttention(o, outdatedItem(have, floor));
10816
10947
  }
10817
10948
 
@@ -11626,8 +11757,8 @@ async function decideCycle(o) {
11626
11757
  const optional = async (what, read, fallback) => {
11627
11758
  try {
11628
11759
  return await read;
11629
- } catch (err2) {
11630
- emit.warn(`reading ${what} failed: ${err2 instanceof Error ? err2.message : String(err2)}`);
11760
+ } catch (err3) {
11761
+ emit.warn(`reading ${what} failed: ${err3 instanceof Error ? err3.message : String(err3)}`);
11631
11762
  return fallback;
11632
11763
  }
11633
11764
  };
@@ -14330,7 +14461,8 @@ function describeRun(run2, route2, seatIds, worktreeOf = (d, n) => {
14330
14461
  worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
14331
14462
  stream: run2.streamPath,
14332
14463
  pid: run2.pid,
14333
- startedAt: run2.spawnedAt
14464
+ startedAt: run2.spawnedAt,
14465
+ deadline: run2.deadline ?? null
14334
14466
  };
14335
14467
  }
14336
14468
 
@@ -15504,7 +15636,10 @@ async function discover(o) {
15504
15636
  ...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
15505
15637
  ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
15506
15638
  ...ownerId !== void 0 ? { owner_id: ownerId } : {},
15507
- ...writeMachineId ? { machine_id: machineId } : {}
15639
+ ...writeMachineId ? { machine_id: machineId } : {},
15640
+ // ISSUE-2791: a ship connecting by hand is a developer machine; the
15641
+ // column arrives with a template update, so only written when present.
15642
+ ...shipColumns.has("kind") ? { kind: "developer_machine" } : {}
15508
15643
  });
15509
15644
  shipRowId = created.id;
15510
15645
  out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
@@ -16633,7 +16768,7 @@ async function runAgentSuggest(o) {
16633
16768
  const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
16634
16769
  const run2 = await new Promise((resolve6) => {
16635
16770
  let out = "";
16636
- let err2 = "";
16771
+ let err3 = "";
16637
16772
  const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
16638
16773
  let settled = false;
16639
16774
  const settle = (r) => {
@@ -16650,7 +16785,7 @@ async function runAgentSuggest(o) {
16650
16785
  }
16651
16786
  child.stdout.destroy();
16652
16787
  child.stderr.destroy();
16653
- settle({ code: null, out, err: err2, aborted: true });
16788
+ settle({ code: null, out, err: err3, aborted: true });
16654
16789
  };
16655
16790
  if (signal.aborted) onAbort();
16656
16791
  else signal.addEventListener("abort", onAbort, { once: true });
@@ -16658,10 +16793,10 @@ async function runAgentSuggest(o) {
16658
16793
  out += d.toString();
16659
16794
  });
16660
16795
  child.stderr.on("data", (d) => {
16661
- err2 += d.toString();
16796
+ err3 += d.toString();
16662
16797
  });
16663
- child.on("error", (e) => settle({ code: null, out, err: err2, spawnError: e, aborted: false }));
16664
- child.on("close", (code) => settle({ code, out, err: err2, aborted: false }));
16798
+ child.on("error", (e) => settle({ code: null, out, err: err3, spawnError: e, aborted: false }));
16799
+ child.on("close", (code) => settle({ code, out, err: err3, aborted: false }));
16665
16800
  child.stdin.on("error", () => {
16666
16801
  });
16667
16802
  child.stdin.end(buildAgentPrompt(o.sniff));
@@ -17163,7 +17298,7 @@ function readPeriscopePrior(stateDir, ticketRecordId) {
17163
17298
  var DEFAULT_MAX_PERISCOPES2 = 3;
17164
17299
  var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
17165
17300
  function parsePeriscopeUpArgs(positional2, flags) {
17166
- const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--no-open] [--json]";
17301
+ const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--requested-by ID] [--no-open] [--json]";
17167
17302
  const rest = positional2.slice(2);
17168
17303
  if (rest.length > 2) throw new PeriscopeError(usage2);
17169
17304
  const topic = flags.topic?.trim();
@@ -17184,6 +17319,7 @@ ${usage2}`);
17184
17319
  isolate: flags.isolate,
17185
17320
  ...flags.host ? { host: flags.host } : {},
17186
17321
  ...flags.assignee ? { assignee: flags.assignee } : {},
17322
+ ...flags.requestedBy?.trim() ? { requestedBy: flags.requestedBy.trim() } : {},
17187
17323
  json: flags.json
17188
17324
  };
17189
17325
  }
@@ -17670,8 +17806,8 @@ function ensureShipSshKeypair(stateDir) {
17670
17806
  "-q"
17671
17807
  ], { stdio: ["ignore", "ignore", "pipe"] });
17672
17808
  } catch (e) {
17673
- const err2 = e;
17674
- throw new SshKeyError(`ssh-keygen failed: ${err2.stderr?.toString().trim() || err2.message}`);
17809
+ const err3 = e;
17810
+ throw new SshKeyError(`ssh-keygen failed: ${err3.stderr?.toString().trim() || err3.message}`);
17675
17811
  }
17676
17812
  chmodSync3(privateKeyPath, 384);
17677
17813
  }
@@ -17697,9 +17833,9 @@ var realExec = (cmd, args, opts) => {
17697
17833
  try {
17698
17834
  return execFileSync9(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
17699
17835
  } catch (e) {
17700
- const err2 = e;
17701
- const why = [err2.stderr, err2.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
17702
- throw new Error(`${cmd} ${args.join(" ")}: ${why || err2.message.trim()}`);
17836
+ const err3 = e;
17837
+ const why = [err3.stderr, err3.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
17838
+ throw new Error(`${cmd} ${args.join(" ")}: ${why || err3.message.trim()}`);
17703
17839
  }
17704
17840
  };
17705
17841
  var DOCKER_INFO_TIMEOUT_MS = 1e4;
@@ -18272,8 +18408,8 @@ function runSyncDaemonFromEnv(env = process.env) {
18272
18408
  const secret = requireEnv(env, "PASSENGER_SYNC_SECRET");
18273
18409
  const targets = JSON.parse(requireEnv(env, "PASSENGER_SYNC_TARGETS"));
18274
18410
  const server = createSyncDaemonServer(targets, secret);
18275
- server.on("error", (err2) => {
18276
- console.error(`Passenger sync daemon failed to bind 0.0.0.0:${port}: ${err2 instanceof Error ? err2.message : String(err2)}`);
18411
+ server.on("error", (err3) => {
18412
+ console.error(`Passenger sync daemon failed to bind 0.0.0.0:${port}: ${err3 instanceof Error ? err3.message : String(err3)}`);
18277
18413
  process.exit(1);
18278
18414
  });
18279
18415
  server.listen(port, "0.0.0.0", () => {
@@ -18390,22 +18526,22 @@ function codeOf(e) {
18390
18526
  const code = e?.code;
18391
18527
  return typeof code === "string" ? code : void 0;
18392
18528
  }
18393
- function isTransientError(err2) {
18394
- if (!(err2 instanceof Error)) return false;
18395
- const msg = err2.message;
18529
+ function isTransientError(err3) {
18530
+ if (!(err3 instanceof Error)) return false;
18531
+ const msg = err3.message;
18396
18532
  if (msg === "fetch failed" || msg.startsWith("fetch failed")) return true;
18397
- for (let e = err2, depth = 0; e && depth < 4; e = e.cause, depth++) {
18533
+ for (let e = err3, depth = 0; e && depth < 4; e = e.cause, depth++) {
18398
18534
  const code = codeOf(e);
18399
18535
  if (code && NETWORK_CODES.includes(code)) return true;
18400
18536
  }
18401
18537
  if (NETWORK_CODES.some((c) => msg.includes(c))) return true;
18402
18538
  if (/\b(?:HTTP|status)\s*:?\s*(502|503|504)\b/i.test(msg) || /^(502|503|504)\b/.test(msg)) return true;
18403
- if (err2 instanceof SyntaxError && /Unexpected token '?<|is not valid JSON|Unexpected end of JSON/.test(msg)) return true;
18539
+ if (err3 instanceof SyntaxError && /Unexpected token '?<|is not valid JSON|Unexpected end of JSON/.test(msg)) return true;
18404
18540
  return false;
18405
18541
  }
18406
- function describeCause(err2) {
18407
- const first = (err2 instanceof Error ? err2.message : String(err2)).split("\n")[0];
18408
- const code = codeOf(err2?.cause);
18542
+ function describeCause(err3) {
18543
+ const first = (err3 instanceof Error ? err3.message : String(err3)).split("\n")[0];
18544
+ const code = codeOf(err3?.cause);
18409
18545
  return code && !first.includes(code) ? `${first} (${code})` : first;
18410
18546
  }
18411
18547
 
@@ -18434,22 +18570,22 @@ async function runDaemonLoop(o) {
18434
18570
  o.onRecovered?.({ downMs: now() - downSince });
18435
18571
  transientRun = 0;
18436
18572
  }
18437
- } catch (err2) {
18438
- if (isTransientError(err2)) {
18573
+ } catch (err3) {
18574
+ if (isTransientError(err3)) {
18439
18575
  passes++;
18440
18576
  if (transientRun === 0) downSince = now();
18441
18577
  transientRun++;
18442
18578
  const wait = transientBackoffMs(transientRun);
18443
18579
  const downMs = now() - downSince;
18444
- o.onTransient?.({ err: err2, consecutive: transientRun, backoffMs: wait, downMs });
18445
- if (transientRun === OUTAGE_ESCALATE_AFTER) o.onOutage?.({ err: err2, consecutive: transientRun, downMs });
18580
+ o.onTransient?.({ err: err3, consecutive: transientRun, backoffMs: wait, downMs });
18581
+ if (transientRun === OUTAGE_ESCALATE_AFTER) o.onOutage?.({ err: err3, consecutive: transientRun, downMs });
18446
18582
  if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
18447
18583
  if (o.signal?.aborted) return "aborted";
18448
18584
  await sleep(wait);
18449
18585
  continue;
18450
18586
  }
18451
18587
  passes++;
18452
- o.onPassError?.(err2);
18588
+ o.onPassError?.(err3);
18453
18589
  if (o.maxPasses !== void 0 && passes >= o.maxPasses) return "maxPasses";
18454
18590
  if (o.signal?.aborted) return "aborted";
18455
18591
  await sleep(backoff);
@@ -18568,7 +18704,7 @@ async function runOnePassUnguarded(o) {
18568
18704
  await hydrateApiKeys([o.route], o.getSessionStore);
18569
18705
  const emit = o.newEmitter();
18570
18706
  const decide = o.decide ?? decideCycle;
18571
- const spawn8 = o.spawnRoleAgent ?? runRoleAgent;
18707
+ const spawn9 = o.spawnRoleAgent ?? runRoleAgent;
18572
18708
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
18573
18709
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
18574
18710
  if (o.configFile) await reconcileRouteShipName({ route: o.route, ship: o.ship, configFile: o.configFile, emit });
@@ -18672,7 +18808,7 @@ async function runOnePassUnguarded(o) {
18672
18808
  const settled = new Promise((resolve6) => {
18673
18809
  ready = resolve6;
18674
18810
  });
18675
- const run2 = spawn8(role, decision, ctx, { ready }).then(() => {
18811
+ const run2 = spawn9(role, decision, ctx, { ready }).then(() => {
18676
18812
  breaker.recordSuccess(role);
18677
18813
  clearShipAttention(o.state, `role_parked:${role}`);
18678
18814
  }).catch(async (e) => {
@@ -18780,7 +18916,7 @@ async function reconcileForRoute(ctx, o) {
18780
18916
  if (wt) dirty = status(wt.path).length > 0;
18781
18917
  } catch {
18782
18918
  }
18783
- await tracker.postEvent(sc.ticketRecordId, interruptedComment({
18919
+ await tracker.postEvent(sc.ticketRecordId, result.killed ? runKilledComment({ limitMinutes: result.killed.limitMinutes, branch: wt?.branch ?? null, dirty }) : interruptedComment({
18784
18920
  branch: wt?.branch ?? null,
18785
18921
  dirty,
18786
18922
  reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
@@ -18803,6 +18939,23 @@ async function reconcileForRoute(ctx, o) {
18803
18939
  }
18804
18940
  });
18805
18941
  }
18942
+ async function postRunKilled(route2, tracker, ticketId, memberId, limitMinutes, emit) {
18943
+ if (!ticketId || !memberId) return;
18944
+ try {
18945
+ const t = await tracker.ticket(ticketId);
18946
+ const target = repoTargetFor(route2, t.repo_id);
18947
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
18948
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
18949
+ let dirty = null;
18950
+ try {
18951
+ if (wt) dirty = status(wt.path).length > 0;
18952
+ } catch {
18953
+ }
18954
+ await tracker.postEvent(ticketId, runKilledComment({ limitMinutes, branch: wt?.branch ?? null, dirty }), memberId, "interrupted");
18955
+ } catch (e) {
18956
+ emit.warn(`could not post the wall-clock limit comment: ${e.message}`, { step: "agent" });
18957
+ }
18958
+ }
18806
18959
  async function runRoleAgent(role, decision, ctx, hooks) {
18807
18960
  const held = {};
18808
18961
  try {
@@ -18965,8 +19118,12 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
18965
19118
  } catch {
18966
19119
  }
18967
19120
  }
18968
- await spawnAgent(plan, emit, hintMoved);
18969
- await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19121
+ const result = await spawnAgent(plan, emit, hintMoved);
19122
+ if (result.killed) {
19123
+ await postRunKilled(route2, tracker, workingId, memberId, result.killed.limitMinutes, emit);
19124
+ } else {
19125
+ await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19126
+ }
18970
19127
  } finally {
18971
19128
  await settleRunEnd({
18972
19129
  tracker,
@@ -19020,7 +19177,213 @@ import {
19020
19177
  import { createConnection } from "net";
19021
19178
  import { createServer } from "http";
19022
19179
  import { basename as basename12, dirname as dirname21, join as join43 } from "path";
19023
- import { execFile, spawn as spawn7 } from "child_process";
19180
+ import { execFile, spawn as spawn8 } from "child_process";
19181
+
19182
+ // src/periscope-control.ts
19183
+ import { spawn as spawn7 } from "child_process";
19184
+ var reply = (status2, body) => ({ status: status2, body });
19185
+ var err = (status2, message) => reply(status2, { error: message });
19186
+ var TICKET_RE = /^(?:[A-Za-z]+-)?\d+$/;
19187
+ var MAX_FIELD = 200;
19188
+ function plainText(v, field, required) {
19189
+ if (v === void 0 || v === null || v === "") return required ? { error: `${field} is required` } : void 0;
19190
+ if (typeof v !== "string") return { error: `${field} must be a string` };
19191
+ const t = v.trim();
19192
+ if (!t) return required ? { error: `${field} is required` } : void 0;
19193
+ if (t.length > MAX_FIELD || /[\r\n\0]/.test(t)) return { error: `${field} must be one line of at most ${MAX_FIELD} characters` };
19194
+ if (t.startsWith("-")) return { error: `${field} must not start with "-"` };
19195
+ return t;
19196
+ }
19197
+ var isErr = (v) => typeof v === "object" && v !== null && "error" in v;
19198
+ function pickRoute(h, named) {
19199
+ const hosting = h.routes.filter((r) => r.hostPeriscopes);
19200
+ if (named !== void 0) {
19201
+ const hit = hosting.find((r) => r.route === named);
19202
+ return hit ?? err(404, "not found");
19203
+ }
19204
+ if (hosting.length === 1) return hosting[0];
19205
+ return err(400, `route is required (this ship hosts periscopes on: ${hosting.map((r) => r.route).join(", ")})`);
19206
+ }
19207
+ function cliMessage(r) {
19208
+ const lines = r.stderr.split("\n").map((l) => l.trim()).filter(Boolean);
19209
+ const plain = lines.filter((l) => !l.startsWith("{"));
19210
+ const last = plain[plain.length - 1] ?? lines[lines.length - 1] ?? `crew exited with status ${r.code}`;
19211
+ return last.replace(/^crew periscope:\s*/, "");
19212
+ }
19213
+ function failureStatus(r) {
19214
+ const m = cliMessage(r);
19215
+ if (/^no periscope for ticket /.test(m)) return 404;
19216
+ if (/already hosts \d+ live periscope/.test(m)) return 409;
19217
+ return r.code === 1 ? 422 : 500;
19218
+ }
19219
+ function lastJsonLine(stdout) {
19220
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
19221
+ for (let i = lines.length - 1; i >= 0; i--) {
19222
+ try {
19223
+ const v = JSON.parse(lines[i]);
19224
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
19225
+ } catch {
19226
+ }
19227
+ }
19228
+ return null;
19229
+ }
19230
+ function parseLine(line) {
19231
+ try {
19232
+ const v = JSON.parse(line);
19233
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
19234
+ } catch {
19235
+ return null;
19236
+ }
19237
+ }
19238
+ async function execute(h, args, streaming) {
19239
+ const finish = (r) => {
19240
+ if (r.code !== 0) return { status: failureStatus(r), body: { error: cliMessage(r) } };
19241
+ const done = lastJsonLine(r.stdout);
19242
+ return done ? { status: 200, body: done } : { status: 502, body: { error: "the command finished without a result" } };
19243
+ };
19244
+ if (!streaming) {
19245
+ const f = finish(await h.run(args, () => {
19246
+ }));
19247
+ return reply(f.status, f.body);
19248
+ }
19249
+ return {
19250
+ status: 200,
19251
+ body: null,
19252
+ stream: async (write) => {
19253
+ const r = await h.run(args, (line) => {
19254
+ const ev = parseLine(line);
19255
+ if (ev) write(ev);
19256
+ });
19257
+ const f = finish(r);
19258
+ write(f.status === 200 ? f.body : { event: "error", status: f.status, message: f.body.error });
19259
+ }
19260
+ };
19261
+ }
19262
+ var isPeriscopePath = (p) => p === "/periscopes" || p.startsWith("/periscopes/");
19263
+ async function handlePeriscopes(h, q) {
19264
+ if (!h || !h.routes.some((r) => r.hostPeriscopes)) return err(404, "not found");
19265
+ const down = /^\/periscopes\/([^/]+)\/down$/.exec(q.path);
19266
+ const isUp = q.path === "/periscopes" && q.method === "POST";
19267
+ const isList = q.path === "/periscopes" && q.method === "GET";
19268
+ if (q.path === "/periscopes" && !isUp && !isList) return err(405, "method not allowed");
19269
+ if (down && q.method !== "POST") return err(405, "method not allowed");
19270
+ if (q.path !== "/periscopes" && !down) return err(404, "not found");
19271
+ const member = async (workspaceId) => {
19272
+ const v = await h.verifyIn(q.token, workspaceId);
19273
+ if (v === "ok") return void 0;
19274
+ if (v === "unavailable") return err(503, "could not verify the key right now");
19275
+ return err(403, "this key does not belong to the workspace of this route");
19276
+ };
19277
+ if (isList) {
19278
+ const named = q.query.get("route") ?? void 0;
19279
+ const candidates2 = named === void 0 ? h.routes.filter((r) => r.hostPeriscopes) : [pickRoute(h, named)];
19280
+ const rows = [];
19281
+ for (const c of candidates2) {
19282
+ if ("status" in c) return c;
19283
+ const denied2 = await member(c.workspaceId);
19284
+ if (denied2) {
19285
+ if (named !== void 0) return denied2;
19286
+ continue;
19287
+ }
19288
+ const r = await h.run(["periscope", "list", c.route, "--json"], () => {
19289
+ });
19290
+ if (r.code !== 0) return err(failureStatus(r), cliMessage(r));
19291
+ try {
19292
+ const list = JSON.parse(r.stdout);
19293
+ if (Array.isArray(list)) for (const e of list) rows.push({ route: c.route, ...e });
19294
+ } catch {
19295
+ return err(502, "the list command did not answer JSON");
19296
+ }
19297
+ }
19298
+ return reply(200, { periscopes: rows });
19299
+ }
19300
+ const b = q.body;
19301
+ if (!b) return err(400, "body must be a small JSON object");
19302
+ const routeName = plainText(b.route, "route", false);
19303
+ if (isErr(routeName)) return err(400, routeName.error);
19304
+ const picked = pickRoute(h, routeName);
19305
+ if ("status" in picked) return picked;
19306
+ const denied = await member(picked.workspaceId);
19307
+ if (denied) return denied;
19308
+ if (down) {
19309
+ const ticket = decodeURIComponent(down[1]);
19310
+ if (!TICKET_RE.test(ticket)) return err(400, "ticket must be a ticket number such as 123 or CREW-123");
19311
+ if (b.outcome !== "keep" && b.outcome !== "discard") return err(400, 'outcome must be "keep" or "discard"');
19312
+ if (b.allowEmpty !== void 0 && typeof b.allowEmpty !== "boolean") return err(400, "allowEmpty must be a boolean");
19313
+ return execute(h, [
19314
+ "periscope",
19315
+ "down",
19316
+ picked.route,
19317
+ ticket,
19318
+ `--${b.outcome}`,
19319
+ ...b.allowEmpty === true ? ["--allow-empty"] : [],
19320
+ "--json"
19321
+ ], q.wantsStream);
19322
+ }
19323
+ const repo = plainText(b.repo, "repo", true);
19324
+ const topic = plainText(b.topic, "topic", true);
19325
+ const requestedBy = plainText(b.requestedBy, "requestedBy", false);
19326
+ for (const v of [repo, topic, requestedBy]) if (isErr(v)) return err(400, v.error);
19327
+ if (b.isolate !== void 0 && typeof b.isolate !== "boolean") return err(400, "isolate must be a boolean");
19328
+ if (!h.previewHost) return err(409, "this ship has no ship.previewHost, so a Passenger could not reach a periscope");
19329
+ const live = await h.run(["periscope", "list", picked.route, "--json"], () => {
19330
+ });
19331
+ if (live.code === 0) {
19332
+ try {
19333
+ const n = JSON.parse(live.stdout).length;
19334
+ if (n >= h.maxPeriscopes) return err(409, `this ship already hosts ${n} live periscope(s) (ship.maxPeriscopes is ${h.maxPeriscopes}) \u2014 discard one first`);
19335
+ } catch {
19336
+ }
19337
+ }
19338
+ return execute(h, [
19339
+ "periscope",
19340
+ "up",
19341
+ picked.route,
19342
+ repo,
19343
+ "--topic",
19344
+ topic,
19345
+ "--host",
19346
+ h.previewHost,
19347
+ ...requestedBy ? ["--requested-by", requestedBy] : [],
19348
+ ...b.isolate === true ? ["--isolate"] : [],
19349
+ "--no-open",
19350
+ "--json"
19351
+ ], q.wantsStream);
19352
+ }
19353
+ function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
19354
+ return new Promise((resolve6) => {
19355
+ const child = spawn7(process.execPath, [process.argv[1] ?? "", ...args], { stdio: ["ignore", "pipe", "pipe"] });
19356
+ let stdout = "";
19357
+ let stderr = "";
19358
+ let pending = "";
19359
+ child.stdout.on("data", (d) => {
19360
+ stdout += d.toString("utf8");
19361
+ });
19362
+ child.stderr.on("data", (d) => {
19363
+ const text = d.toString("utf8");
19364
+ stderr += text;
19365
+ pending += text;
19366
+ let i;
19367
+ while ((i = pending.indexOf("\n")) >= 0) {
19368
+ const line = pending.slice(0, i).trim();
19369
+ pending = pending.slice(i + 1);
19370
+ if (line) onLine(line);
19371
+ }
19372
+ });
19373
+ const t = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
19374
+ child.on("error", (e) => {
19375
+ clearTimeout(t);
19376
+ resolve6({ code: 127, stdout, stderr: `${stderr}${e.message}
19377
+ ` });
19378
+ });
19379
+ child.on("close", (code) => {
19380
+ clearTimeout(t);
19381
+ resolve6({ code: code ?? 1, stdout, stderr });
19382
+ });
19383
+ });
19384
+ }
19385
+
19386
+ // src/control-api.ts
19024
19387
  var PAUSABLE = ["dev", "design", "qa"];
19025
19388
  var CONTROL_SOCKET_NAME = "control.sock";
19026
19389
  var controlSocketFor = (stateDir) => join43(stateDir, CONTROL_SOCKET_NAME);
@@ -19051,11 +19414,26 @@ var ControlAuth = class {
19051
19414
  }
19052
19415
  return verdict;
19053
19416
  }
19054
- async ask(token) {
19417
+ /**
19418
+ * Like `verify`, but the token must belong to `workspaceId` itself, not just
19419
+ * to some workspace this ship serves. The ship's own keys pass.
19420
+ */
19421
+ async verifyIn(token, workspaceId) {
19422
+ if (!token) return "unauthorized";
19423
+ for (const k of this.o.shipKeys()) if (k && secretsMatch(token, k)) return "ok";
19424
+ const now = (this.o.now ?? Date.now)();
19425
+ const id = `${workspaceId}:${createHash9("sha256").update(token).digest("hex")}`;
19426
+ const hit = this.cache.get(id);
19427
+ if (hit && now - hit.at < (this.o.cacheMs ?? 6e4)) return hit.verdict;
19428
+ const verdict = await this.ask(token, [workspaceId]);
19429
+ if (verdict !== "unavailable") this.cache.set(id, { verdict, at: now });
19430
+ return verdict;
19431
+ }
19432
+ async ask(token, workspaceIds = this.o.workspaceIds) {
19055
19433
  const doFetch = this.o.fetch ?? fetch;
19056
19434
  const base = this.o.baseUrl.replace(/\/+$/, "");
19057
19435
  let forbidden = false;
19058
- for (const ws of this.o.workspaceIds) {
19436
+ for (const ws of workspaceIds) {
19059
19437
  let res;
19060
19438
  try {
19061
19439
  res = await doFetch(`${base}/api/auth/me?workspaceId=${encodeURIComponent(ws)}`, {
@@ -19206,8 +19584,8 @@ function waitForChange(dir, ms3, signal) {
19206
19584
  }
19207
19585
  });
19208
19586
  }
19209
- var reply = (status2, body) => ({ status: status2, body });
19210
- var err = (status2, message) => reply(status2, { error: message });
19587
+ var reply2 = (status2, body) => ({ status: status2, body });
19588
+ var err2 = (status2, message) => reply2(status2, { error: message });
19211
19589
  function pauseDoc(state) {
19212
19590
  return {
19213
19591
  crew: state.isPaused(),
@@ -19247,26 +19625,38 @@ async function readJsonBody(req) {
19247
19625
  async function tailReply(path, q, rotated, abort) {
19248
19626
  const after = q.get("after") ?? void 0;
19249
19627
  const tail3 = q.has("tail") ? Number(q.get("tail")) : void 0;
19250
- if (tail3 !== void 0 && !(Number.isInteger(tail3) && tail3 >= 0 && tail3 <= 1e3)) return err(400, "tail must be an integer from 0 to 1000");
19628
+ if (tail3 !== void 0 && !(Number.isInteger(tail3) && tail3 >= 0 && tail3 <= 1e3)) return err2(400, "tail must be an integer from 0 to 1000");
19251
19629
  const wait = q.has("wait") ? Number(q.get("wait")) : 0;
19252
- if (!(Number.isFinite(wait) && wait >= 0)) return err(400, "wait must be a number of seconds");
19253
- if (after !== void 0 && !decodeCursor(after)) return err(400, "after is not a cursor this API issued");
19630
+ if (!(Number.isFinite(wait) && wait >= 0)) return err2(400, "wait must be a number of seconds");
19631
+ if (after !== void 0 && !decodeCursor(after)) return err2(400, "after is not a cursor this API issued");
19254
19632
  const deadline = Date.now() + Math.min(wait, MAX_WAIT_SECONDS) * 1e3;
19255
19633
  let r = readTail(path, { after, tail: tail3, rotated });
19256
19634
  while (after && !r.lines.length && !r.reset && Date.now() < deadline && !abort.aborted) {
19257
19635
  await waitForChange(dirname21(path), deadline - Date.now(), abort);
19258
19636
  r = readTail(path, { after: r.cursor, rotated });
19259
19637
  }
19260
- return reply(200, r);
19638
+ return reply2(200, r);
19261
19639
  }
19262
19640
  async function route(deps, method, url, req, abort) {
19263
19641
  const p = url.pathname.replace(/\/+$/, "") || "/";
19264
19642
  const get2 = method === "GET";
19265
19643
  const post3 = method === "POST";
19644
+ if (isPeriscopePath(p)) {
19645
+ const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]?.trim() ?? "";
19646
+ const body = method === "POST" ? await readJsonBody(req) : {};
19647
+ return handlePeriscopes(deps.periscopes, {
19648
+ method,
19649
+ path: p,
19650
+ query: url.searchParams,
19651
+ token,
19652
+ body,
19653
+ wantsStream: url.searchParams.get("stream") === "1" || (req.headers.accept ?? "").includes("application/x-ndjson")
19654
+ });
19655
+ }
19266
19656
  if (p === "/status" && get2) {
19267
19657
  const doc = await deps.status();
19268
- if (!doc) return err(502, "could not produce the status document");
19269
- return reply(200, {
19658
+ if (!doc) return err2(502, "could not produce the status document");
19659
+ return reply2(200, {
19270
19660
  ...doc,
19271
19661
  daemon: {
19272
19662
  alive: true,
@@ -19278,41 +19668,50 @@ async function route(deps, method, url, req, abort) {
19278
19668
  });
19279
19669
  }
19280
19670
  if (p === "/events" && get2) return tailReply(eventFileFor(deps.stateDir), url.searchParams, true, abort);
19281
- if (p === "/streams" && get2) return reply(200, { streams: liveRuns(deps.stateDir).map(describeStream) });
19671
+ if (p === "/streams" && get2) return reply2(200, { streams: liveRuns(deps.stateDir).map(describeStream) });
19282
19672
  if (p.startsWith("/streams/") && get2) {
19283
19673
  const run2 = decodeURIComponent(p.slice("/streams/".length));
19284
- if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return err(400, "not a run name");
19674
+ if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return err2(400, "not a run name");
19285
19675
  const file = join43(deps.stateDir, "streams", run2.endsWith(".jsonl") ? run2 : `${run2}.jsonl`);
19286
- if (!existsSync39(file)) return err(404, "no such run");
19676
+ if (!existsSync39(file)) return err2(404, "no such run");
19287
19677
  return tailReply(file, url.searchParams, false, abort);
19288
19678
  }
19289
19679
  if ((p === "/pause" || p === "/resume") && post3) {
19290
19680
  const body = await readJsonBody(req);
19291
- if (!body) return err(400, "body must be a small JSON object");
19681
+ if (!body) return err2(400, "body must be a small JSON object");
19292
19682
  const role = body.role;
19293
- if (role !== void 0 && !PAUSABLE.includes(role)) return err(400, `role must be one of ${PAUSABLE.join(", ")}`);
19683
+ if (role !== void 0 && !PAUSABLE.includes(role)) return err2(400, `role must be one of ${PAUSABLE.join(", ")}`);
19294
19684
  if (p === "/pause") deps.state.pause(role);
19295
19685
  else deps.state.resume(role);
19296
- return reply(200, { ok: true, pause: pauseDoc(deps.state) });
19686
+ return reply2(200, { ok: true, pause: pauseDoc(deps.state) });
19297
19687
  }
19298
19688
  if ((p === "/release/pause" || p === "/release/resume") && post3) {
19299
19689
  deps.state.setReleasePaused(p === "/release/pause");
19300
- return reply(200, { ok: true, pause: pauseDoc(deps.state) });
19690
+ return reply2(200, { ok: true, pause: pauseDoc(deps.state) });
19301
19691
  }
19302
19692
  const sched = /^\/scheduler\/(start|stop|restart)$/.exec(p);
19303
19693
  if (sched && post3) {
19304
19694
  const r = deps.scheduler(sched[1]);
19305
- return r.accepted ? reply(202, { accepted: true, detail: r.detail }) : err(409, r.detail);
19695
+ return r.accepted ? reply2(202, { accepted: true, detail: r.detail }) : err2(409, r.detail);
19306
19696
  }
19307
19697
  if (p === "/doctor" && get2) {
19308
19698
  const doc = await deps.doctor();
19309
- return doc ? reply(200, doc) : err(502, "could not produce the doctor report");
19699
+ return doc ? reply2(200, doc) : err2(502, "could not produce the doctor report");
19310
19700
  }
19311
19701
  const known = ["/status", "/events", "/streams", "/pause", "/resume", "/release/pause", "/release/resume", "/doctor"];
19312
- if (known.includes(p) || p.startsWith("/streams/") || sched) return err(405, "method not allowed");
19313
- return err(404, "not found");
19702
+ if (known.includes(p) || p.startsWith("/streams/") || sched) return err2(405, "method not allowed");
19703
+ return err2(404, "not found");
19314
19704
  }
19315
19705
  function send(res, r) {
19706
+ if (r.stream) {
19707
+ res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
19708
+ return r.stream((ev) => {
19709
+ if (!res.writableEnded) res.write(`${JSON.stringify(ev)}
19710
+ `);
19711
+ }).then(() => {
19712
+ res.end();
19713
+ });
19714
+ }
19316
19715
  const body = JSON.stringify(r.body);
19317
19716
  res.writeHead(r.status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store" });
19318
19717
  res.end(body);
@@ -19348,13 +19747,13 @@ async function startControlServer(deps) {
19348
19747
  try {
19349
19748
  const m = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");
19350
19749
  const verdict = await deps.auth.verify(m?.[1]?.trim() ?? "");
19351
- if (verdict === "unauthorized") return send(res, err(401, "invalid or revoked key"));
19352
- if (verdict === "forbidden") return send(res, err(403, "this key does not belong to a workspace this ship serves"));
19353
- if (verdict === "unavailable") return send(res, err(503, "could not verify the key right now"));
19354
- send(res, await route(deps, req.method ?? "GET", new URL(req.url ?? "/", "http://ship"), req, abort.signal));
19750
+ if (verdict === "unauthorized") return send(res, err2(401, "invalid or revoked key"));
19751
+ if (verdict === "forbidden") return send(res, err2(403, "this key does not belong to a workspace this ship serves"));
19752
+ if (verdict === "unavailable") return send(res, err2(503, "could not verify the key right now"));
19753
+ await send(res, await route(deps, req.method ?? "GET", new URL(req.url ?? "/", "http://ship"), req, abort.signal));
19355
19754
  } catch (e) {
19356
19755
  log(`control API request failed: ${e.message}`);
19357
- if (!res.headersSent) send(res, err(500, "internal error"));
19756
+ if (!res.headersSent) send(res, err2(500, "internal error"));
19358
19757
  else res.end();
19359
19758
  }
19360
19759
  })();
@@ -19384,7 +19783,7 @@ async function startControlServer(deps) {
19384
19783
  });
19385
19784
  }
19386
19785
  var shq2 = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
19387
- function runDetached(commands, spawnFn = spawn7) {
19786
+ function runDetached(commands, spawnFn = spawn8) {
19388
19787
  const script = `sleep 1; ${commands.map((c) => c.map(shq2).join(" ")).join("; ")}`;
19389
19788
  const child = spawnFn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" });
19390
19789
  child.on("error", () => {
@@ -20790,8 +21189,8 @@ ${cu.tail.trimEnd()}
20790
21189
  `swept ${r.parked} parked, ${r.restored} restored${r.failed ? `, ${r.failed} failed` : ""}${r.contended ? `, ${r.contended} contended` : ""} (${e.route.route})`,
20791
21190
  { step: "sweep", data: r }
20792
21191
  );
20793
- } catch (err2) {
20794
- remit.warn(`could not sweep ${e.route.route}: ${err2.message}`, { step: "sweep" });
21192
+ } catch (err3) {
21193
+ remit.warn(`could not sweep ${e.route.route}: ${err3.message}`, { step: "sweep" });
20795
21194
  }
20796
21195
  }
20797
21196
  process.stdout.write(renderFleet(fleet, state));
@@ -21307,21 +21706,30 @@ ${cu.tail.trimEnd()}
21307
21706
  noteDaemonStarted(cfg.ship.stateDir);
21308
21707
  const spawnBreaker = new SpawnBreaker();
21309
21708
  const controlRoutes = cfg.routes.filter((r) => r.resolved?.workspaceId);
21709
+ const controlAuth = new ControlAuth({
21710
+ baseUrl: route2.baseUrl || DEFAULT_BASE_URL,
21711
+ workspaceIds: [...new Set(controlRoutes.map((r) => r.resolved.workspaceId))],
21712
+ shipKeys: () => cfg.routes.flatMap((r) => {
21713
+ try {
21714
+ return [resolveApiKey(r)];
21715
+ } catch {
21716
+ return [];
21717
+ }
21718
+ }),
21719
+ userAgent: cfg.ship.userAgent
21720
+ });
21310
21721
  const controlServer = await startControlServer({
21311
21722
  stateDir: cfg.ship.stateDir,
21312
21723
  state,
21313
- auth: new ControlAuth({
21314
- baseUrl: route2.baseUrl || DEFAULT_BASE_URL,
21315
- workspaceIds: [...new Set(controlRoutes.map((r) => r.resolved.workspaceId))],
21316
- shipKeys: () => cfg.routes.flatMap((r) => {
21317
- try {
21318
- return [resolveApiKey(r)];
21319
- } catch {
21320
- return [];
21321
- }
21322
- }),
21323
- userAgent: cfg.ship.userAgent
21324
- }),
21724
+ auth: controlAuth,
21725
+ // CREW-2859: Passenger periscopes, only on a route that sets hostPeriscopes.
21726
+ periscopes: {
21727
+ routes: controlRoutes.filter((r) => r.enabled).map((r) => ({ route: r.route, workspaceId: r.resolved.workspaceId, hostPeriscopes: r.hostPeriscopes === true })),
21728
+ previewHost: cfg.ship.previewHost,
21729
+ maxPeriscopes: cfg.ship.maxPeriscopes,
21730
+ run: runCrew,
21731
+ verifyIn: (token, ws) => controlAuth.verifyIn(token, ws)
21732
+ },
21325
21733
  status: () => crewJson(["status", route2.route, "--json"]),
21326
21734
  doctor: () => crewJson(["doctor", route2.route, "--json", "--offline"]),
21327
21735
  scheduler: schedulerHandler(planDaemonControl(CREW_HOME, hostPlatform(), detectSystemd())),
@@ -21346,11 +21754,11 @@ ${cu.tail.trimEnd()}
21346
21754
  `)
21347
21755
  })
21348
21756
  }),
21349
- onTransient: ({ err: err2, consecutive, backoffMs }) => emit.warn(
21350
- `tracker unavailable (${describeCause(err2)}); attempt ${consecutive}, retrying in ${Math.round(backoffMs / 1e3)}s`
21757
+ onTransient: ({ err: err3, consecutive, backoffMs }) => emit.warn(
21758
+ `tracker unavailable (${describeCause(err3)}); attempt ${consecutive}, retrying in ${Math.round(backoffMs / 1e3)}s`
21351
21759
  ),
21352
- onOutage: ({ err: err2, downMs }) => {
21353
- const msg = `tracker unreachable for ${Math.max(1, Math.round(downMs / 6e4))} minutes: ${describeCause(err2)}`;
21760
+ onOutage: ({ err: err3, downMs }) => {
21761
+ const msg = `tracker unreachable for ${Math.max(1, Math.round(downMs / 6e4))} minutes: ${describeCause(err3)}`;
21354
21762
  emit.error(msg);
21355
21763
  void raiseShipAttention({ state, emit, route: route2, ship: cfg.ship }, {
21356
21764
  kind: "tracker_unreachable",
@@ -21363,8 +21771,8 @@ ${cu.tail.trimEnd()}
21363
21771
  clearShipAttention(state, TRACKER_OUTAGE_KEY);
21364
21772
  emit.emit(`tracker is back after ${Math.max(1, Math.round(downMs / 1e3))}s`);
21365
21773
  },
21366
- onPassError: (err2) => emit.error(
21367
- `daemon pass failed, continuing: ${err2 instanceof Error ? err2.stack ?? err2.message : String(err2)}`
21774
+ onPassError: (err3) => emit.error(
21775
+ `daemon pass failed, continuing: ${err3 instanceof Error ? err3.stack ?? err3.message : String(err3)}`
21368
21776
  ),
21369
21777
  checkStale: makeStaleChecker(watchPaths, { reloadSentinel: reloadSentinelFor(cfg.ship.stateDir) }),
21370
21778
  onPass: (result) => {
@@ -23289,7 +23697,7 @@ ${resumed.url}
23289
23697
  `);
23290
23698
  break;
23291
23699
  }
23292
- const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
23700
+ const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), requestedBy: value("requested-by"), isolate: flag("isolate"), json: jsonP });
23293
23701
  let a;
23294
23702
  if (parsedUp.repo !== void 0) a = { ...parsedUp, repo: parsedUp.repo };
23295
23703
  else {
@@ -23347,7 +23755,7 @@ ${where.join("\n")}`);
23347
23755
  title,
23348
23756
  description: `A periscope: a throwaway preview of ${a.repo} for a viewer, opened by \`crew periscope up\`.`,
23349
23757
  report_type: "feature",
23350
- reporter_name: operatorName,
23758
+ reporter_name: a.requestedBy ?? operatorName,
23351
23759
  [cP.columns.status]: cP.statuses.draft,
23352
23760
  [cP.columns.assignee]: pairId,
23353
23761
  ...routeP.resolved.areaId ? { [cP.columns.slice]: routeP.resolved.areaId } : {},
@@ -24150,7 +24558,7 @@ watermark: ${state.watermark()}
24150
24558
  );
24151
24559
  }
24152
24560
  for (const x of runs) {
24153
- process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
24561
+ process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt}${x.deadline ? `, deadline ${x.deadline}` : ""})
24154
24562
  `);
24155
24563
  }
24156
24564
  if (shipRows.length) {
@@ -24360,6 +24768,11 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24360
24768
  const missingAuthorship = await tracker.missingAuthorshipColumns();
24361
24769
  process.stdout.write(
24362
24770
  missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
24771
+ `
24772
+ );
24773
+ const missingRuntime = await tracker.missingRuntimeColumns();
24774
+ process.stdout.write(
24775
+ missingRuntime.length === 0 ? "runtime columns: forge_host, kind, instance_class, relay_url present\n" : `runtime columns: missing ${missingRuntime.join(", ")} \u2014 read as their defaults; update the Issues template
24363
24776
  `
24364
24777
  );
24365
24778
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",