@tablation/crew 0.87.1 → 0.88.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 +308 -67
  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 join40, 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,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9829
9842
  "workspace-id",
9830
9843
  "write",
9831
9844
  "topic",
9832
- "assignee"
9845
+ "assignee",
9846
+ "resume"
9833
9847
  ]);
9834
9848
  function positionalArgs(argv2) {
9835
9849
  const out = [];
@@ -13169,7 +13183,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13169
13183
  }
13170
13184
  if (culprits.length === 0 && truncated) {
13171
13185
  let lo = 2, hi = applied.length;
13172
- let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13186
+ let tail3 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13173
13187
  while (lo < hi) {
13174
13188
  const mid = Math.floor((lo + hi) / 2);
13175
13189
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
@@ -13184,12 +13198,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13184
13198
  const r = clean ? await hook(o, name) : null;
13185
13199
  if (r && r.code !== 0) {
13186
13200
  hi = mid;
13187
- tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13201
+ tail3 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13188
13202
  } else lo = mid + 1;
13189
13203
  }
13190
13204
  const owner = applied[hi - 1];
13191
13205
  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 });
13206
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail3 });
13193
13207
  }
13194
13208
  }
13195
13209
  }
@@ -14301,8 +14315,8 @@ async function decideFleet(o) {
14301
14315
  }
14302
14316
  var LOUD_STREAK = 5;
14303
14317
  function since(iso, now = Date.now()) {
14304
- const ms2 = Math.max(0, now - Date.parse(iso));
14305
- const mins = Math.round(ms2 / 6e4);
14318
+ const ms3 = Math.max(0, now - Date.parse(iso));
14319
+ const mins = Math.round(ms3 / 6e4);
14306
14320
  if (mins < 60) return `${mins}m`;
14307
14321
  const hours = Math.round(mins / 60);
14308
14322
  if (hours < 48) return `${hours}h`;
@@ -16197,6 +16211,7 @@ function planWorktreeSweep(target, tickets, contract) {
16197
16211
  for (const w of worktrees(target.dir).slice(1)) {
16198
16212
  const ticket = ticketForWorktree(w, byNumber, contract);
16199
16213
  if (!ticket) continue;
16214
+ if (ticket.status === contract.statuses.draft) continue;
16200
16215
  const isVerified = ticket.status === verified;
16201
16216
  if (!isVerified && !terminal.has(ticket.status)) continue;
16202
16217
  actions.push({
@@ -16209,6 +16224,15 @@ function planWorktreeSweep(target, tickets, contract) {
16209
16224
  }
16210
16225
  return actions;
16211
16226
  }
16227
+ function draftWorktrees(target, tickets, contract) {
16228
+ const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
16229
+ const out = [];
16230
+ for (const w of worktrees(target.dir).slice(1)) {
16231
+ const ticket = ticketForWorktree(w, byNumber, contract);
16232
+ if (ticket && ticket.status === contract.statuses.draft) out.push({ ticket, path: w.path });
16233
+ }
16234
+ return out;
16235
+ }
16212
16236
  var DIR_NUMBER = /(?:^|-)(\d+)$/;
16213
16237
  var DIR_NUMBER_SUFFIXED = /(?:^|-)(\d+)(?:-[a-z0-9]{1,8})?$/;
16214
16238
  var BRANCH_STEM = /^(?:.*\/)?([^/]+?-\d+)(?:-.*)?$/;
@@ -16508,7 +16532,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16508
16532
  }
16509
16533
 
16510
16534
  // 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";
16535
+ 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 statSync11, readdirSync as readdirSync17 } from "fs";
16512
16536
  import { createInterface } from "readline/promises";
16513
16537
 
16514
16538
  // src/periscope-cmd.ts
@@ -16773,17 +16797,149 @@ function formatPeriscopeList(entries) {
16773
16797
  ].join(" ")).join("\n");
16774
16798
  }
16775
16799
 
