@tablation/crew 0.87.1 → 0.89.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 +553 -167
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4225,8 +4225,8 @@ var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
4225
4225
  function checkoutLockPath(dir) {
4226
4226
  return `${dir}.sync.lock`;
4227
4227
  }
4228
- function sleepSync(ms2) {
4229
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
4228
+ function sleepSync(ms3) {
4229
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms3);
4230
4230
  }
4231
4231
  function acquireCheckoutLock(dir, timeoutMs = 3e4) {
4232
4232
  const lockPath = checkoutLockPath(dir);
@@ -6980,6 +6980,25 @@ var Tracker = class {
6980
6980
  });
6981
6981
  return rows.length;
6982
6982
  }
6983
+ /** This ship's `live` Periscopes rows (EPIC-077); empty without the table or a Ships row. */
6984
+ async liveShipPeriscopes() {
6985
+ const modelId = this.route.resolved?.periscopesModelId;
6986
+ const { shipId } = await this.authorshipProbe();
6987
+ if (!modelId || !shipId) return [];
6988
+ return this.client.records.list(modelId, {
6989
+ filters: encodeFilters([
6990
+ { columnName: "ship_id", operator: "EQ", value: shipId },
6991
+ { columnName: "state", operator: "EQ", value: "live" }
6992
+ ]),
6993
+ limit: 200
6994
+ });
6995
+ }
6996
+ /** Patches one Periscopes row (EPIC-077). */
6997
+ async updatePeriscope(rowId, fields) {
6998
+ const modelId = this.route.resolved?.periscopesModelId;
6999
+ if (!modelId) throw new Error("this workspace has no Periscopes table");
7000
+ await this.client.records.update(modelId, rowId, fields);
7001
+ }
6983
7002
  /** Records a Periscopes row for this ship (EPIC-077); `ship_id` is stamped here, never by the caller. */
6984
7003
  async createPeriscope(fields) {
6985
7004
  const modelId = this.route.resolved?.periscopesModelId;
@@ -7008,12 +7027,6 @@ var Tracker = class {
7008
7027
  }
7009
7028
  return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
7010
7029
  }
7011
- /** Updates one Periscopes row (EPIC-077). */
7012
- async updatePeriscope(id, patch2) {
7013
- const modelId = this.route.resolved?.periscopesModelId;
7014
- if (!modelId) throw new Error("this workspace has no Periscopes table");
7015
- await this.client.records.update(modelId, id, patch2);
7016
- }
7017
7030
  /**
7018
7031
  * The authorship columns this workspace is missing, as `table.column` names
7019
7032
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -9096,10 +9109,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
9096
9109
  });
9097
9110
  } catch {
9098
9111
  }
9099
- const ms2 = Date.now() - Date.parse(startedAt);
9112
+ const ms3 = Date.now() - Date.parse(startedAt);
9100
9113
  emit.emit(end.interrupted ? "agent run interrupted" : `agent run finished (${pending ? "hand-off pending" : `exit ${end.code ?? "unknown"}`})`, {
9101
9114
  data: {
9102
- ms: ms2,
9115
+ ms: ms3,
9103
9116
  code: end.code,
9104
9117
  ...pending ? { handoff: "pending" } : {},
9105
9118
  ...end.interrupted ? { interrupted: end.interrupted } : {},
@@ -9166,7 +9179,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
9166
9179
  nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
9167
9180
  });
9168
9181
  emit.warn(`could not report agent log (${e.message}); queued to retry`, { step: "agent" });
9169
- return { ms: ms2, ticket: attributedTicket, handoffPending: !!pending };
9182
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
9170
9183
  } catch {
9171
9184
  }
9172
9185
  }
@@ -9182,7 +9195,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
9182
9195
  emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
9183
9196
  }
9184
9197
  }
9185
- return { ms: ms2, ticket: attributedTicket, handoffPending: !!pending };
9198
+ return { ms: ms3, ticket: attributedTicket, handoffPending: !!pending };
9186
9199
  }
9187
9200
  function spawnAgent(plan, emit, hintMoved) {
9188
9201
  const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
@@ -9237,8 +9250,8 @@ function spawnAgent(plan, emit, hintMoved) {
9237
9250
  const meta = { pid: child.pid ?? null };
9238
9251
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9239
9252
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9240
- const tail2 = new FileTail(plan.streamPath);
9241
- const timer = setInterval(() => obs.feed(tail2.poll()), TAIL_POLL_MS);
9253
+ const tail3 = new FileTail(plan.streamPath);
9254
+ const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
9242
9255
  let spawnFailed = false;
9243
9256
  child.on("error", (err) => {
9244
9257
  spawnFailed = true;
@@ -9247,12 +9260,12 @@ function spawnAgent(plan, emit, hintMoved) {
9247
9260
  });
9248
9261
  child.on("close", async (code) => {
9249
9262
  clearInterval(timer);
9250
- obs.feed(tail2.poll());
9263
+ obs.feed(tail3.poll());
9251
9264
  obs.end();
9252
9265
  await endSink(eventsSink);
9253
9266
  if (spawnFailed) return;
9254
- const { ms: ms2, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
9255
- resolve6({ code: code ?? 1, ms: ms2, ...handoffPending ? { handoffPending } : {} });
9267
+ const { ms: ms3, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
9268
+ resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {} });
9256
9269
  });
9257
9270
  child.stdin?.on("error", (err) => {
9258
9271
  emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
@@ -9266,24 +9279,24 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9266
9279
  const id = { ...sc, streamPath };
9267
9280
  const eventsSink = openSink(sc.eventsPath);
9268
9281
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9269
- const tail2 = new FileTail(streamPath);
9282
+ const tail3 = new FileTail(streamPath);
9270
9283
  obs.muted = true;
9271
- obs.feed(tail2.poll());
9284
+ obs.feed(tail3.poll());
9272
9285
  obs.muted = false;
9273
9286
  const adopted = isAlive(sc);
9274
9287
  if (adopted) {
9275
9288
  o.onAdopted?.();
9276
9289
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9277
9290
  while (isAlive(sc)) {
9278
- obs.feed(tail2.poll());
9291
+ obs.feed(tail3.poll());
9279
9292
  await new Promise((r) => setTimeout(r, pollMs));
9280
9293
  }
9281
9294
  }
9282
- obs.feed(tail2.poll());
9295
+ obs.feed(tail3.poll());
9283
9296
  obs.end();
9284
9297
  await endSink(eventsSink);
9285
9298
  const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9286
- const { ms: ms2, ticket } = await finishRun(
9299
+ const { ms: ms3, ticket } = await finishRun(
9287
9300
  id,
9288
9301
  obs,
9289
9302
  emit,
@@ -9291,7 +9304,7 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9291
9304
  { code: null, ...interrupted ? { interrupted } : {} },
9292
9305
  o.agentLogKey
9293
9306
  );
9294
- return { adopted, ...interrupted ? { interrupted } : {}, ms: ms2, ticket };
9307
+ return { adopted, ...interrupted ? { interrupted } : {}, ms: ms3, ticket };
9295
9308
  }
9296
9309
 
9297
9310
  // src/seat-keys.ts
@@ -9790,7 +9803,7 @@ function doctorSelftestLines(stored, toolchains) {
9790
9803
  }
9791
9804
 
9792
9805
  // src/cli.ts
9793
- import { resolve as resolve5, dirname as dirname20, join as join39, basename as basename12 } from "path";
9806
+ import { resolve as resolve5, dirname as dirname20, join as join41, basename as basename12 } from "path";
9794
9807
  import { fileURLToPath } from "url";
9795
9808
  import { hostname as hostname2, homedir as homedir10 } from "os";
9796
9809
  import { spawnSync as spawnSync7 } from "child_process";
@@ -9829,7 +9842,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9829
9842
  "workspace-id",
9830
9843
  "write",
9831
9844
  "topic",
9832
- "assignee"
9845
+ "assignee",
9846
+ "issue",
9847
+ "resume"
9833
9848
  ]);
9834
9849
  function positionalArgs(argv2) {
9835
9850
  const out = [];
@@ -13169,7 +13184,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13169
13184
  }
13170
13185
  if (culprits.length === 0 && truncated) {
13171
13186
  let lo = 2, hi = applied.length;
13172
- let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13187
+ let tail3 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13173
13188
  while (lo < hi) {
13174
13189
  const mid = Math.floor((lo + hi) / 2);
13175
13190
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
@@ -13184,12 +13199,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13184
13199
  const r = clean ? await hook(o, name) : null;
13185
13200
  if (r && r.code !== 0) {
13186
13201
  hi = mid;
13187
- tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13202
+ tail3 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13188
13203
  } else lo = mid + 1;
13189
13204
  }
13190
13205
  const owner = applied[hi - 1];
13191
13206
  o.emit.warn(`${owner.branch} is the first branch whose prefix fails the ${name} hook (bisected over ${applied.length})`, { ticket: owner.ticket.issue_id });
13192
- culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail2 });
13207
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail3 });
13193
13208
  }
13194
13209
  }
13195
13210
  }
@@ -14301,8 +14316,8 @@ async function decideFleet(o) {
14301
14316
  }
14302
14317
  var LOUD_STREAK = 5;
14303
14318
  function since(iso, now = Date.now()) {
14304
- const ms2 = Math.max(0, now - Date.parse(iso));
14305
- const mins = Math.round(ms2 / 6e4);
14319
+ const ms3 = Math.max(0, now - Date.parse(iso));
14320
+ const mins = Math.round(ms3 / 6e4);
14306
14321
  if (mins < 60) return `${mins}m`;
14307
14322
  const hours = Math.round(mins / 60);
14308
14323
  if (hours < 48) return `${hours}h`;
@@ -16197,6 +16212,7 @@ function planWorktreeSweep(target, tickets, contract) {
16197
16212
  for (const w of worktrees(target.dir).slice(1)) {
16198
16213
  const ticket = ticketForWorktree(w, byNumber, contract);
16199
16214
  if (!ticket) continue;
16215
+ if (ticket.status === contract.statuses.draft) continue;
16200
16216
  const isVerified = ticket.status === verified;
16201
16217
  if (!isVerified && !terminal.has(ticket.status)) continue;
16202
16218
  actions.push({
@@ -16209,6 +16225,15 @@ function planWorktreeSweep(target, tickets, contract) {
16209
16225
  }
16210
16226
  return actions;
16211
16227
  }
16228
+ function draftWorktrees(target, tickets, contract) {
16229
+ const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
16230
+ const out = [];
16231
+ for (const w of worktrees(target.dir).slice(1)) {
16232
+ const ticket = ticketForWorktree(w, byNumber, contract);
16233
+ if (ticket && ticket.status === contract.statuses.draft) out.push({ ticket, path: w.path });
16234
+ }
16235
+ return out;
16236
+ }
16212
16237
  var DIR_NUMBER = /(?:^|-)(\d+)$/;
16213
16238
  var DIR_NUMBER_SUFFIXED = /(?:^|-)(\d+)(?:-[a-z0-9]{1,8})?$/;
16214
16239
  var BRANCH_STEM = /^(?:.*\/)?([^/]+?-\d+)(?:-.*)?$/;
@@ -16508,24 +16533,55 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16508
16533
  }
16509
16534
 
16510
16535
  // src/cli.ts
16511
- import { realpathSync as realpathSync3, readFileSync as readFileSync32, existsSync as existsSync36, mkdirSync as mkdirSync23, writeFileSync as writeFileSync24, rmSync as rmSync16, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync17 } from "fs";
16536
+ import { realpathSync as realpathSync3, readFileSync as readFileSync33, existsSync as existsSync37, mkdirSync as mkdirSync24, writeFileSync as writeFileSync25, rmSync as rmSync17, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync11, readdirSync as readdirSync17 } from "fs";
16512
16537
  import { createInterface } from "readline/promises";
16513
16538
 
16514
16539
  // src/periscope-cmd.ts
16540
+ import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, rmSync as rmSync15, writeFileSync as writeFileSync21 } from "fs";
16541
+ import { join as join35 } from "path";
16515
16542
  var PeriscopeError = class extends Error {
16516
16543
  };
16544
+ var priorDir = (stateDir) => join35(stateDir, "periscope-priors");
16545
+ var priorPath = (stateDir, ticketRecordId) => join35(priorDir(stateDir), `${ticketRecordId}.json`);
16546
+ function removePeriscopePrior(stateDir, ticketRecordId) {
16547
+ rmSync15(priorPath(stateDir, ticketRecordId), { force: true });
16548
+ }
16549
+ function writePeriscopePrior(stateDir, ticketRecordId, prior) {
16550
+ try {
16551
+ mkdirSync19(priorDir(stateDir), { recursive: true });
16552
+ writeFileSync21(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
16553
+ } catch {
16554
+ }
16555
+ }
16556
+ function readPeriscopePrior(stateDir, ticketRecordId) {
16557
+ try {
16558
+ const p = priorPath(stateDir, ticketRecordId);
16559
+ return existsSync32(p) ? JSON.parse(readFileSync28(p, "utf8")) : void 0;
16560
+ } catch {
16561
+ return void 0;
16562
+ }
16563
+ }
16517
16564
  var DEFAULT_MAX_PERISCOPES2 = 3;
16518
16565
  var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16519
16566
  function parsePeriscopeUpArgs(positional2, flags) {
16567
+ const usage2 = "usage: crew periscope up [route] REPO [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]";
16520
16568
  const rest = positional2.slice(2);
16521
- if (rest.length === 0 || rest.length > 2) {
16522
- throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
16523
- }
16569
+ if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16524
16570
  const topic = flags.topic?.trim();
16571
+ let issue;
16572
+ if (flags.issue !== void 0) {
16573
+ if (topic) throw new PeriscopeError(`--issue and --topic cannot be combined: --issue periscopes a ticket that already exists
16574
+ ${usage2}`);
16575
+ const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(flags.issue.trim());
16576
+ if (!m) throw new PeriscopeError(`"${flags.issue}" is not a ticket number
16577
+ ${usage2}`);
16578
+ issue = String(Number.parseInt(m[1], 10));
16579
+ }
16525
16580
  return {
16526
16581
  ...rest.length === 2 ? { route: rest[0] } : {},
16527
16582
  repo: rest[rest.length - 1],
16528
16583
  ...topic ? { topic } : {},
16584
+ ...issue ? { issue } : {},
16529
16585
  isolate: flags.isolate,
16530
16586
  ...flags.host ? { host: flags.host } : {},
16531
16587
  ...flags.assignee ? { assignee: flags.assignee } : {},
@@ -16550,6 +16606,17 @@ function periscopeCaps(l) {
16550
16606
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
16551
16607
  };
16552
16608
  }
16609
+ async function existingRefusal(e, t) {
16610
+ if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
16611
+ if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
16612
+ if (t.assignee && t.assignee !== e.pairId) {
16613
+ const agent = await e.agentHolder(t.assignee);
16614
+ if (agent) return `${t.tag} is held by ${agent}`;
16615
+ }
16616
+ if (t.heldByShipId && e.shipId && t.heldByShipId !== e.shipId) return `${t.tag} is held by another ship (${t.heldByShipId})`;
16617
+ if (e.hasLiveRun(t.id)) return `${t.tag} has a run in flight on this ship`;
16618
+ return void 0;
16619
+ }
16553
16620
  var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16554
16621
  function progressComment(url, handoff) {
16555
16622
  return [
@@ -16571,17 +16638,36 @@ async function periscopePreflight(d, repo) {
16571
16638
  }
16572
16639
  async function runPeriscopeUp(d, args) {
16573
16640
  await periscopePreflight(d, args.repo);
16574
- const topic = args.topic ?? (await d.askTopic())?.trim();
16575
- if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16576
- const unsafe = d.baseProblem();
16577
- if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16578
- const draft = await d.fileDraft(`Periscope: ${topic}`);
16579
- d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16641
+ const ex = d.existing;
16642
+ let draft;
16643
+ let prior;
16644
+ if (args.issue) {
16645
+ if (!ex) throw new PeriscopeError("--issue is not supported here");
16646
+ prior = await ex.load(args.issue);
16647
+ if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
16648
+ const refused = await existingRefusal(ex, prior);
16649
+ if (refused) throw new PeriscopeError(refused);
16650
+ const unsafe = d.baseProblem();
16651
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16652
+ await ex.hold(prior.id);
16653
+ ex.remember(prior.id, { assignee: prior.assignee, status: prior.status });
16654
+ draft = prior;
16655
+ d.say("held", `holding ${prior.tag} (status ${prior.status} stays)`, { ticket: prior.tag, ticketRecordId: prior.id });
16656
+ } else {
16657
+ const topic = args.topic ?? (await d.askTopic())?.trim();
16658
+ if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16659
+ const unsafe = d.baseProblem();
16660
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16661
+ draft = await d.fileDraft(`Periscope: ${topic}`);
16662
+ d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16663
+ }
16580
16664
  let wt;
16581
16665
  const abandon = async (why) => {
16582
16666
  if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
16583
16667
  });
16584
- await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16668
+ if (prior) await d.existing.restore(prior.id, prior.assignee).catch(() => {
16669
+ });
16670
+ else await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16585
16671
  });
16586
16672
  throw new PeriscopeError(why);
16587
16673
  };
@@ -16658,7 +16744,15 @@ ${tail(preview.output)}`);
16658
16744
  await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
16659
16745
  d.say("warning", `progress comment not posted: ${e.message}`);
16660
16746
  });
16661
- return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16747
+ return {
16748
+ ticket: draft.tag,
16749
+ ticketRecordId: draft.id,
16750
+ worktree: wt.path,
16751
+ branch: wt.branch,
16752
+ url,
16753
+ periscopeRecordId: row.id,
16754
+ ...prior ? { existing: true, priorStatus: prior.status } : {}
16755
+ };
16662
16756
  }
16663
16757
  function parsePeriscopeDownArgs(positional2, flags) {
16664
16758
  const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
@@ -16707,6 +16801,21 @@ async function runPeriscopeDown(d, args) {
16707
16801
  d.say("unchanged", `${done.ticket} is already ${done.state}; nothing to do`);
16708
16802
  return { ticket: done.ticket, mode: args.mode, outcome: "unchanged", state: done.state };
16709
16803
  }
16804
+ const existing = (await d.readTicket(row.ticketRecordId)).isDraft === false;
16805
+ const prior = existing ? d.priorHold?.(row.ticketRecordId) : void 0;
16806
+ if (existing && args.mode === "discard") {
16807
+ const killed2 = await d.stopServer(row.worktree, row.number);
16808
+ d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
16809
+ const onRemote = d.remoteHasBranch?.(row.worktree, row.branch) ?? true;
16810
+ d.removeWorktree(row.worktree, row.branch, !onRemote);
16811
+ d.say("worktree", onRemote ? `removed ${row.worktree}; ${row.branch} stays, it is on the remote` : `removed ${row.worktree} and branch ${row.branch}`);
16812
+ await d.updateTicket(row.ticketRecordId, { status: "released", fillProjectAndRepo: false });
16813
+ d.say("ticket", `${row.ticket} hold cleared; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16814
+ await d.setRowState(row.id, "discarded");
16815
+ d.say("row", "row is discarded");
16816
+ forgetLocal(d, row.ticketRecordId);
16817
+ return { ticket: row.ticket, mode: "discard", outcome: "done", state: "discarded", existing: true, ...prior ? { status: prior.status } : {} };
16818
+ }
16710
16819
  if (args.mode === "discard") {
16711
16820
  const killed2 = await d.stopServer(row.worktree, row.number);
16712
16821
  d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
@@ -16738,12 +16847,17 @@ async function runPeriscopeDown(d, args) {
16738
16847
  const description = t.description.includes(PROTOTYPE_HEADING) ? t.description : `${t.description.trimEnd()}
16739
16848
 
16740
16849
  ${section}`;
16741
- await d.updateTicket(row.ticketRecordId, {
16742
- description,
16743
- fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16744
- status: "new"
16745
- });
16746
- d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16850
+ if (existing) {
16851
+ await d.updateTicket(row.ticketRecordId, { description, fillProjectAndRepo: false, status: "released" });
16852
+ d.say("ticket", `${row.ticket} has a Prototype section and no assignee; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16853
+ } else {
16854
+ await d.updateTicket(row.ticketRecordId, {
16855
+ description,
16856
+ fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16857
+ status: "new"
16858
+ });
16859
+ d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16860
+ }
16747
16861
  await d.postPushed(row.ticketRecordId, pushed.sha, pushed.remote, row.branch);
16748
16862
  const killed = await d.stopServer(row.worktree, row.number);
16749
16863
  d.say("server", killed ? `stopped ${killed} process(es)` : "no server was listening");
@@ -16752,7 +16866,7 @@ ${section}`;
16752
16866
  await d.setRowState(row.id, "kept");
16753
16867
  d.say("row", "row is kept");
16754
16868
  forgetLocal(d, row.ticketRecordId);
16755
- return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha };
16869
+ return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha, ...existing ? { existing: true, ...prior ? { status: prior.status } : {} } : {} };
16756
16870
  }
16757
16871
  function periscopeListEntries(rows, now, isListening) {
16758
16872
  return rows.filter((r) => r.state === "live").map((r) => {
@@ -16773,25 +16887,157 @@ function formatPeriscopeList(entries) {
16773
16887
  ].join(" ")).join("\n");
16774
16888
  }
16775
16889
 
16890
+ // src/periscope-resume.ts
16891
+ var tail2 = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16892
+ async function runPeriscopeResume(d, number) {
16893
+ const found = await d.find(number);
16894
+ if (!found) throw new PeriscopeError(`no live periscope for ISSUE-${number} on this ship \u2014 open one with \`crew periscope up\``);
16895
+ const { row, tag } = found;
16896
+ if (!d.worktreeExists(row.worktree)) {
16897
+ throw new PeriscopeError(`the worktree of ${tag} is gone (${row.worktree}); there is nothing to resume`);
16898
+ }
16899
+ const env = { CREW_TICKET: tag };
16900
+ if (row.branch) env.CREW_BRANCH = row.branch;
16901
+ const ports = await d.runHook("ports", row.worktree, env);
16902
+ if (ports) Object.assign(env, parseEnvLines(ports.output));
16903
+ if (row.isolated) {
16904
+ const iso = await d.runHook("isolate", row.worktree, env);
16905
+ if (iso && iso.code !== 0) throw new PeriscopeError(`isolate failed (exit ${iso.code})
16906
+ ${tail2(iso.output)}`);
16907
+ if (iso) Object.assign(env, parseEnvLines(iso.output));
16908
+ }
16909
+ const preview = await d.runHook("preview", row.worktree, { ...env, CREW_PREVIEW_HOST: row.host || "localhost" });
16910
+ if (!preview || preview.code !== 0) {
16911
+ throw new PeriscopeError(`preview exited ${preview?.code ?? "without running"}
16912
+ ${tail2(preview?.output ?? "")}`);
16913
+ }
16914
+ const url = previewUrlFrom(preview.output);
16915
+ if (!url) throw new PeriscopeError(`preview printed no URL as its last line
16916
+ ${tail2(preview.output)}`);
16917
+ const now = d.now();
16918
+ await d.update(row.id, {
16919
+ url,
16920
+ last_activity_at: now.toISOString(),
16921
+ expires_at: new Date(now.getTime() + d.caps.idleMinutes * 6e4).toISOString()
16922
+ });
16923
+ d.clearItem(tag);
16924
+ d.say("resumed", `preview at ${url}`, { url });
16925
+ return { ticket: tag, worktree: row.worktree, url, periscopeRecordId: row.id };
16926
+ }
16927
+
16928
+ // src/periscope-idle.ts
16929
+ import { execFileSync as execFileSync6 } from "child_process";
16930
+ import { statSync as statSync9 } from "fs";
16931
+ import { join as join36 } from "path";
16932
+ var MAX_SCANNED_FILES = 2e4;
16933
+ function newestWorktreeMtime(root) {
16934
+ let listing;
16935
+ try {
16936
+ listing = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
16937
+ cwd: root,
16938
+ encoding: "utf8",
16939
+ stdio: ["ignore", "pipe", "ignore"],
16940
+ maxBuffer: 64 * 1024 * 1024
16941
+ });
16942
+ } catch {
16943
+ return null;
16944
+ }
16945
+ let newest = null;
16946
+ let scanned = 0;
16947
+ for (const rel of listing.split("\0")) {
16948
+ if (!rel) continue;
16949
+ if (++scanned > MAX_SCANNED_FILES) break;
16950
+ try {
16951
+ const m = statSync9(join36(root, rel)).mtimeMs;
16952
+ if (newest === null || m > newest) newest = m;
16953
+ } catch {
16954
+ }
16955
+ }
16956
+ return newest;
16957
+ }
16958
+ var ms2 = (iso) => {
16959
+ const t = iso ? Date.parse(iso) : NaN;
16960
+ return Number.isFinite(t) ? t : 0;
16961
+ };
16962
+ function decideIdle(row, mtimeMs, nowMs, idleMinutes) {
16963
+ const idleMs = idleMinutes * 6e4;
16964
+ const recordedActivity = ms2(row.last_activity_at);
16965
+ const activity = Math.max(recordedActivity, mtimeMs ?? 0);
16966
+ const expiresAt = ms2(row.expires_at);
16967
+ if (activity > 0 && activity + idleMs > expiresAt) {
16968
+ return {
16969
+ action: "refresh",
16970
+ lastActivityAt: new Date(activity).toISOString(),
16971
+ expiresAt: new Date(activity + idleMs).toISOString()
16972
+ };
16973
+ }
16974
+ if (expiresAt > 0 && nowMs >= expiresAt) return { action: "expire", idleMs: Math.max(0, nowMs - activity) };
16975
+ return { action: "none" };
16976
+ }
16977
+ var periscopeIdleKey = (ticket) => `periscope_idle:${ticket}`;
16978
+ var fmtIdle = (idleMs) => {
16979
+ const minutes = Math.max(1, Math.round(idleMs / 6e4));
16980
+ const h = Math.floor(minutes / 60);
16981
+ return h > 0 ? `${h}h ${minutes % 60}m` : `${minutes}m`;
16982
+ };
16983
+ function periscopeIdleItem(ticket, repo, number, idleMs, nowIso) {
16984
+ return {
16985
+ kind: "periscope_idle",
16986
+ key: periscopeIdleKey(ticket),
16987
+ since: nowIso,
16988
+ message: `Periscope ${ticket} (${repo}) has been idle for ${fmtIdle(idleMs)}; its server is stopped and the worktree is untouched. End it with \`crew periscope down ${number} --keep\` (push the work) or \`crew periscope down ${number} --discard\`, or bring the server back with \`crew periscope up --resume ${number}\`.`
16989
+ };
16990
+ }
16991
+ async function runPeriscopeIdlePass(d) {
16992
+ const result = { refreshed: 0, expired: 0 };
16993
+ const now = d.now();
16994
+ const liveKeys = /* @__PURE__ */ new Set();
16995
+ let lookupFailed = false;
16996
+ for (const row of await d.listLive()) {
16997
+ try {
16998
+ const t = await d.ticketOf(row);
16999
+ liveKeys.add(periscopeIdleKey(t.tag));
17000
+ const decision = decideIdle(row, d.mtimeOf(row.worktree), now.getTime(), d.idleMinutes);
17001
+ if (decision.action === "none") continue;
17002
+ if (decision.action === "refresh") {
17003
+ await d.update(row.id, { last_activity_at: decision.lastActivityAt, expires_at: decision.expiresAt });
17004
+ d.clear(periscopeIdleKey(t.tag));
17005
+ result.refreshed++;
17006
+ continue;
17007
+ }
17008
+ await d.stopServer(row.worktree, t.number);
17009
+ await d.raise(periscopeIdleItem(t.tag, t.repo, t.number, decision.idleMs, now.toISOString()));
17010
+ result.expired++;
17011
+ } catch (e) {
17012
+ lookupFailed = true;
17013
+ d.warn(`periscope idle check failed for ${row.worktree}: ${e.message}`);
17014
+ }
17015
+ }
17016
+ if (!lookupFailed) {
17017
+ for (const key of d.openKeys()) if (!liveKeys.has(key)) d.clear(key);
17018
+ }
17019
+ return result;
17020
+ }
17021
+
16776
17022
  // src/cli.ts