16800
+ // src/periscope-resume.ts
16801
+ var tail2 = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16802
+ async function runPeriscopeResume(d, number) {
16803
+ const found = await d.find(number);
16804
+ if (!found) throw new PeriscopeError(`no live periscope for ISSUE-${number} on this ship \u2014 open one with \`crew periscope up\``);
16805
+ const { row, tag } = found;
16806
+ if (!d.worktreeExists(row.worktree)) {
16807
+ throw new PeriscopeError(`the worktree of ${tag} is gone (${row.worktree}); there is nothing to resume`);
16808
+ }
16809
+ const env = { CREW_TICKET: tag };
16810
+ if (row.branch) env.CREW_BRANCH = row.branch;
16811
+ const ports = await d.runHook("ports", row.worktree, env);
16812
+ if (ports) Object.assign(env, parseEnvLines(ports.output));
16813
+ if (row.isolated) {
16814
+ const iso = await d.runHook("isolate", row.worktree, env);
16815
+ if (iso && iso.code !== 0) throw new PeriscopeError(`isolate failed (exit ${iso.code})
16816
+ ${tail2(iso.output)}`);
16817
+ if (iso) Object.assign(env, parseEnvLines(iso.output));
16818
+ }
16819
+ const preview = await d.runHook("preview", row.worktree, { ...env, CREW_PREVIEW_HOST: row.host || "localhost" });
16820
+ if (!preview || preview.code !== 0) {
16821
+ throw new PeriscopeError(`preview exited ${preview?.code ?? "without running"}
16822
+ ${tail2(preview?.output ?? "")}`);
16823
+ }
16824
+ const url = previewUrlFrom(preview.output);
16825
+ if (!url) throw new PeriscopeError(`preview printed no URL as its last line
16826
+ ${tail2(preview.output)}`);
16827
+ const now = d.now();
16828
+ await d.update(row.id, {
16829
+ url,
16830
+ last_activity_at: now.toISOString(),
16831
+ expires_at: new Date(now.getTime() + d.caps.idleMinutes * 6e4).toISOString()
16832
+ });
16833
+ d.clearItem(tag);
16834
+ d.say("resumed", `preview at ${url}`, { url });
16835
+ return { ticket: tag, worktree: row.worktree, url, periscopeRecordId: row.id };
16836
+ }
16837
+
16838
+ // src/periscope-idle.ts
16839
+ import { execFileSync as execFileSync6 } from "child_process";
16840
+ import { statSync as statSync9 } from "fs";
16841
+ import { join as join35 } from "path";
16842
+ var MAX_SCANNED_FILES = 2e4;
16843
+ function newestWorktreeMtime(root) {
16844
+ let listing;
16845
+ try {
16846
+ listing = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
16847
+ cwd: root,
16848
+ encoding: "utf8",
16849
+ stdio: ["ignore", "pipe", "ignore"],
16850
+ maxBuffer: 64 * 1024 * 1024
16851
+ });
16852
+ } catch {
16853
+ return null;
16854
+ }
16855
+ let newest = null;
16856
+ let scanned = 0;
16857
+ for (const rel of listing.split("\0")) {
16858
+ if (!rel) continue;
16859
+ if (++scanned > MAX_SCANNED_FILES) break;
16860
+ try {
16861
+ const m = statSync9(join35(root, rel)).mtimeMs;
16862
+ if (newest === null || m > newest) newest = m;
16863
+ } catch {
16864
+ }
16865
+ }
16866
+ return newest;
16867
+ }
16868
+ var ms2 = (iso) => {
16869
+ const t = iso ? Date.parse(iso) : NaN;
16870
+ return Number.isFinite(t) ? t : 0;
16871
+ };
16872
+ function decideIdle(row, mtimeMs, nowMs, idleMinutes) {
16873
+ const idleMs = idleMinutes * 6e4;
16874
+ const recordedActivity = ms2(row.last_activity_at);
16875
+ const activity = Math.max(recordedActivity, mtimeMs ?? 0);
16876
+ const expiresAt = ms2(row.expires_at);
16877
+ if (activity > 0 && activity + idleMs > expiresAt) {
16878
+ return {
16879
+ action: "refresh",
16880
+ lastActivityAt: new Date(activity).toISOString(),
16881
+ expiresAt: new Date(activity + idleMs).toISOString()
16882
+ };
16883
+ }
16884
+ if (expiresAt > 0 && nowMs >= expiresAt) return { action: "expire", idleMs: Math.max(0, nowMs - activity) };
16885
+ return { action: "none" };
16886
+ }
16887
+ var periscopeIdleKey = (ticket) => `periscope_idle:${ticket}`;
16888
+ var fmtIdle = (idleMs) => {
16889
+ const minutes = Math.max(1, Math.round(idleMs / 6e4));
16890
+ const h = Math.floor(minutes / 60);
16891
+ return h > 0 ? `${h}h ${minutes % 60}m` : `${minutes}m`;
16892
+ };
16893
+ function periscopeIdleItem(ticket, repo, number, idleMs, nowIso) {
16894
+ return {
16895
+ kind: "periscope_idle",
16896
+ key: periscopeIdleKey(ticket),
16897
+ since: nowIso,
16898
+ 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}\`.`
16899
+ };
16900
+ }
16901
+ async function runPeriscopeIdlePass(d) {
16902
+ const result = { refreshed: 0, expired: 0 };
16903
+ const now = d.now();
16904
+ const liveKeys = /* @__PURE__ */ new Set();
16905
+ let lookupFailed = false;
16906
+ for (const row of await d.listLive()) {
16907
+ try {
16908
+ const t = await d.ticketOf(row);
16909
+ liveKeys.add(periscopeIdleKey(t.tag));
16910
+ const decision = decideIdle(row, d.mtimeOf(row.worktree), now.getTime(), d.idleMinutes);
16911
+ if (decision.action === "none") continue;
16912
+ if (decision.action === "refresh") {
16913
+ await d.update(row.id, { last_activity_at: decision.lastActivityAt, expires_at: decision.expiresAt });
16914
+ d.clear(periscopeIdleKey(t.tag));
16915
+ result.refreshed++;
16916
+ continue;
16917
+ }
16918
+ await d.stopServer(row.worktree, t.number);
16919
+ await d.raise(periscopeIdleItem(t.tag, t.repo, t.number, decision.idleMs, now.toISOString()));
16920
+ result.expired++;
16921
+ } catch (e) {
16922
+ lookupFailed = true;
16923
+ d.warn(`periscope idle check failed for ${row.worktree}: ${e.message}`);
16924
+ }
16925
+ }
16926
+ if (!lookupFailed) {
16927
+ for (const key of d.openKeys()) if (!liveKeys.has(key)) d.clear(key);
16928
+ }
16929
+ return result;
16930
+ }
16931
+
16776
16932
  // src/cli.ts