16777
17023
  import { dirname as dirOf2, resolve as resolvePath } from "path";
16778
17024
 
16779
17025
  // src/ssh-keys.ts
16780
- import { execFileSync as execFileSync6 } from "child_process";
16781
- import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, chmodSync } from "fs";
16782
- import { dirname as dirname19, join as join35 } from "path";
17026
+ import { execFileSync as execFileSync7 } from "child_process";
17027
+ import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, chmodSync } from "fs";
17028
+ import { dirname as dirname19, join as join37 } from "path";
16783
17029
  var SshKeyError = class extends Error {
16784
17030
  };
16785
17031
  function shipSshKeyPathFor(stateDir) {
16786
- return join35(stateDir, "ssh", "id_ed25519");
17032
+ return join37(stateDir, "ssh", "id_ed25519");
16787
17033
  }
16788
17034
  function ensureShipSshKeypair(stateDir) {
16789
17035
  const privateKeyPath = shipSshKeyPathFor(stateDir);
16790
17036
  const publicKeyPath = `${privateKeyPath}.pub`;
16791
- if (!existsSync32(privateKeyPath)) {
16792
- mkdirSync19(dirname19(privateKeyPath), { recursive: true });
17037
+ if (!existsSync33(privateKeyPath)) {
17038
+ mkdirSync20(dirname19(privateKeyPath), { recursive: true });
16793
17039
  try {
16794
- execFileSync6("ssh-keygen", [
17040
+ execFileSync7("ssh-keygen", [
16795
17041
  "-t",
16796
17042
  "ed25519",
16797
17043
  "-N",
@@ -16809,15 +17055,15 @@ function ensureShipSshKeypair(stateDir) {
16809
17055
  }
16810
17056
  chmodSync(privateKeyPath, 384);
16811
17057
  }
16812
- if (!existsSync32(publicKeyPath)) {
17058
+ if (!existsSync33(publicKeyPath)) {
16813
17059
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
16814
17060
  }
16815
- const publicKey = readFileSync28(publicKeyPath, "utf8").trim();
17061
+ const publicKey = readFileSync29(publicKeyPath, "utf8").trim();
16816
17062
  return { privateKeyPath, publicKeyPath, publicKey };
16817
17063
  }
16818
17064
  function sshKeygenAvailable() {
16819
17065
  try {
16820
- execFileSync6("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
17066
+ execFileSync7("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
16821
17067
  return true;
16822
17068
  } catch (e) {
16823
17069
  return e.code !== "ENOENT";
@@ -16825,11 +17071,11 @@ function sshKeygenAvailable() {
16825
17071
  }
16826
17072
 
16827
17073
  // src/passenger-containers.ts
16828
- import { execFileSync as execFileSync7 } from "child_process";
17074
+ import { execFileSync as execFileSync8 } from "child_process";
16829
17075
  import { createHash as createHash6 } from "crypto";
16830
17076
  var realExec = (cmd, args, opts) => {
16831
17077
  try {
16832
- return execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
17078
+ return execFileSync8(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
16833
17079
  } catch (e) {
16834
17080
  const err = e;
16835
17081
  const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
@@ -17029,9 +17275,9 @@ function syncAllPassengerCheckouts(cfg) {
17029
17275
 
17030
17276
  // src/tunnel.ts
17031
17277
  import { spawn as spawn5 } from "child_process";
17032
- import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17278
+ import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17033
17279
  import { randomInt } from "crypto";
17034
- import { join as join36 } from "path";
17280
+ import { join as join38 } from "path";
17035
17281
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
17036
17282
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
17037
17283
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -17098,21 +17344,21 @@ function startTunnel(opts) {
17098
17344
  };
17099
17345
  }
17100
17346
  function tunnelStatePath(stateDir, workspaceId) {
17101
- return join36(stateDir, "passengers", `${workspaceId}.json`);
17347
+ return join38(stateDir, "passengers", `${workspaceId}.json`);
17102
17348
  }
17103
17349
  function readPersistedTunnel(stateDir, workspaceId) {
17104
17350
  const path = tunnelStatePath(stateDir, workspaceId);
17105
- if (!existsSync33(path)) return void 0;
17351
+ if (!existsSync34(path)) return void 0;
17106
17352
  try {
17107
- return JSON.parse(readFileSync29(path, "utf8"));
17353
+ return JSON.parse(readFileSync30(path, "utf8"));
17108
17354
  } catch {
17109
17355
  return void 0;
17110
17356
  }
17111
17357
  }
17112
17358
  function writePersistedTunnel(stateDir, state) {
17113
17359
  const path = tunnelStatePath(stateDir, state.workspaceId);
17114
- mkdirSync20(join36(stateDir, "passengers"), { recursive: true });
17115
- writeFileSync21(path, `${JSON.stringify(state, null, 2)}
17360
+ mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17361
+ writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17116
17362
  `);
17117
17363
  }
17118
17364
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -17209,8 +17455,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
17209
17455
  }
17210
17456
  }
17211
17457
  function readAllPersistedWorkspaceIds(stateDir) {
17212
- const dir = join36(stateDir, "passengers");
17213
- if (!existsSync33(dir)) return [];
17458
+ const dir = join38(stateDir, "passengers");
17459
+ if (!existsSync34(dir)) return [];
17214
17460
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
17215
17461
  }
17216
17462
 
@@ -17331,8 +17577,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
17331
17577
  import { createServer as createHttpServer } from "http";
17332
17578
  import { spawn as spawn6 } from "child_process";
17333
17579
  import { randomBytes } from "crypto";
17334
- import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17335
- import { join as join37 } from "path";
17580
+ import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, writeFileSync as writeFileSync23, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17581
+ import { join as join39 } from "path";
17336
17582
 
17337
17583
  // src/secret-compare.ts
17338
17584
  import { timingSafeEqual } from "crypto";
@@ -17421,21 +17667,21 @@ var defaultSpawn2 = (cmd, args, env) => {
17421
17667
  return child;
17422
17668
  };
17423
17669
  function syncDaemonStatePath(stateDir, workspaceId) {
17424
- return join37(stateDir, "passengers", `${workspaceId}-sync.json`);
17670
+ return join39(stateDir, "passengers", `${workspaceId}-sync.json`);
17425
17671
  }
17426
17672
  function readPersistedSyncDaemon(stateDir, workspaceId) {
17427
17673
  const path = syncDaemonStatePath(stateDir, workspaceId);
17428
- if (!existsSync34(path)) return void 0;
17674
+ if (!existsSync35(path)) return void 0;
17429
17675
  try {
17430
- return JSON.parse(readFileSync30(path, "utf8"));
17676
+ return JSON.parse(readFileSync31(path, "utf8"));
17431
17677
  } catch {
17432
17678
  return void 0;
17433
17679
  }
17434
17680
  }
17435
17681
  function writePersistedSyncDaemon(stateDir, state) {
17436
17682
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
17437
- mkdirSync21(join37(stateDir, "passengers"), { recursive: true });
17438
- writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17683
+ mkdirSync22(join39(stateDir, "passengers"), { recursive: true });
17684
+ writeFileSync23(path, `${JSON.stringify(state, null, 2)}
17439
17685
  `);
17440
17686
  }
17441
17687
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -17445,8 +17691,8 @@ function removePersistedSyncDaemon(stateDir, workspaceId) {
17445
17691
  } catch {
17446
17692
  }
17447
17693
  }
17448
- function sleepSync2(ms2) {
17449
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
17694
+ function sleepSync2(ms3) {
17695
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms3);
17450
17696
  }
17451
17697
  function waitForExit(pid, alive, timeoutMs = 5e3) {
17452
17698
  const deadline = Date.now() + timeoutMs;
@@ -17455,8 +17701,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
17455
17701
  }
17456
17702
  }
17457
17703
  function readAllPersistedSyncWorkspaceIds(stateDir) {
17458
- const dir = join37(stateDir, "passengers");
17459
- if (!existsSync34(dir)) return [];
17704
+ const dir = join39(stateDir, "passengers");
17705
+ if (!existsSync35(dir)) return [];
17460
17706
  const suffix = "-sync.json";
17461
17707
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
17462
17708
  }
@@ -17544,12 +17790,12 @@ function describeCause(err) {
17544
17790
  }
17545
17791
 
17546
17792
  // src/daemon.ts
17547
- import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, readdirSync as readdirSync16, rmSync as rmSync15, statSync as statSync9, writeFileSync as writeFileSync23 } from "fs";
17548
- import { join as join38 } from "path";
17793
+ import { existsSync as existsSync36, mkdirSync as mkdirSync23, readFileSync as readFileSync32, readdirSync as readdirSync16, rmSync as rmSync16, statSync as statSync10, writeFileSync as writeFileSync24 } from "fs";
17794
+ import { join as join40 } from "path";
17549
17795
  import { randomBytes as randomBytes2 } from "crypto";
17550
17796
  var DEFAULT_FLOOR_MS = 5e3;
17551
17797
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
17552
- var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
17798
+ var defaultSleep = (ms3) => new Promise((resolve6) => setTimeout(resolve6, ms3));
17553
17799
  async function runDaemonLoop(o) {
17554
17800
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
17555
17801
  const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
@@ -17608,7 +17854,7 @@ async function runDaemonLoop(o) {
17608
17854
  function latestMtime(path) {
17609
17855
  let stat;
17610
17856
  try {
17611
- stat = statSync9(path);
17857
+ stat = statSync10(path);
17612
17858
  } catch {
17613
17859
  return 0;
17614
17860
  }
@@ -17616,7 +17862,7 @@ function latestMtime(path) {
17616
17862
  if (!stat.isDirectory()) return 0;
17617
17863
  let latest = stat.mtimeMs;
17618
17864
  for (const entry of readdirSync16(path, { withFileTypes: true })) {
17619
- latest = Math.max(latest, latestMtime(join38(path, entry.name)));
17865
+ latest = Math.max(latest, latestMtime(join40(path, entry.name)));
17620
17866
  }
17621
17867
  return latest;
17622
17868
  }
@@ -17625,23 +17871,23 @@ function computeTreeSignature(paths) {
17625
17871
  }
17626
17872
  function makeStaleChecker(paths, opts = {}) {
17627
17873
  const initial = computeTreeSignature(paths);
17628
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync35(opts.reloadSentinel);
17874
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync36(opts.reloadSentinel);
17629
17875
  }
17630
17876
  function reloadSentinelFor(stateDir) {
17631
- return join38(stateDir, "reload-requested");
17877
+ return join40(stateDir, "reload-requested");
17632
17878
  }
17633
17879
  function daemonStampFor(stateDir) {
17634
- return join38(stateDir, "daemon.started");
17880
+ return join40(stateDir, "daemon.started");
17635
17881
  }
17636
17882
  function noteDaemonStarted(stateDir, now = Date.now()) {
17637
- mkdirSync22(stateDir, { recursive: true });
17638
- rmSync15(reloadSentinelFor(stateDir), { force: true });
17639
- writeFileSync23(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
17883
+ mkdirSync23(stateDir, { recursive: true });
17884
+ rmSync16(reloadSentinelFor(stateDir), { force: true });
17885
+ writeFileSync24(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
17640
17886
  `);
17641
17887
  }
17642
17888
  function readDaemonStarted(stateDir) {
17643
17889
  try {
17644
- const v = JSON.parse(readFileSync31(daemonStampFor(stateDir), "utf8")).startedAt;
17890
+ const v = JSON.parse(readFileSync32(daemonStampFor(stateDir), "utf8")).startedAt;
17645
17891
  return typeof v === "number" ? v : null;
17646
17892
  } catch {
17647
17893
  return null;
@@ -17650,7 +17896,7 @@ function readDaemonStarted(stateDir) {
17650
17896
  function configChangedAfter(files, startedAt) {
17651
17897
  for (const f of files) {
17652
17898
  try {
17653
- if (statSync9(f).mtimeMs > startedAt) return f;
17899
+ if (statSync10(f).mtimeMs > startedAt) return f;
17654
17900
  } catch {
17655
17901
  }
17656
17902
  }
@@ -17749,6 +17995,35 @@ async function runOnePassUnguarded(o) {
17749
17995
  emit.warn(`could not sweep stalled tickets: ${e.message}`, { step: "sweep" });
17750
17996
  }
17751
17997
  }
17998
+ if (o.route.resolved?.periscopesModelId) {
17999
+ const tracker = new Tracker(o.route, o.ship);
18000
+ try {
18001
+ await runPeriscopeIdlePass({
18002
+ idleMinutes: periscopeCaps(o.ship).idleMinutes,
18003
+ now: () => /* @__PURE__ */ new Date(),
18004
+ listLive: () => tracker.liveShipPeriscopes(),
18005
+ mtimeOf: newestWorktreeMtime,
18006
+ update: (id, fields) => tracker.updatePeriscope(id, fields),
18007
+ ticketOf: async (row) => {
18008
+ const t = await tracker.ticket(row.issue_id);
18009
+ return {
18010
+ tag: t.issue_tag ?? t.issue_id,
18011
+ number: t.issue_id.replace(/^\D+/, ""),
18012
+ repo: t.repo_id && o.route.resolved?.repoNames?.[t.repo_id] || "unknown repository"
18013
+ };
18014
+ },
18015
+ stopServer: (worktree, number) => killGently(pidsInWorktree(worktree, Number(number))),
18016
+ raise: async (item) => {
18017
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, item);
18018
+ },
18019
+ clear: (key) => clearShipAttention(o.state, key),
18020
+ openKeys: () => openShipAttention(o.state).filter((i) => i.kind === "periscope_idle").map((i) => i.key),
18021
+ warn: (message) => emit.warn(message, { step: "sweep" })
18022
+ });
18023
+ } catch (e) {
18024
+ emit.warn(`could not check idle periscopes: ${e.message}`, { step: "sweep" });
18025
+ }
18026
+ }
17752
18027
  const order = [...decision.selection.pending].sort(
17753
18028
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
17754
18029
  );
@@ -18234,7 +18509,8 @@ function usage(problem, requested = false) {
18234
18509
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
18235
18510
  crew drop [route] NNN remove a merged ticket's worktree and branch
18236
18511
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18237
- crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18512
+ crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18513
+ crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
18238
18514
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18239
18515
  crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
18240
18516
  end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
@@ -18513,7 +18789,7 @@ if (command === "passenger-sync-daemon") {
18513
18789
  } else {
18514
18790
  const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
18515
18791
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
18516
- if (configless && !existsSync36(findConfigFile(CREW_HOME))) {
18792
+ if (configless && !existsSync37(findConfigFile(CREW_HOME))) {
18517
18793
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
18518
18794
  } else {
18519
18795
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -18751,8 +19027,8 @@ if (command === "passenger-sync-daemon") {
18751
19027
  }
18752
19028
  if (host === "linux" && hasSystemd) {
18753
19029
  const label = labelFor(CREW_HOME, "run");
18754
- const staleTimer = join39(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18755
- if (existsSync36(staleTimer)) {
19030
+ const staleTimer = join41(homedir10(), ".config", "systemd", "user", `${label}.timer`);
19031
+ if (existsSync37(staleTimer)) {
18756
19032
  process.stdout.write(
18757
19033
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
18758
19034
  `
@@ -18779,7 +19055,7 @@ if (command === "passenger-sync-daemon") {
18779
19055
  const tracker = new Tracker(c, cfg.ship);
18780
19056
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
18781
19057
  for (const r of await resolvedRepos(c)) {
18782
- if (!existsSync36(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
19058
+ if (!existsSync37(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18783
19059
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
18784
19060
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
18785
19061
  }
@@ -18797,7 +19073,7 @@ if (command === "passenger-sync-daemon") {
18797
19073
  }
18798
19074
  async function doctorFix(o) {
18799
19075
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
18800
- const unitExists = (p) => existsSync36(p);
19076
+ const unitExists = (p) => existsSync37(p);
18801
19077
  const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
18802
19078
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
18803
19079
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -18814,7 +19090,7 @@ if (command === "passenger-sync-daemon") {
18814
19090
  for (const a of plan.actions) {
18815
19091
  if (a.kind === "enable") {
18816
19092
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
18817
- writeFileSync24(cfg.configFile, planEnable(readFileSync32(cfg.configFile, "utf8"), a.route));
19093
+ writeFileSync25(cfg.configFile, planEnable(readFileSync33(cfg.configFile, "utf8"), a.route));
18818
19094
  enabledSound.add(a.route);
18819
19095
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
18820
19096
  } else {
@@ -19918,7 +20194,7 @@ ${cu.tail.trimEnd()}
19918
20194
  process.exit(1);
19919
20195
  }
19920
20196
  requireArmed("run the daemon loop");
19921
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join39(CREW_HOME, "src"), join39(CREW_HOME, "dist"), join39(CREW_HOME, "bin")];
20197
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join41(CREW_HOME, "src"), join41(CREW_HOME, "dist"), join41(CREW_HOME, "bin")];
19922
20198
  const configPaths = daemonConfigPaths(cfg);
19923
20199
  watchPaths.push(...configPaths);
19924
20200
  noteDaemonStarted(cfg.ship.stateDir);
@@ -20147,10 +20423,10 @@ ${cu.tail.trimEnd()}
20147
20423
  `);
20148
20424
  if (!dryRun) {
20149
20425
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
20150
- const raw = existsSync36(resolvedPath) ? JSON.parse(readFileSync32(resolvedPath, "utf8")) : {};
20426
+ const raw = existsSync37(resolvedPath) ? JSON.parse(readFileSync33(resolvedPath, "utf8")) : {};
20151
20427
  raw.agentSkills = result.agentSkills;
20152
- mkdirSync23(dirOf2(resolvedPath), { recursive: true });
20153
- writeFileSync24(resolvedPath, `${JSON.stringify(raw, null, 2)}
20428
+ mkdirSync24(dirOf2(resolvedPath), { recursive: true });
20429
+ writeFileSync25(resolvedPath, `${JSON.stringify(raw, null, 2)}
20154
20430
  `);
20155
20431
  }
20156
20432
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -20173,7 +20449,7 @@ ${cu.tail.trimEnd()}
20173
20449
  throw e;
20174
20450
  }
20175
20451
  const oldName = cfg.ship.name;
20176
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), newName));
20452
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), newName));
20177
20453
  cfg.ship.name = newName;
20178
20454
  let renameMachineId;
20179
20455
  try {
@@ -20220,7 +20496,7 @@ ${cu.tail.trimEnd()}
20220
20496
  process.exit(2);
20221
20497
  };
20222
20498
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
20223
- const configText = readFileSync32(cfg.configFile, "utf8");
20499
+ const configText = readFileSync33(cfg.configFile, "utf8");
20224
20500
  if (sub === "check") {
20225
20501
  try {
20226
20502
  const routeArg = value("route") ?? positional[2];
@@ -20251,8 +20527,8 @@ ${cu.tail.trimEnd()}
20251
20527
  try {
20252
20528
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
20253
20529
  const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
20254
- if (!existsSync36(dir)) throw new ReposError(`${dir} does not exist on disk`);
20255
- const files = { exists: (r) => existsSync36(resolvePath(dir, r)), list: (r) => {
20530
+ if (!existsSync37(dir)) throw new ReposError(`${dir} does not exist on disk`);
20531
+ const files = { exists: (r) => existsSync37(resolvePath(dir, r)), list: (r) => {
20256
20532
  try {
20257
20533
  return readdirSync17(resolvePath(dir, r));
20258
20534
  } catch {
@@ -20260,7 +20536,7 @@ ${cu.tail.trimEnd()}
20260
20536
  }
20261
20537
  }, read: (r) => {
20262
20538
  try {
20263
- return readFileSync32(resolvePath(dir, r), "utf8");
20539
+ return readFileSync33(resolvePath(dir, r), "utf8");
20264
20540
  } catch {
20265
20541
  return void 0;
20266
20542
  }
@@ -20302,19 +20578,19 @@ ${cu.tail.trimEnd()}
20302
20578
  let cleared;
20303
20579
  if (clear2) {
20304
20580
  const plan = clearRouteHooks(configText, a.route, a.name);
20305
- if (plan.removed.length > 0) writeFileSync24(cfg.configFile, plan.text);
20581
+ if (plan.removed.length > 0) writeFileSync25(cfg.configFile, plan.text);
20306
20582
  cleared = { file: cfg.configFile, removed: plan.removed };
20307
20583
  }
20308
20584
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
20309
20585
  if (write === "route") {
20310
20586
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
20311
- writeFileSync24(cfg.configFile, plan.text);
20587
+ writeFileSync25(cfg.configFile, plan.text);
20312
20588
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
20313
20589
  } else if (write === "repo") {
20314
20590
  const have = existingRepoConfig(files);
20315
20591
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
20316
20592
  const file = resolvePath(dir, ".crew.yaml");
20317
- writeFileSync24(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20593
+ writeFileSync25(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20318
20594
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
20319
20595
  }
20320
20596
  if (flag("json")) {
@@ -20370,7 +20646,7 @@ ${cu.tail.trimEnd()}
20370
20646
  if (sub === "list" && !routeName) usage2();
20371
20647
  if (sub === "add") {
20372
20648
  try {
20373
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync36(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20649
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync37(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20374
20650
  routeName = a.route;
20375
20651
  addPath = a.path;
20376
20652
  addName = a.name;
@@ -20399,16 +20675,16 @@ ${cu.tail.trimEnd()}
20399
20675
  }
20400
20676
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
20401
20677
  let resolvedRemotes;
20402
- if (existsSync36(resolvedFile)) {
20678
+ if (existsSync37(resolvedFile)) {
20403
20679
  try {
20404
- resolvedRemotes = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20680
+ resolvedRemotes = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20405
20681
  } catch {
20406
20682
  }
20407
20683
  }
20408
20684
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
20409
20685
  const text = configText;
20410
20686
  const base = dirname20(cfg.configFile);
20411
- const isCheckout = (d) => existsSync36(`${d}/.git`);
20687
+ const isCheckout = (d) => existsSync37(`${d}/.git`);
20412
20688
  try {
20413
20689
  if (sub === "list") {
20414
20690
  if (flag("json")) {
@@ -20418,11 +20694,11 @@ ${cu.tail.trimEnd()}
20418
20694
  base,
20419
20695
  resolved: resolvedRemotes,
20420
20696
  originOf,
20421
- exists: (d) => existsSync36(d),
20697
+ exists: (d) => existsSync37(d),
20422
20698
  isCheckout,
20423
20699
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
20424
20700
  try {
20425
- return Object.keys(parseRepoConfig(readFileSync32(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20701
+ return Object.keys(parseRepoConfig(readFileSync33(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20426
20702
  } catch {
20427
20703
  return false;
20428
20704
  }
@@ -20439,7 +20715,7 @@ ${cu.tail.trimEnd()}
20439
20715
  for (const r of repos) {
20440
20716
  const origin2 = originOf(r.dir);
20441
20717
  const row = matchRepoName(origin2, resolvedRemotes);
20442
- const note = !existsSync36(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20718
+ const note = !existsSync37(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20443
20719
  process.stdout.write(`${r.name} ${r.dir} ${note}
20444
20720
  `);
20445
20721
  }
@@ -20451,9 +20727,9 @@ ${cu.tail.trimEnd()}
20451
20727
  ` : `${human}
20452
20728
  `);
20453
20729
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
20454
- if (existsSync36(plan2.target) && (existsSync36(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20730
+ if (existsSync37(plan2.target) && (existsSync37(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20455
20731
  throw new ReposError(
20456
- existsSync36(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
20732
+ existsSync37(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
20457
20733
  );
20458
20734
  }
20459
20735
  if (dryRun) {
@@ -20471,14 +20747,14 @@ ${cu.tail.trimEnd()}
20471
20747
  throw new ReposError(`${e.message}${authHint}
20472
20748
  crew.yaml was not changed`);
20473
20749
  }
20474
- writeFileSync24(cfg.configFile, plan2.text);
20750
+ writeFileSync25(cfg.configFile, plan2.text);
20475
20751
  if (plan2.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan2.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
20476
20752
  `);
20477
20753
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
20478
20754
  break;
20479
20755
  }
20480
20756
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
20481
- const top = existsSync36(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20757
+ const top = existsSync37(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20482
20758
  if (!top) throw new ReposError(`${target} is not a git checkout`);
20483
20759
  const origin = originOf(top);
20484
20760
  const plan = planRepoAdd({
@@ -20530,15 +20806,15 @@ ${cu.tail.trimEnd()}
20530
20806
  const created = await rowTracker.createRepoRow(rowPayload);
20531
20807
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
20532
20808
  `);
20533
- if (existsSync36(resolvedFile)) {
20534
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20809
+ if (existsSync37(resolvedFile)) {
20810
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20535
20811
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
20536
20812
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
20537
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
20813
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
20538
20814
  `);
20539
20815
  }
20540
20816
  }
20541
- writeFileSync24(cfg.configFile, plan.text);
20817
+ writeFileSync25(cfg.configFile, plan.text);
20542
20818
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
20543
20819
  `);
20544
20820
  } catch (e) {
@@ -20905,7 +21181,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20905
21181
  process.exit(2);
20906
21182
  }
20907
21183
  if (found.shipNameAdopted && !dryRun) {
20908
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), found.shipNameAdopted));
21184
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), found.shipNameAdopted));
20909
21185
  cfg.ship.name = found.shipNameAdopted;
20910
21186
  }
20911
21187
  if (found.shipRow) {
@@ -20948,10 +21224,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20948
21224
  }
20949
21225
  }
20950
21226
  let previous;
20951
- const hadPreviousFile = existsSync36(resolvedPath);
21227
+ const hadPreviousFile = existsSync37(resolvedPath);
20952
21228
  if (hadPreviousFile) {
20953
21229
  try {
20954
- previous = JSON.parse(readFileSync32(resolvedPath, "utf8"));
21230
+ previous = JSON.parse(readFileSync33(resolvedPath, "utf8"));
20955
21231
  } catch {
20956
21232
  }
20957
21233
  }
@@ -21080,10 +21356,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21080
21356
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
21081
21357
  `);
21082
21358
  } else {
21083
- mkdirSync23(dirname20(resolvedPath), { recursive: true });
21359
+ mkdirSync24(dirname20(resolvedPath), { recursive: true });
21084
21360
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
21085
21361
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
21086
- writeFileSync24(tmpPath, `${JSON.stringify(resolved, null, 2)}
21362
+ writeFileSync25(tmpPath, `${JSON.stringify(resolved, null, 2)}
21087
21363
  `);
21088
21364
  renameSync3(tmpPath, resolvedPath);
21089
21365
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -21129,8 +21405,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21129
21405
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
21130
21406
  `);
21131
21407
  } else {
21132
- mkdirSync23(dirname20(apiKeyFile), { recursive: true });
21133
- writeFileSync24(apiKeyFile, `${apiKeyVar}=${apiKey}
21408
+ mkdirSync24(dirname20(apiKeyFile), { recursive: true });
21409
+ writeFileSync25(apiKeyFile, `${apiKeyVar}=${apiKey}
21134
21410
  `, { mode: 384 });
21135
21411
  process.stderr.write(`API key written to ${apiKeyFile}
21136
21412
  `);
@@ -21449,6 +21725,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21449
21725
  await tracker.ticketsByNumbers(remoteBranchTicketNumbers(r.dir, r.config.branch.remote, r.config.branch.base), truncated)
21450
21726
  ).byRepo.get(r.name) ?? [];
21451
21727
  const actions = planWorktreeSweep(r, wtTickets, tracker.contract);
21728
+ for (const k of draftWorktrees(r, wtTickets, tracker.contract)) {
21729
+ anything = true;
21730
+ emit.emit(`keeping ${k.path}: ${k.ticket.issue_tag ?? k.ticket.issue_id} is a draft (a periscope) \u2014 end it with \`crew periscope down\``, { step: "worktree" });
21731
+ }
21452
21732
  const orphanDirs = orphanedWorktreeDirs(r, wtTickets, tracker.contract);
21453
21733
  warnOrphanedWorktreeDirs(orphanDirs, emit, dryRun ? void 0 : { state, repo: r.name });
21454
21734
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, brTickets, tracker.contract);
@@ -21514,6 +21794,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21514
21794
  }
21515
21795
  const { repo: target, found } = candidates[0];
21516
21796
  const wt = found.path;
21797
+ try {
21798
+ const trD = new Tracker(route, cfg.ship);
21799
+ const tD = (await trD.ticketsByNumbers([n])).find((t) => t.issue_id.replace(/^\D+/, "") === String(num));
21800
+ if (tD && tD[trD.contract.columns.status] === trD.contract.statuses.draft) {
21801
+ process.stderr.write(`drop: ${tD.issue_tag ?? tD.issue_id} is a draft ticket \u2014 its worktree belongs to a periscope; end it with \`crew periscope down ${num} --keep\` or \`--discard\`
21802
+ `);
21803
+ process.exit(2);
21804
+ }
21805
+ } catch {
21806
+ }
21517
21807
  const branch = found.branch ?? branchForIssue(target.dir, `ISSUE-${n}`, {
21518
21808
  name: target.config.branch.name,
21519
21809
  push: target.config.branch.push
@@ -21666,7 +21956,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21666
21956
  case "periscope": {
21667
21957
  const subP = positional[1];
21668
21958
  if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21669
- process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21959
+ process.stderr.write("crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21670
21960
  process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
21671
21961
  process.stderr.write("crew periscope list [route] [--json]\n");
21672
21962
  process.exit(2);
@@ -21750,11 +22040,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21750
22040
  },
21751
22041
  readTicket: async (id) => {
21752
22042
  const t = await trD.ticket(id);
21753
- return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo] };
22043
+ return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo], isDraft: t.status === cD.statuses.draft };
21754
22044
  },
22045
+ priorHold: (id) => readPeriscopePrior(cfg.ship.stateDir, id),
22046
+ remoteHasBranch: (_wt, branch) => remoteBranchExists(repoD.dir, remote, branch),
21755
22047
  updateTicket: async (id, p) => {
21756
22048
  const patch2 = {};
21757
- if (p.status === "new") {
22049
+ if (p.status === "released") {
22050
+ patch2[cD.columns.assignee] = null;
22051
+ if ((await trD.authorshipProbe()).issueHeld) patch2[cD.columns.heldBy] = null;
22052
+ if (p.description !== void 0) patch2.description = p.description;
22053
+ } else if (p.status === "new") {
21758
22054
  patch2[cD.columns.status] = "new";
21759
22055
  patch2[cD.columns.assignee] = null;
21760
22056
  if (p.description !== void 0) patch2.description = p.description;
@@ -21779,7 +22075,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21779
22075
  if (deleteBranch) gitOk(repoD.dir, ["branch", "-D", branch]);
21780
22076
  },
21781
22077
  setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
21782
- forgetLocal: (id) => removePeriscopeSidecar(cfg.ship.stateDir, id),
22078
+ forgetLocal: (id) => {
22079
+ removePeriscopeSidecar(cfg.ship.stateDir, id);
22080
+ removePeriscopePrior(cfg.ship.stateDir, id);
22081
+ },
21783
22082
  say
21784
22083
  };
21785
22084
  if (dryRun) {
@@ -21802,7 +22101,48 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21802
22101
  break;
21803
22102
  }
21804
22103
  try {
21805
- const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22104
+ const resumeRaw = value("resume");
22105
+ if (resumeRaw !== void 0) {
22106
+ const nR = parseTicketNumber(resumeRaw);
22107
+ if (!nR) throw new PeriscopeError("--resume needs a ticket number (326, ISSUE-326, or CREW-326)");
22108
+ let hit;
22109
+ for (const cand of cfg.routes.filter((r) => r.enabled && r.resolved?.periscopesModelId)) {
22110
+ const tr = new Tracker(cand, cfg.ship);
22111
+ for (const row of await tr.liveShipPeriscopes()) {
22112
+ const t = await tr.ticket(row.issue_id);
22113
+ if (t.issue_id.replace(/^\D+/, "") !== String(Number(nR))) continue;
22114
+ const repo = (await resolvedRepos(cand)).find((r) => r.id === t.repo_id);
22115
+ if (!repo) throw new PeriscopeError(`route ${cand.route} has no checkout for the repository of ${t.issue_tag ?? t.issue_id}`);
22116
+ hit = { route: cand, tracker: tr, row, tag: t.issue_tag ?? t.issue_id, repo };
22117
+ }
22118
+ }
22119
+ if (dryRun) {
22120
+ say("dry-run", hit ? `would rerun ports${hit.row.isolated ? ", isolate" : ""} and preview in ${hit.row.worktree} and refresh the row` : `no live periscope for ISSUE-${nR} on this ship`);
22121
+ break;
22122
+ }
22123
+ const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
22124
+ const resumed = await runPeriscopeResume({
22125
+ find: async () => hit && { row: hit.row, tag: hit.tag },
22126
+ worktreeExists: (p) => existsSync37(p),
22127
+ caps: periscopeCaps(cfg.ship),
22128
+ runHook: async (name, cwd, env) => {
22129
+ const script = hit?.repo.config.hooks[name];
22130
+ return script ? runScript(script, { cwd, env, shell: shellR, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
22131
+ },
22132
+ update: (id, fields) => hit.tracker.updatePeriscope(id, fields),
22133
+ clearItem: (tag) => clearShipAttention(state, periscopeIdleKey(tag)),
22134
+ now: () => /* @__PURE__ */ new Date(),
22135
+ say
22136
+ }, String(Number(nR)));
22137
+ if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...resumed })}
22138
+ `);
22139
+ else process.stdout.write(`${resumed.ticket}
22140
+ ${resumed.worktree}
22141
+ ${resumed.url}
22142
+ `);
22143
+ break;
22144
+ }
22145
+ const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
21806
22146
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
21807
22147
  const servedP = [];
21808
22148
  for (const cand of candidatesP) {
@@ -21864,10 +22204,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21864
22204
  },
21865
22205
  prepare: async (path) => {
21866
22206
  for (const f of repoP.config.worktrees.copy) {
21867
- const src = join39(repoP.dir, f);
21868
- if (!existsSync36(src)) continue;
21869
- mkdirSync23(dirOf2(join39(path, f)), { recursive: true });
21870
- copyFileSync2(src, join39(path, f));
22207
+ const src = join41(repoP.dir, f);
22208
+ if (!existsSync37(src)) continue;
22209
+ mkdirSync24(dirOf2(join41(path, f)), { recursive: true });
22210
+ copyFileSync2(src, join41(path, f));
21871
22211
  }
21872
22212
  const setup = repoP.config.hooks.setup;
21873
22213
  return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
@@ -21891,12 +22231,58 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21891
22231
  const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
21892
22232
  await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
21893
22233
  },
22234
+ existing: {
22235
+ load: async (number) => {
22236
+ const t = (await trackerP.ticketsByNumbers([number])).find((x) => String(x[cP.columns.key]).replace(/^\D+/, "").replace(/^0+(?=\d)/, "") === number);
22237
+ if (!t) return void 0;
22238
+ return {
22239
+ id: t.id,
22240
+ tag: t.issue_tag ?? t.issue_id,
22241
+ issueId: t.issue_id,
22242
+ prefix: t.project_issue_prefix,
22243
+ status: t.status,
22244
+ closed: cP.statuses.resolved.includes(t.status) || t.status === cP.statuses.wontFix,
22245
+ repoId: t[cP.columns.repo] ?? null,
22246
+ assignee: t[cP.columns.assignee] ?? null,
22247
+ heldByShipId: t[cP.columns.heldBy] ?? null
22248
+ };
22249
+ },
22250
+ repoId: repoP.id ?? null,
22251
+ repoName: a.repo,
22252
+ pairId,
22253
+ shipId: (await trackerP.authorshipProbe()).shipId,
22254
+ agentHolder: async (assigneeId) => {
22255
+ const row = crewP.find((r) => r.id === assigneeId);
22256
+ return row?.agent_id ? row.name ?? assigneeId : void 0;
22257
+ },
22258
+ hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
22259
+ hold: async (id) => {
22260
+ const auth = await trackerP.authorshipProbe();
22261
+ await trackerP.updateTicket(id, {
22262
+ [cP.columns.assignee]: pairId,
22263
+ ...auth.issueHeld && auth.shipId ? { [cP.columns.heldBy]: auth.shipId } : {}
22264
+ });
22265
+ },
22266
+ restore: async (id, assignee) => {
22267
+ const auth = await trackerP.authorshipProbe();
22268
+ await trackerP.updateTicket(id, { [cP.columns.assignee]: assignee, ...auth.issueHeld ? { [cP.columns.heldBy]: null } : {} });
22269
+ },
22270
+ remember: (id, prior) => writePeriscopePrior(cfg.ship.stateDir, id, prior)
22271
+ },
21894
22272
  now: () => /* @__PURE__ */ new Date(),
21895
22273
  host: a.host ?? "localhost",
21896
22274
  say
21897
22275
  };
21898
22276
  if (dryRun) {
21899
22277
  await periscopePreflight(deps, a.repo);
22278
+ if (a.issue) {
22279
+ const t = await deps.existing.load(a.issue);
22280
+ if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
22281
+ const refused = await existingRefusal(deps.existing, t);
22282
+ if (refused) throw new PeriscopeError(refused);
22283
+ say("dry-run", `would hold ${t.tag} (status ${t.status} stays) for ${pairId}, cut or reuse its worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row`);
22284
+ break;
22285
+ }
21900
22286
  say("dry-run", `would file "Periscope: ${a.topic ?? "<topic>"}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
21901
22287
  break;
21902
22288
  }
@@ -22091,7 +22477,7 @@ ${res.url}
22091
22477
  };
22092
22478
  const droppedRoutes = [];
22093
22479
  try {
22094
- const rawCfg = parseYaml2(readFileSync32(cfg.configFile, "utf8"));
22480
+ const rawCfg = parseYaml2(readFileSync33(cfg.configFile, "utf8"));
22095
22481
  for (const r of rawCfg?.routes ?? []) {
22096
22482
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
22097
22483
  try {
@@ -22133,8 +22519,8 @@ ${res.url}
22133
22519
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
22134
22520
  break;
22135
22521
  }
22136
- mkdirSync23(cfg.ship.stateDir, { recursive: true });
22137
- writeFileSync24(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22522
+ mkdirSync24(cfg.ship.stateDir, { recursive: true });
22523
+ writeFileSync25(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22138
22524
  `);
22139
22525
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
22140
22526
  if (flag("now") && control?.stopCommand && control.startCommand) {
@@ -22185,7 +22571,7 @@ ${res.url}
22185
22571
  }
22186
22572
  case "log": {
22187
22573
  try {
22188
- const text = readFileSync32(cfg.ship.logFile, "utf8").split("\n");
22574
+ const text = readFileSync33(cfg.ship.logFile, "utf8").split("\n");
22189
22575
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
22190
22576
  `);
22191
22577
  } catch {
@@ -22208,7 +22594,7 @@ ${res.url}
22208
22594
  };
22209
22595
  if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
22210
22596
  const tracker = new Tracker(route, cfg.ship);
22211
- const configText = readFileSync32(cfg.configFile, "utf8");
22597
+ const configText = readFileSync33(cfg.configFile, "utf8");
22212
22598
  const cfgBase = dirname20(cfg.configFile);
22213
22599
  const configured = configuredRepos(configText, route.route, cfgBase);
22214
22600
  const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
@@ -22237,7 +22623,7 @@ ${res.url}
22237
22623
  }
22238
22624
  if (configured.some((r) => r.dir === dir)) {
22239
22625
  try {
22240
- writeFileSync24(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22626
+ writeFileSync25(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22241
22627
  } catch (e) {
22242
22628
  if (e instanceof ReposError) fail(e.message);
22243
22629
  throw e;
@@ -22245,29 +22631,29 @@ ${res.url}
22245
22631
  }
22246
22632
  if (row) {
22247
22633
  await tracker.deleteRepoRow(row.id);
22248
- if (existsSync36(resolvedFile)) {
22249
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22634
+ if (existsSync37(resolvedFile)) {
22635
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22250
22636
  if (raw.repoNames) delete raw.repoNames[row.id];
22251
22637
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
22252
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22638
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22253
22639
  `);
22254
22640
  }
22255
22641
  }
22256
- if (existsSync36(join39(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync16(dir, { recursive: true, force: true });
22642
+ if (existsSync37(join41(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync17(dir, { recursive: true, force: true });
22257
22643
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
22258
22644
  break;
22259
22645
  }
22260
22646
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
22261
- const fresh = !existsSync36(join39(dir, ".git"));
22262
- if (existsSync36(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22647
+ const fresh = !existsSync37(join41(dir, ".git"));
22648
+ if (existsSync37(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22263
22649
  if (dryRun) {
22264
22650
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
22265
22651
  break;
22266
22652
  }
22267
22653
  if (fresh) {
22268
- mkdirSync23(dir, { recursive: true });
22269
- writeFileSync24(join39(dir, "README.md"), SAMPLE_README);
22270
- writeFileSync24(join39(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22654
+ mkdirSync24(dir, { recursive: true });
22655
+ writeFileSync25(join41(dir, "README.md"), SAMPLE_README);
22656
+ writeFileSync25(join41(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22271
22657
  const g = (...a) => {
22272
22658
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
22273
22659
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -22285,17 +22671,17 @@ ${res.url}
22285
22671
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
22286
22672
  });
22287
22673
  repoId = (await tracker.createRepoRow(payload)).id;
22288
- if (existsSync36(resolvedFile)) {
22289
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22674
+ if (existsSync37(resolvedFile)) {
22675
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22290
22676
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
22291
22677
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
22292
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22678
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22293
22679
  `);
22294
22680
  }
22295
22681
  }
22296
22682
  if (!configured.some((r) => r.dir === dir)) {
22297
22683
  try {
22298
- writeFileSync24(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22684
+ writeFileSync25(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22299
22685
  } catch (e) {
22300
22686
  if (e instanceof ReposError) fail(e.message);
22301
22687
  throw e;
@@ -22630,7 +23016,7 @@ watermark: ${state.watermark()}
22630
23016
  }
22631
23017
  for (const r of repos) {
22632
23018
  const remote = r.config.branch.remote;
22633
- if (existsSync36(r.dir)) {
23019
+ if (existsSync37(r.dir)) {
22634
23020
  pushAuth.push({
22635
23021
  route: c.route,
22636
23022
  repo: r.name,
@@ -22707,7 +23093,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22707
23093
  }
22708
23094
  try {
22709
23095
  const evFile = eventFileFor(cfg.ship.stateDir);
22710
- const evSize = existsSync36(evFile) ? statSync10(evFile).size : 0;
23096
+ const evSize = existsSync37(evFile) ? statSync11(evFile).size : 0;
22711
23097
  process.stdout.write(
22712
23098
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
22713
23099
  ` : "events log: ok\n"
@@ -22718,7 +23104,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22718
23104
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
22719
23105
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
22720
23106
  try {
22721
- return readFileSync32(p, "utf8");
23107
+ return readFileSync33(p, "utf8");
22722
23108
  } catch {
22723
23109
  return null;
22724
23110
  }
@@ -22734,7 +23120,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22734
23120
  const changed2 = configChangedAfter(daemonConfigPaths(cfg), daemonStartedAt);
22735
23121
  if (changed2) {
22736
23122
  process.stdout.write(
22737
- `daemon config: WARN ${changed2} changed at ${new Date(statSync10(changed2).mtimeMs).toISOString()} but the daemon started at ${new Date(daemonStartedAt).toISOString()} \u2014 run \`crew reload\`
23123
+ `daemon config: WARN ${changed2} changed at ${new Date(statSync11(changed2).mtimeMs).toISOString()} but the daemon started at ${new Date(daemonStartedAt).toISOString()} \u2014 run \`crew reload\`
22738
23124
  `
22739
23125
  );
22740
23126
  }