16777
16933
  import { dirname as dirOf2, resolve as resolvePath } from "path";
16778
16934
 
16779
16935
  // src/ssh-keys.ts
16780
- import { execFileSync as execFileSync6 } from "child_process";
16936
+ import { execFileSync as execFileSync7 } from "child_process";
16781
16937
  import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, chmodSync } from "fs";
16782
- import { dirname as dirname19, join as join35 } from "path";
16938
+ import { dirname as dirname19, join as join36 } from "path";
16783
16939
  var SshKeyError = class extends Error {
16784
16940
  };
16785
16941
  function shipSshKeyPathFor(stateDir) {
16786
- return join35(stateDir, "ssh", "id_ed25519");
16942
+ return join36(stateDir, "ssh", "id_ed25519");
16787
16943
  }
16788
16944
  function ensureShipSshKeypair(stateDir) {
16789
16945
  const privateKeyPath = shipSshKeyPathFor(stateDir);
@@ -16791,7 +16947,7 @@ function ensureShipSshKeypair(stateDir) {
16791
16947
  if (!existsSync32(privateKeyPath)) {
16792
16948
  mkdirSync19(dirname19(privateKeyPath), { recursive: true });
16793
16949
  try {
16794
- execFileSync6("ssh-keygen", [
16950
+ execFileSync7("ssh-keygen", [
16795
16951
  "-t",
16796
16952
  "ed25519",
16797
16953
  "-N",
@@ -16817,7 +16973,7 @@ function ensureShipSshKeypair(stateDir) {
16817
16973
  }
16818
16974
  function sshKeygenAvailable() {
16819
16975
  try {
16820
- execFileSync6("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
16976
+ execFileSync7("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
16821
16977
  return true;
16822
16978
  } catch (e) {
16823
16979
  return e.code !== "ENOENT";
@@ -16825,11 +16981,11 @@ function sshKeygenAvailable() {
16825
16981
  }
16826
16982
 
16827
16983
  // src/passenger-containers.ts
16828
- import { execFileSync as execFileSync7 } from "child_process";
16984
+ import { execFileSync as execFileSync8 } from "child_process";
16829
16985
  import { createHash as createHash6 } from "crypto";
16830
16986
  var realExec = (cmd, args, opts) => {
16831
16987
  try {
16832
- return execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
16988
+ return execFileSync8(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs });
16833
16989
  } catch (e) {
16834
16990
  const err = e;
16835
16991
  const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
@@ -17031,7 +17187,7 @@ function syncAllPassengerCheckouts(cfg) {
17031
17187
  import { spawn as spawn5 } from "child_process";
17032
17188
  import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17033
17189
  import { randomInt } from "crypto";
17034
- import { join as join36 } from "path";
17190
+ import { join as join37 } from "path";
17035
17191
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
17036
17192
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
17037
17193
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -17098,7 +17254,7 @@ function startTunnel(opts) {
17098
17254
  };
17099
17255
  }
17100
17256
  function tunnelStatePath(stateDir, workspaceId) {
17101
- return join36(stateDir, "passengers", `${workspaceId}.json`);
17257
+ return join37(stateDir, "passengers", `${workspaceId}.json`);
17102
17258
  }
17103
17259
  function readPersistedTunnel(stateDir, workspaceId) {
17104
17260
  const path = tunnelStatePath(stateDir, workspaceId);
@@ -17111,7 +17267,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
17111
17267
  }
17112
17268
  function writePersistedTunnel(stateDir, state) {
17113
17269
  const path = tunnelStatePath(stateDir, state.workspaceId);
17114
- mkdirSync20(join36(stateDir, "passengers"), { recursive: true });
17270
+ mkdirSync20(join37(stateDir, "passengers"), { recursive: true });
17115
17271
  writeFileSync21(path, `${JSON.stringify(state, null, 2)}
17116
17272
  `);
17117
17273
  }
@@ -17209,7 +17365,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
17209
17365
  }
17210
17366
  }
17211
17367
  function readAllPersistedWorkspaceIds(stateDir) {
17212
- const dir = join36(stateDir, "passengers");
17368
+ const dir = join37(stateDir, "passengers");
17213
17369
  if (!existsSync33(dir)) return [];
17214
17370
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
17215
17371
  }
@@ -17332,7 +17488,7 @@ import { createServer as createHttpServer } from "http";
17332
17488
  import { spawn as spawn6 } from "child_process";
17333
17489
  import { randomBytes } from "crypto";
17334
17490
  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";
17491
+ import { join as join38 } from "path";
17336
17492
 
17337
17493
  // src/secret-compare.ts
17338
17494
  import { timingSafeEqual } from "crypto";
@@ -17421,7 +17577,7 @@ var defaultSpawn2 = (cmd, args, env) => {
17421
17577
  return child;
17422
17578
  };
17423
17579
  function syncDaemonStatePath(stateDir, workspaceId) {
17424
- return join37(stateDir, "passengers", `${workspaceId}-sync.json`);
17580
+ return join38(stateDir, "passengers", `${workspaceId}-sync.json`);
17425
17581
  }
17426
17582
  function readPersistedSyncDaemon(stateDir, workspaceId) {
17427
17583
  const path = syncDaemonStatePath(stateDir, workspaceId);
@@ -17434,7 +17590,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
17434
17590
  }
17435
17591
  function writePersistedSyncDaemon(stateDir, state) {
17436
17592
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
17437
- mkdirSync21(join37(stateDir, "passengers"), { recursive: true });
17593
+ mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17438
17594
  writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17439
17595
  `);
17440
17596
  }
@@ -17445,8 +17601,8 @@ function removePersistedSyncDaemon(stateDir, workspaceId) {
17445
17601
  } catch {
17446
17602
  }
17447
17603
  }
17448
- function sleepSync2(ms2) {
17449
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
17604
+ function sleepSync2(ms3) {
17605
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms3);
17450
17606
  }
17451
17607
  function waitForExit(pid, alive, timeoutMs = 5e3) {
17452
17608
  const deadline = Date.now() + timeoutMs;
@@ -17455,7 +17611,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
17455
17611
  }
17456
17612
  }
17457
17613
  function readAllPersistedSyncWorkspaceIds(stateDir) {
17458
- const dir = join37(stateDir, "passengers");
17614
+ const dir = join38(stateDir, "passengers");
17459
17615
  if (!existsSync34(dir)) return [];
17460
17616
  const suffix = "-sync.json";
17461
17617
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
@@ -17544,12 +17700,12 @@ function describeCause(err) {
17544
17700
  }
17545
17701
 
17546
17702
  // 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";
17703
+ import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, readdirSync as readdirSync16, rmSync as rmSync15, statSync as statSync10, writeFileSync as writeFileSync23 } from "fs";
17704
+ import { join as join39 } from "path";
17549
17705
  import { randomBytes as randomBytes2 } from "crypto";
17550
17706
  var DEFAULT_FLOOR_MS = 5e3;
17551
17707
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
17552
- var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
17708
+ var defaultSleep = (ms3) => new Promise((resolve6) => setTimeout(resolve6, ms3));
17553
17709
  async function runDaemonLoop(o) {
17554
17710
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
17555
17711
  const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
@@ -17608,7 +17764,7 @@ async function runDaemonLoop(o) {
17608
17764
  function latestMtime(path) {
17609
17765
  let stat;
17610
17766
  try {
17611
- stat = statSync9(path);
17767
+ stat = statSync10(path);
17612
17768
  } catch {
17613
17769
  return 0;
17614
17770
  }
@@ -17616,7 +17772,7 @@ function latestMtime(path) {
17616
17772
  if (!stat.isDirectory()) return 0;
17617
17773
  let latest = stat.mtimeMs;
17618
17774
  for (const entry of readdirSync16(path, { withFileTypes: true })) {
17619
- latest = Math.max(latest, latestMtime(join38(path, entry.name)));
17775
+ latest = Math.max(latest, latestMtime(join39(path, entry.name)));
17620
17776
  }
17621
17777
  return latest;
17622
17778
  }
@@ -17628,10 +17784,10 @@ function makeStaleChecker(paths, opts = {}) {
17628
17784
  return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync35(opts.reloadSentinel);
17629
17785
  }
17630
17786
  function reloadSentinelFor(stateDir) {
17631
- return join38(stateDir, "reload-requested");
17787
+ return join39(stateDir, "reload-requested");
17632
17788
  }
17633
17789
  function daemonStampFor(stateDir) {
17634
- return join38(stateDir, "daemon.started");
17790
+ return join39(stateDir, "daemon.started");
17635
17791
  }
17636
17792
  function noteDaemonStarted(stateDir, now = Date.now()) {
17637
17793
  mkdirSync22(stateDir, { recursive: true });
@@ -17650,7 +17806,7 @@ function readDaemonStarted(stateDir) {
17650
17806
  function configChangedAfter(files, startedAt) {
17651
17807
  for (const f of files) {
17652
17808
  try {
17653
- if (statSync9(f).mtimeMs > startedAt) return f;
17809
+ if (statSync10(f).mtimeMs > startedAt) return f;
17654
17810
  } catch {
17655
17811
  }
17656
17812
  }
@@ -17749,6 +17905,35 @@ async function runOnePassUnguarded(o) {
17749
17905
  emit.warn(`could not sweep stalled tickets: ${e.message}`, { step: "sweep" });
17750
17906
  }
17751
17907
  }
17908
+ if (o.route.resolved?.periscopesModelId) {
17909
+ const tracker = new Tracker(o.route, o.ship);
17910
+ try {
17911
+ await runPeriscopeIdlePass({
17912
+ idleMinutes: periscopeCaps(o.ship).idleMinutes,
17913
+ now: () => /* @__PURE__ */ new Date(),
17914
+ listLive: () => tracker.liveShipPeriscopes(),
17915
+ mtimeOf: newestWorktreeMtime,
17916
+ update: (id, fields) => tracker.updatePeriscope(id, fields),
17917
+ ticketOf: async (row) => {
17918
+ const t = await tracker.ticket(row.issue_id);
17919
+ return {
17920
+ tag: t.issue_tag ?? t.issue_id,
17921
+ number: t.issue_id.replace(/^\D+/, ""),
17922
+ repo: t.repo_id && o.route.resolved?.repoNames?.[t.repo_id] || "unknown repository"
17923
+ };
17924
+ },
17925
+ stopServer: (worktree, number) => killGently(pidsInWorktree(worktree, Number(number))),
17926
+ raise: async (item) => {
17927
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, item);
17928
+ },
17929
+ clear: (key) => clearShipAttention(o.state, key),
17930
+ openKeys: () => openShipAttention(o.state).filter((i) => i.kind === "periscope_idle").map((i) => i.key),
17931
+ warn: (message) => emit.warn(message, { step: "sweep" })
17932
+ });
17933
+ } catch (e) {
17934
+ emit.warn(`could not check idle periscopes: ${e.message}`, { step: "sweep" });
17935
+ }
17936
+ }
17752
17937
  const order = [...decision.selection.pending].sort(
17753
17938
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
17754
17939
  );
@@ -18235,6 +18420,7 @@ function usage(problem, requested = false) {
18235
18420
  crew drop [route] NNN remove a merged ticket's worktree and branch
18236
18421
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18237
18422
  crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18423
+ crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
18238
18424
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18239
18425
  crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
18240
18426
  end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
@@ -18751,7 +18937,7 @@ if (command === "passenger-sync-daemon") {
18751
18937
  }
18752
18938
  if (host === "linux" && hasSystemd) {
18753
18939
  const label = labelFor(CREW_HOME, "run");
18754
- const staleTimer = join39(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18940
+ const staleTimer = join40(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18755
18941
  if (existsSync36(staleTimer)) {
18756
18942
  process.stdout.write(
18757
18943
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
@@ -19918,7 +20104,7 @@ ${cu.tail.trimEnd()}
19918
20104
  process.exit(1);
19919
20105
  }
19920
20106
  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")];
20107
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join40(CREW_HOME, "src"), join40(CREW_HOME, "dist"), join40(CREW_HOME, "bin")];
19922
20108
  const configPaths = daemonConfigPaths(cfg);
19923
20109
  watchPaths.push(...configPaths);
19924
20110
  noteDaemonStarted(cfg.ship.stateDir);
@@ -21449,6 +21635,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21449
21635
  await tracker.ticketsByNumbers(remoteBranchTicketNumbers(r.dir, r.config.branch.remote, r.config.branch.base), truncated)
21450
21636
  ).byRepo.get(r.name) ?? [];
21451
21637
  const actions = planWorktreeSweep(r, wtTickets, tracker.contract);
21638
+ for (const k of draftWorktrees(r, wtTickets, tracker.contract)) {
21639
+ anything = true;
21640
+ 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" });
21641
+ }
21452
21642
  const orphanDirs = orphanedWorktreeDirs(r, wtTickets, tracker.contract);
21453
21643
  warnOrphanedWorktreeDirs(orphanDirs, emit, dryRun ? void 0 : { state, repo: r.name });
21454
21644
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, brTickets, tracker.contract);
@@ -21514,6 +21704,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21514
21704
  }
21515
21705
  const { repo: target, found } = candidates[0];
21516
21706
  const wt = found.path;
21707
+ try {
21708
+ const trD = new Tracker(route, cfg.ship);
21709
+ const tD = (await trD.ticketsByNumbers([n])).find((t) => t.issue_id.replace(/^\D+/, "") === String(num));
21710
+ if (tD && tD[trD.contract.columns.status] === trD.contract.statuses.draft) {
21711
+ 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\`
21712
+ `);
21713
+ process.exit(2);
21714
+ }
21715
+ } catch {
21716
+ }
21517
21717
  const branch = found.branch ?? branchForIssue(target.dir, `ISSUE-${n}`, {
21518
21718
  name: target.config.branch.name,
21519
21719
  push: target.config.branch.push
@@ -21802,6 +22002,47 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21802
22002
  break;
21803
22003
  }
21804
22004
  try {
22005
+ const resumeRaw = value("resume");
22006
+ if (resumeRaw !== void 0) {
22007
+ const nR = parseTicketNumber(resumeRaw);
22008
+ if (!nR) throw new PeriscopeError("--resume needs a ticket number (326, ISSUE-326, or CREW-326)");
22009
+ let hit;
22010
+ for (const cand of cfg.routes.filter((r) => r.enabled && r.resolved?.periscopesModelId)) {
22011
+ const tr = new Tracker(cand, cfg.ship);
22012
+ for (const row of await tr.liveShipPeriscopes()) {
22013
+ const t = await tr.ticket(row.issue_id);
22014
+ if (t.issue_id.replace(/^\D+/, "") !== String(Number(nR))) continue;
22015
+ const repo = (await resolvedRepos(cand)).find((r) => r.id === t.repo_id);
22016
+ if (!repo) throw new PeriscopeError(`route ${cand.route} has no checkout for the repository of ${t.issue_tag ?? t.issue_id}`);
22017
+ hit = { route: cand, tracker: tr, row, tag: t.issue_tag ?? t.issue_id, repo };
22018
+ }
22019
+ }
22020
+ if (dryRun) {
22021
+ 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`);
22022
+ break;
22023
+ }
22024
+ const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
22025
+ const resumed = await runPeriscopeResume({
22026
+ find: async () => hit && { row: hit.row, tag: hit.tag },
22027
+ worktreeExists: (p) => existsSync36(p),
22028
+ caps: periscopeCaps(cfg.ship),
22029
+ runHook: async (name, cwd, env) => {
22030
+ const script = hit?.repo.config.hooks[name];
22031
+ return script ? runScript(script, { cwd, env, shell: shellR, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
22032
+ },
22033
+ update: (id, fields) => hit.tracker.updatePeriscope(id, fields),
22034
+ clearItem: (tag) => clearShipAttention(state, periscopeIdleKey(tag)),
22035
+ now: () => /* @__PURE__ */ new Date(),
22036
+ say
22037
+ }, String(Number(nR)));
22038
+ if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...resumed })}
22039
+ `);
22040
+ else process.stdout.write(`${resumed.ticket}
22041
+ ${resumed.worktree}
22042
+ ${resumed.url}
22043
+ `);
22044
+ break;
22045
+ }
21805
22046
  const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
21806
22047
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
21807
22048
  const servedP = [];
@@ -21864,10 +22105,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21864
22105
  },
21865
22106
  prepare: async (path) => {
21866
22107
  for (const f of repoP.config.worktrees.copy) {
21867
- const src = join39(repoP.dir, f);
22108
+ const src = join40(repoP.dir, f);
21868
22109
  if (!existsSync36(src)) continue;
21869
- mkdirSync23(dirOf2(join39(path, f)), { recursive: true });
21870
- copyFileSync2(src, join39(path, f));
22110
+ mkdirSync23(dirOf2(join40(path, f)), { recursive: true });
22111
+ copyFileSync2(src, join40(path, f));
21871
22112
  }
21872
22113
  const setup = repoP.config.hooks.setup;
21873
22114
  return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
@@ -22253,12 +22494,12 @@ ${res.url}
22253
22494
  `);
22254
22495
  }
22255
22496
  }
22256
- if (existsSync36(join39(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync16(dir, { recursive: true, force: true });
22497
+ if (existsSync36(join40(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync16(dir, { recursive: true, force: true });
22257
22498
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
22258
22499
  break;
22259
22500
  }
22260
22501
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
22261
- const fresh = !existsSync36(join39(dir, ".git"));
22502
+ const fresh = !existsSync36(join40(dir, ".git"));
22262
22503
  if (existsSync36(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22263
22504
  if (dryRun) {
22264
22505
  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`);
@@ -22266,8 +22507,8 @@ ${res.url}
22266
22507
  }
22267
22508
  if (fresh) {
22268
22509
  mkdirSync23(dir, { recursive: true });
22269
- writeFileSync24(join39(dir, "README.md"), SAMPLE_README);
22270
- writeFileSync24(join39(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22510
+ writeFileSync24(join40(dir, "README.md"), SAMPLE_README);
22511
+ writeFileSync24(join40(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22271
22512
  const g = (...a) => {
22272
22513
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
22273
22514
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -22707,7 +22948,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22707
22948
  }
22708
22949
  try {
22709
22950
  const evFile = eventFileFor(cfg.ship.stateDir);
22710
- const evSize = existsSync36(evFile) ? statSync10(evFile).size : 0;
22951
+ const evSize = existsSync36(evFile) ? statSync11(evFile).size : 0;
22711
22952
  process.stdout.write(
22712
22953
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
22713
22954
  ` : "events log: ok\n"
@@ -22734,7 +22975,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22734
22975
  const changed2 = configChangedAfter(daemonConfigPaths(cfg), daemonStartedAt);
22735
22976
  if (changed2) {
22736
22977
  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\`
22978
+ `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
22979
  `
22739
22980
  );
22740
22981
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.87.1",
3
+ "version": "0.88.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",