@tablation/crew 0.99.0 → 0.100.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 +894 -299
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -45,12 +45,12 @@ function periscopeSummary(cfg) {
45
45
  if (routes.length === 0) return `off (no enabled route sets hostPeriscopes: true; ${limits})`;
46
46
  return `ON for ${routes.join(", ")} (host ${cfg.ship.previewHost ?? "NOT SET (ship.previewHost)"}, ${limits})`;
47
47
  }
48
- async function previewHostProblem(cfg, resolve6 = (h) => lookup(h)) {
48
+ async function previewHostProblem(cfg, resolve7 = (h) => lookup(h)) {
49
49
  if (periscopeRoutes(cfg).length === 0) return void 0;
50
50
  const host = cfg.ship.previewHost;
51
51
  if (!host) return "ship.previewHost is not set \u2014 a route hosts periscopes, so Passengers have no host to be sent to";
52
52
  try {
53
- await resolve6(host);
53
+ await resolve7(host);
54
54
  } catch {
55
55
  return `ship.previewHost "${host}" does not resolve from this machine`;
56
56
  }
@@ -83,6 +83,25 @@ function routePeriscopeProblems(o) {
83
83
  function previewHostFinding(message) {
84
84
  return /does not resolve/.test(message) ? { severity: "attention", kind: "preview_host_unresolvable", message } : { severity: "blocker", kind: "preview_host", message };
85
85
  }
86
+ var refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
87
+ function duplicateLivePeriscopes(rows, tagOf, shipName2, route2) {
88
+ const byTicket = /* @__PURE__ */ new Map();
89
+ for (const r of rows) {
90
+ const t = refId(r.issue_id);
91
+ if (t) byTicket.set(t, [...byTicket.get(t) ?? [], r]);
92
+ }
93
+ const out = [];
94
+ for (const [t, rs] of byTicket) {
95
+ if (rs.length < 2) continue;
96
+ out.push({
97
+ severity: "attention",
98
+ kind: "periscope_duplicate_live",
99
+ ...route2 ? { route: route2 } : {},
100
+ message: `${tagOf(t)} has ${rs.length} live Periscopes rows (${rs.map((r) => `${r.id} on ${shipName2(r.ship_id)}${r.url ? ` ${r.url}` : ""}`).join("; ")}) \u2014 the periscope_url rollup assumes one; end all but one with \`crew periscope down\``
101
+ });
102
+ }
103
+ return out;
104
+ }
86
105
 
87
106
  // src/periscope-state.ts
88
107
  import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -121,11 +140,11 @@ function urlPort(url) {
121
140
  }
122
141
  }
123
142
  function portListening(port, timeoutMs = 500) {
124
- return new Promise((resolve6) => {
143
+ return new Promise((resolve7) => {
125
144
  const s = connect({ port, host: "127.0.0.1" });
126
145
  const done = (ok) => {
127
146
  s.destroy();
128
- resolve6(ok);
147
+ resolve7(ok);
129
148
  };
130
149
  s.setTimeout(timeoutMs, () => done(false));
131
150
  s.once("connect", () => done(true));
@@ -564,8 +583,8 @@ function removeClaudeMcp(o) {
564
583
  if (got.missing) return `mcp: \`${o.bin}\` not found, so Claude Code was not touched`;
565
584
  if (got.status !== 0) return `mcp: no ${MCP_SERVER_NAME} server registered with Claude Code`;
566
585
  if (o.dryRun) return `(dry run) would remove the ${MCP_SERVER_NAME} MCP server from Claude Code (user scope)`;
567
- const rm = runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
568
- return rm.status === 0 ? `mcp: removed the ${MCP_SERVER_NAME} MCP server from Claude Code (user scope)` : `mcp: \`claude mcp remove\` failed (${rm.stderr.trim() || `exit ${rm.status}`})`;
586
+ const rm2 = runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
587
+ return rm2.status === 0 ? `mcp: removed the ${MCP_SERVER_NAME} MCP server from Claude Code (user scope)` : `mcp: \`claude mcp remove\` failed (${rm2.stderr.trim() || `exit ${rm2.status}`})`;
569
588
  }
570
589
 
571
590
  // src/launcher.ts
@@ -601,10 +620,10 @@ function selectsCustomModelProvider(args) {
601
620
  }
602
621
  return false;
603
622
  }
604
- function parseOmlxSettings(text) {
623
+ function parseOmlxSettings(text2) {
605
624
  let raw;
606
625
  try {
607
- raw = JSON.parse(text);
626
+ raw = JSON.parse(text2);
608
627
  } catch {
609
628
  return void 0;
610
629
  }
@@ -825,32 +844,32 @@ var BLOCK_RE = new RegExp(`${escapeRe(PAIR_BLOCK_START)}[\\s\\S]*?${escapeRe(PAI
825
844
  function escapeRe(s) {
826
845
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
827
846
  }
828
- function hasPairBlock(text) {
829
- return BLOCK_RE.test(text);
847
+ function hasPairBlock(text2) {
848
+ return BLOCK_RE.test(text2);
830
849
  }
831
- function pairBlockIsBroken(text) {
832
- return !hasPairBlock(text) && (text.includes(PAIR_BLOCK_START) || text.includes(PAIR_BLOCK_END));
850
+ function pairBlockIsBroken(text2) {
851
+ return !hasPairBlock(text2) && (text2.includes(PAIR_BLOCK_START) || text2.includes(PAIR_BLOCK_END));
833
852
  }
834
- function upsertPairBlock(text, block) {
835
- if (hasPairBlock(text)) return text.replace(BLOCK_RE, () => `${block}
853
+ function upsertPairBlock(text2, block) {
854
+ if (hasPairBlock(text2)) return text2.replace(BLOCK_RE, () => `${block}
836
855
  `);
837
- if (text.trim() === "") return `${block}
856
+ if (text2.trim() === "") return `${block}
838
857
  `;
839
- return `${text.replace(/\n*$/, "")}
858
+ return `${text2.replace(/\n*$/, "")}
840
859
 
841
860
  ${block}
842
861
  `;
843
862
  }
844
- function removePairBlock(text) {
845
- if (!hasPairBlock(text)) return text;
846
- return text.replace(new RegExp(`\\n*${BLOCK_RE.source}`), "\n").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n");
863
+ function removePairBlock(text2) {
864
+ if (!hasPairBlock(text2)) return text2;
865
+ return text2.replace(new RegExp(`\\n*${BLOCK_RE.source}`), "\n").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n");
847
866
  }
848
867
  function readIfExists(path) {
849
868
  return existsSync5(path) ? readFileSync7(path, "utf8") : void 0;
850
869
  }
851
- function writeFileMkdir(path, text) {
870
+ function writeFileMkdir(path, text2) {
852
871
  mkdirSync4(dirname2(path), { recursive: true });
853
- writeFileSync3(path, text);
872
+ writeFileSync3(path, text2);
854
873
  }
855
874
  function installBlockFile(path, harness, dryRun) {
856
875
  const before = readIfExists(path) ?? "";
@@ -870,9 +889,9 @@ function removeBlockFile(path, dryRun) {
870
889
  return true;
871
890
  }
872
891
  function blockVerdict(path, harness) {
873
- const text = readIfExists(path);
874
- if (text === void 0 || !hasPairBlock(text)) return { ok: false, detail: `no managed block in ${path}` };
875
- if (!text.includes(pairBlock(harness))) return { ok: false, detail: `the managed block in ${path} is out of date \u2014 run crew connect` };
892
+ const text2 = readIfExists(path);
893
+ if (text2 === void 0 || !hasPairBlock(text2)) return { ok: false, detail: `no managed block in ${path}` };
894
+ if (!text2.includes(pairBlock(harness))) return { ok: false, detail: `the managed block in ${path} is out of date \u2014 run crew connect` };
876
895
  return { ok: true, detail: "" };
877
896
  }
878
897
  var HEADER_RE = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/;
@@ -894,24 +913,24 @@ function tomlTableRange(lines, name) {
894
913
  return [start, end];
895
914
  }
896
915
  var tomlString = (v) => JSON.stringify(v);
897
- function upsertTomlTable(text, name, table2) {
898
- const lines = text.split("\n");
916
+ function upsertTomlTable(text2, name, table2) {
917
+ const lines = text2.split("\n");
899
918
  const r = tomlTableRange(lines, name);
900
919
  if (r) {
901
920
  lines.splice(r[0], r[1] - r[0], ...table2);
902
921
  return lines.join("\n");
903
922
  }
904
- const body = text.replace(/\n*$/, "");
923
+ const body = text2.replace(/\n*$/, "");
905
924
  return body === "" ? `${table2.join("\n")}
906
925
  ` : `${body}
907
926
 
908
927
  ${table2.join("\n")}
909
928
  `;
910
929
  }
911
- function removeTomlTable(text, name) {
912
- const lines = text.split("\n");
930
+ function removeTomlTable(text2, name) {
931
+ const lines = text2.split("\n");
913
932
  let r = tomlTableRange(lines, name);
914
- if (!r) return text;
933
+ if (!r) return text2;
915
934
  while (r) {
916
935
  let [start, end] = r;
917
936
  if (start > 0 && lines[start - 1].trim() === "") start--;
@@ -1145,10 +1164,10 @@ var geminiPaths = (home) => ({
1145
1164
  config: join8(home, ".gemini", "settings.json")
1146
1165
  });
1147
1166
  function readGeminiSettings(path) {
1148
- const text = readIfExists(path);
1149
- if (text === void 0 || text.trim() === "") return {};
1167
+ const text2 = readIfExists(path);
1168
+ if (text2 === void 0 || text2.trim() === "") return {};
1150
1169
  try {
1151
- const j = JSON.parse(text);
1170
+ const j = JSON.parse(text2);
1152
1171
  return j && typeof j === "object" && !Array.isArray(j) ? j : void 0;
1153
1172
  } catch {
1154
1173
  return void 0;
@@ -1248,10 +1267,10 @@ function vscodeUserDirs(o) {
1248
1267
  }
1249
1268
  var COPILOT_FRONTMATTER = '---\napplyTo: "**"\n---\n\n';
1250
1269
  function readVscodeMcp(path) {
1251
- const text = readIfExists(path);
1252
- if (text === void 0 || text.trim() === "") return {};
1270
+ const text2 = readIfExists(path);
1271
+ if (text2 === void 0 || text2.trim() === "") return {};
1253
1272
  try {
1254
- const j = JSON.parse(text);
1273
+ const j = JSON.parse(text2);
1255
1274
  return j && typeof j === "object" && !Array.isArray(j) ? j : void 0;
1256
1275
  } catch {
1257
1276
  return void 0;
@@ -1427,9 +1446,9 @@ function mapGeminiLine(line) {
1427
1446
  function createGeminiStreamMapper() {
1428
1447
  let pending = "";
1429
1448
  const flush = () => {
1430
- const text = pending;
1449
+ const text2 = pending;
1431
1450
  pending = "";
1432
- return text ? [{ kind: "text", text }] : [];
1451
+ return text2 ? [{ kind: "text", text: text2 }] : [];
1433
1452
  };
1434
1453
  return {
1435
1454
  flush,
@@ -1739,9 +1758,9 @@ function storeForgeToken(forges, host, token, stateDir, env = process.env) {
1739
1758
  if (!token.trim()) throw new ForgeError("empty token");
1740
1759
  i.write(f.entry.token, token.trim(), { host: f.host, stateDir, env });
1741
1760
  }
1742
- function parseCredentialInput(text) {
1761
+ function parseCredentialInput(text2) {
1743
1762
  const out = {};
1744
- for (const line of text.split("\n")) {
1763
+ for (const line of text2.split("\n")) {
1745
1764
  if (line === "") break;
1746
1765
  const eq2 = line.indexOf("=");
1747
1766
  if (eq2 > 0) out[line.slice(0, eq2)] = line.slice(eq2 + 1);
@@ -2129,10 +2148,10 @@ function rejectUnknown(obj, allowed, where, file) {
2129
2148
  );
2130
2149
  }
2131
2150
  }
2132
- function parseRepoConfig(text, file) {
2151
+ function parseRepoConfig(text2, file) {
2133
2152
  let raw;
2134
2153
  try {
2135
- raw = file.endsWith(".json") ? JSON.parse(text) : parse(text);
2154
+ raw = file.endsWith(".json") ? JSON.parse(text2) : parse(text2);
2136
2155
  } catch (e) {
2137
2156
  throw new RepoConfigError(`${file}: could not be parsed \u2014 ${e.message}`);
2138
2157
  }
@@ -2648,9 +2667,9 @@ function parseShipEnv(v) {
2648
2667
  if (typeof val !== "string" && typeof val !== "number" && typeof val !== "boolean") {
2649
2668
  throw new ConfigError(`ship.env.${k} must be a string, number or boolean`);
2650
2669
  }
2651
- const text = String(val);
2652
- if (/[\r\n\0]/.test(text)) throw new ConfigError(`ship.env.${k} must be a single line`);
2653
- out[k] = text;
2670
+ const text2 = String(val);
2671
+ if (/[\r\n\0]/.test(text2)) throw new ConfigError(`ship.env.${k} must be a single line`);
2672
+ out[k] = text2;
2654
2673
  }
2655
2674
  return Object.keys(out).length ? out : void 0;
2656
2675
  }
@@ -3620,10 +3639,10 @@ function runScript(script, opts) {
3620
3639
  let output = "";
3621
3640
  let pending = "";
3622
3641
  const absorb = (buf) => {
3623
- const text = buf.toString();
3624
- output += text;
3642
+ const text2 = buf.toString();
3643
+ output += text2;
3625
3644
  if (!opts.onLine) return;
3626
- pending += text;
3645
+ pending += text2;
3627
3646
  const lines = pending.split("\n");
3628
3647
  pending = lines.pop() ?? "";
3629
3648
  for (const l of lines) opts.onLine(l);
@@ -4043,9 +4062,9 @@ function planCron(ship, crewHome, job) {
4043
4062
  function unitAbandonsAgents(plan, read) {
4044
4063
  const path = plan.unitPaths[0];
4045
4064
  if (!path || plan.mechanism !== "launchd" && plan.mechanism !== "systemd") return null;
4046
- const text = read(path);
4047
- if (text === null) return null;
4048
- return plan.mechanism === "launchd" ? /<key>AbandonProcessGroup<\/key>\s*<true\/>/.test(text) : /^KillMode=process\s*$/m.test(text);
4065
+ const text2 = read(path);
4066
+ if (text2 === null) return null;
4067
+ return plan.mechanism === "launchd" ? /<key>AbandonProcessGroup<\/key>\s*<true\/>/.test(text2) : /^KillMode=process\s*$/m.test(text2);
4049
4068
  }
4050
4069
  function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run", ctx = USER_INSTALL) {
4051
4070
  if (hostShip === "macos") return planLaunchd(ship, crewHome, job);
@@ -4230,8 +4249,8 @@ function launchAgentsDir() {
4230
4249
  function xmlUnescape(s) {
4231
4250
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
4232
4251
  }
4233
- function programFromPlist(text) {
4234
- const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text);
4252
+ function programFromPlist(text2) {
4253
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text2);
4235
4254
  if (!block) return null;
4236
4255
  const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => xmlUnescape(m[1]));
4237
4256
  return args.find((a) => a.endsWith("/bin/crew")) ?? args[0] ?? null;
@@ -4297,9 +4316,9 @@ function clearForeignLaunchdUnits(crewHome, replace, dryRun, log, dir, loaded) {
4297
4316
  function removeAllLaunchdUnits(dryRun, log, dir, loaded) {
4298
4317
  for (const unit of listLaunchdUnits(dir, loaded)) removeLaunchdUnit(unit, dryRun, log);
4299
4318
  }
4300
- function parseLaunchctlList(text) {
4319
+ function parseLaunchctlList(text2) {
4301
4320
  const jobs = /* @__PURE__ */ new Map();
4302
- for (const line of text.split("\n")) {
4321
+ for (const line of text2.split("\n")) {
4303
4322
  const cols = line.split(" ");
4304
4323
  if (cols.length !== 3 || cols[2] === "Label") continue;
4305
4324
  jobs.set(cols[2], { pid: cols[0], lastExit: cols[1] });
@@ -4323,8 +4342,8 @@ function readCrontab() {
4323
4342
  const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
4324
4343
  return res.status === 0 ? res.stdout ?? "" : "";
4325
4344
  }
4326
- function writeCrontab(text) {
4327
- spawnSync4("crontab", ["-"], { input: text, encoding: "utf8" });
4345
+ function writeCrontab(text2) {
4346
+ spawnSync4("crontab", ["-"], { input: text2, encoding: "utf8" });
4328
4347
  }
4329
4348
  function addCrontabLine(marker, line) {
4330
4349
  const existing = readCrontab();
@@ -5337,10 +5356,10 @@ function formatPushAuth(repo, r) {
5337
5356
  return `${label}FAILED \u2014 ${r.detail}; ${r.hint}
5338
5357
  `;
5339
5358
  }
5340
- async function settleRepos(items, nameOf, resolve6) {
5359
+ async function settleRepos(items, nameOf, resolve7) {
5341
5360
  const out = await Promise.all(items.map(async (t) => {
5342
5361
  try {
5343
- return { ok: true, value: await resolve6(t) };
5362
+ return { ok: true, value: await resolve7(t) };
5344
5363
  } catch (e) {
5345
5364
  return { ok: false, repo: nameOf(t), detail: e.message };
5346
5365
  }
@@ -6641,8 +6660,8 @@ async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /
6641
6660
 
6642
6661
  // src/ticket-attribution.ts
6643
6662
  var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
6644
- function ticketMentions(text) {
6645
- return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
6663
+ function ticketMentions(text2) {
6664
+ return [...text2.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
6646
6665
  }
6647
6666
  var TicketAttributionTracker = class {
6648
6667
  #tokensByTicket = /* @__PURE__ */ new Map();
@@ -6661,13 +6680,13 @@ var TicketAttributionTracker = class {
6661
6680
  return this.#current;
6662
6681
  }
6663
6682
  /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
6664
- note(text) {
6665
- const mentions = ticketMentions(text);
6683
+ note(text2) {
6684
+ const mentions = ticketMentions(text2);
6666
6685
  if (mentions.length > 0) this.#current = mentions[0];
6667
6686
  }
6668
6687
  /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
6669
- add(text, tokens) {
6670
- const mentions = ticketMentions(text);
6688
+ add(text2, tokens) {
6689
+ const mentions = ticketMentions(text2);
6671
6690
  if (mentions.length > 0) this.#current = mentions[0];
6672
6691
  if (this.#current === void 0 || tokens <= 0) return;
6673
6692
  this.#turns++;
@@ -6764,10 +6783,10 @@ function resultText(content) {
6764
6783
  if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
6765
6784
  return "";
6766
6785
  }
6767
- function isTransportFailure(isError, text) {
6786
+ function isTransportFailure(isError, text2) {
6768
6787
  if (!isError) {
6769
6788
  try {
6770
- const doc = JSON.parse(text);
6789
+ const doc = JSON.parse(text2);
6771
6790
  if (doc && typeof doc === "object") {
6772
6791
  const code = doc.statusCode;
6773
6792
  return typeof code === "number" && code >= 500;
@@ -6775,7 +6794,7 @@ function isTransportFailure(isError, text) {
6775
6794
  } catch {
6776
6795
  }
6777
6796
  }
6778
- return TRANSPORT_FAILURE.test(text);
6797
+ return TRANSPORT_FAILURE.test(text2);
6779
6798
  }
6780
6799
  var HandoffTranscriptTracker = class {
6781
6800
  open = /* @__PURE__ */ new Map();
@@ -7672,6 +7691,30 @@ var Tracker = class {
7672
7691
  limit: 200
7673
7692
  });
7674
7693
  }
7694
+ /**
7695
+ * Every `live` Periscopes row on any ship (CREW-2871), for the one-per-issue
7696
+ * guard and doctor. Empty without the table.
7697
+ */
7698
+ async livePeriscopesAllShips() {
7699
+ const modelId = this.route.resolved?.periscopesModelId;
7700
+ if (!modelId) return [];
7701
+ return this.client.records.list(modelId, {
7702
+ filters: encodeFilters([{ columnName: "state", operator: "EQ", value: "live" }]),
7703
+ limit: 500
7704
+ });
7705
+ }
7706
+ /** The `live` Periscopes rows of one ticket, on any ship (CREW-2871). */
7707
+ async livePeriscopesForTicket(ticketRecordId) {
7708
+ const modelId = this.route.resolved?.periscopesModelId;
7709
+ if (!modelId) return [];
7710
+ return this.client.records.list(modelId, {
7711
+ filters: encodeFilters([
7712
+ { columnName: "issue_id", operator: "EQ", value: ticketRecordId },
7713
+ { columnName: "state", operator: "EQ", value: "live" }
7714
+ ]),
7715
+ limit: 50
7716
+ });
7717
+ }
7675
7718
  /** Patches one Periscopes row (EPIC-077). */
7676
7719
  async updatePeriscope(rowId, fields) {
7677
7720
  const modelId = this.route.resolved?.periscopesModelId;
@@ -7696,12 +7739,12 @@ var Tracker = class {
7696
7739
  const conds = [{ columnName: "ship_id", operator: "EQ", value: shipId }];
7697
7740
  if (states?.length) conds.push({ columnName: "state", operator: "IN", value: states });
7698
7741
  const rows = await this.client.records.list(modelId, { filters: encodeFilters(conds), limit: 200 });
7699
- const refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7700
- const ids = [...new Set(rows.map((r) => refId(r.issue_id)).filter((x) => !!x))];
7742
+ const refId2 = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7743
+ const ids = [...new Set(rows.map((r) => refId2(r.issue_id)).filter((x) => !!x))];
7701
7744
  const tickets = new Map((await this.ticketsByIds(ids)).map((t) => [t.id, t]));
7702
7745
  const out = [];
7703
7746
  for (const row of rows) {
7704
- const t = tickets.get(refId(row.issue_id) ?? "");
7747
+ const t = tickets.get(refId2(row.issue_id) ?? "");
7705
7748
  if (t) out.push({ row, ticket: t });
7706
7749
  }
7707
7750
  return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
@@ -8610,15 +8653,15 @@ async function replayPendingHandoffs(i) {
8610
8653
  continue;
8611
8654
  }
8612
8655
  if (v.kind === "unverified") throw new Error(v.why);
8613
- const text = rec.comment ?? (rec.commentLanded ? "" : rec.finalText);
8614
- if (text) {
8615
- const head = firstLine(text);
8656
+ const text2 = rec.comment ?? (rec.commentLanded ? "" : rec.finalText);
8657
+ if (text2) {
8658
+ const head = firstLine(text2);
8616
8659
  const cc = i.contract.comments;
8617
8660
  const seen = (await i.writer.comments()).some((c) => {
8618
8661
  const row = c;
8619
8662
  return row[cc.parent] === t.id && row[cc.author] === seatId && firstLine(String(row[cc.body] ?? "")) === head;
8620
8663
  });
8621
- if (!seen) await i.writer.postComment(t.id, text, seatId);
8664
+ if (!seen) await i.writer.postComment(t.id, text2, seatId);
8622
8665
  }
8623
8666
  await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), seatId, "pushed");
8624
8667
  await i.writer.updateTicket(t.id, rec.patch);
@@ -8647,15 +8690,15 @@ var SkillIncludeError = class extends Error {
8647
8690
  var MAX_INCLUDE_DEPTH = 3;
8648
8691
  var MAX_INCLUDED_BYTES = 2e5;
8649
8692
  var MARKER = /\{\{[ \t]*skill:[ \t]*([^{}\r\n]*?)[ \t]*\}\}/g;
8650
- function skillMarkerNames(text) {
8693
+ function skillMarkerNames(text2) {
8651
8694
  const names = [];
8652
- for (const m of text.matchAll(MARKER)) {
8695
+ for (const m of text2.matchAll(MARKER)) {
8653
8696
  const name = m[1].trim();
8654
8697
  if (name && !names.includes(name)) names.push(name);
8655
8698
  }
8656
8699
  return names;
8657
8700
  }
8658
- function expandSkillMarkers(text, lookup2, opts = {}) {
8701
+ function expandSkillMarkers(text2, lookup2, opts = {}) {
8659
8702
  const maxDepth = opts.maxDepth ?? MAX_INCLUDE_DEPTH;
8660
8703
  const maxBytes = opts.maxBytes ?? MAX_INCLUDED_BYTES;
8661
8704
  const warnings = [];
@@ -8682,7 +8725,7 @@ function expandSkillMarkers(text, lookup2, opts = {}) {
8682
8725
  included += bytes;
8683
8726
  return expand2(trimmed, [...chain, name]);
8684
8727
  });
8685
- return { text: expand2(text, []), warnings };
8728
+ return { text: expand2(text2, []), warnings };
8686
8729
  }
8687
8730
 
8688
8731
  // src/skills.ts
@@ -8693,9 +8736,9 @@ import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError
8693
8736
 
8694
8737
  // src/prompt-additions.ts
8695
8738
  var PROMPT_ADDITIONS_SEPARATOR = "---\n\nAdditional instructions from this workspace. Where they conflict with anything above, these take precedence.";
8696
- function appendAdditions(text, additions) {
8697
- if (typeof additions !== "string" || additions.trim() === "") return text;
8698
- return `${text}
8739
+ function appendAdditions(text2, additions) {
8740
+ if (typeof additions !== "string" || additions.trim() === "") return text2;
8741
+ return `${text2}
8699
8742
 
8700
8743
  ${PROMPT_ADDITIONS_SEPARATOR}
8701
8744
 
@@ -9170,8 +9213,8 @@ function describeCrewLink(o) {
9170
9213
  var SkillSyncError = class extends Error {
9171
9214
  };
9172
9215
  function parseSkillFile(path) {
9173
- const text = readFileSync18(path, "utf8");
9174
- const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
9216
+ const text2 = readFileSync18(path, "utf8");
9217
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text2);
9175
9218
  if (!match) throw new SkillSyncError(`${path}: missing YAML frontmatter (expected a leading --- block)`);
9176
9219
  const frontmatter = match[1];
9177
9220
  const body = match[2];
@@ -9360,8 +9403,8 @@ function isEditedSkillRow(row, localPrompt, cached) {
9360
9403
  function skillMarkerWarnings(promptsDir, personaPrompts) {
9361
9404
  const shipped = new Set(listLocalSkills(promptsDir).map((s) => s.name));
9362
9405
  const out = [];
9363
- for (const [role, text] of Object.entries(personaPrompts)) {
9364
- for (const name of skillMarkerNames(text ?? "")) {
9406
+ for (const [role, text2] of Object.entries(personaPrompts)) {
9407
+ for (const name of skillMarkerNames(text2 ?? "")) {
9365
9408
  if (!shipped.has(name)) out.push(`${role}: brief includes {{skill: ${name}}} but this prompt set ships no skill with that name`);
9366
9409
  }
9367
9410
  }
@@ -9698,15 +9741,15 @@ var FileTail = class {
9698
9741
  return "";
9699
9742
  }
9700
9743
  try {
9701
- let text = "";
9744
+ let text2 = "";
9702
9745
  const chunk = Buffer.alloc(64 * 1024);
9703
9746
  for (; ; ) {
9704
9747
  const n = readSync(fd, chunk, 0, chunk.length, this.offset);
9705
9748
  if (n <= 0) break;
9706
9749
  this.offset += n;
9707
- text += this.decoder.write(chunk.subarray(0, n));
9750
+ text2 += this.decoder.write(chunk.subarray(0, n));
9708
9751
  }
9709
- return text;
9752
+ return text2;
9710
9753
  } finally {
9711
9754
  closeSync3(fd);
9712
9755
  }
@@ -9745,8 +9788,8 @@ var RunObserver = class {
9745
9788
  this.liveTicket = id.ticket ?? void 0;
9746
9789
  this.mapper = adapter.createStreamMapper?.() ?? { map: (p) => adapter.mapStreamLine(p), flush: () => [] };
9747
9790
  }
9748
- feed(text) {
9749
- this.buf += text;
9791
+ feed(text2) {
9792
+ this.buf += text2;
9750
9793
  const lines = this.buf.split("\n");
9751
9794
  this.buf = lines.pop() ?? "";
9752
9795
  for (const line of lines) this.handleLine(line);
@@ -10011,7 +10054,7 @@ function spawnAgent(plan, emit, hintMoved) {
10011
10054
  for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
10012
10055
  const eventsSink = openSink(plan.eventsPath);
10013
10056
  const id = identityOf(plan, adapter.harness);
10014
- return new Promise((resolve6, reject) => {
10057
+ return new Promise((resolve7, reject) => {
10015
10058
  let streamFd;
10016
10059
  try {
10017
10060
  mkdirSync12(dirname8(plan.streamPath), { recursive: true });
@@ -10095,7 +10138,7 @@ function spawnAgent(plan, emit, hintMoved) {
10095
10138
  plan.agentLog?.apiKey,
10096
10139
  hintMoved
10097
10140
  );
10098
- resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
10141
+ resolve7({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
10099
10142
  });
10100
10143
  child.stdin?.on("error", (err3) => {
10101
10144
  emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
@@ -10428,7 +10471,7 @@ var SELFTEST_TIMEOUT_MS = 12e4;
10428
10471
  var KEY_VAR = "CREW_API_KEY";
10429
10472
  var SELFTEST_PROMPT = "This is a read-only connectivity check. Use the tracker MCP tool to read this workspace (the workspaces find-one tool, using the workspace id below) and reply with the workspace's name alone, on one line. Do not write anything, do not create files, and do not use any other tool.\n\nWorkspace id: ";
10430
10473
  var realDeps = {
10431
- run: (o) => new Promise((resolve6) => {
10474
+ run: (o) => new Promise((resolve7) => {
10432
10475
  let stdout = "";
10433
10476
  let stderr = "";
10434
10477
  let timedOut = false;
@@ -10436,7 +10479,7 @@ var realDeps = {
10436
10479
  const done = (r) => {
10437
10480
  if (!settled) {
10438
10481
  settled = true;
10439
- resolve6(r);
10482
+ resolve7(r);
10440
10483
  }
10441
10484
  };
10442
10485
  const child = spawn3(o.bin, o.args, { cwd: o.cwd, env: o.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
@@ -10545,12 +10588,12 @@ function classify(spec, run2, seen) {
10545
10588
  return fail("binary-not-found", `could not start \`${bin}\` (${run2.spawnError.message})`);
10546
10589
  }
10547
10590
  if (run2.timedOut) return fail("timeout", "no answer within the time limit");
10548
- const text = `${seen.toolOutput}
10591
+ const text2 = `${seen.toolOutput}
10549
10592
  ${run2.stderr}`;
10550
10593
  if (REFUSED.test(seen.toolOutput) || seen.tools.some((t) => t.startsWith("mcp__")) && REFUSED.test(run2.stderr)) {
10551
10594
  return fail("key-refused", "the tracker returned 401/403 to the MCP call");
10552
10595
  }
10553
- if (seen.mcpFailed || !seen.tools.some((t) => t.startsWith("mcp__")) && MCP_GONE.test(`${text}
10596
+ if (seen.mcpFailed || !seen.tools.some((t) => t.startsWith("mcp__")) && MCP_GONE.test(`${text2}
10554
10597
  ${seen.texts.join("\n")}`)) {
10555
10598
  return fail("mcp-unavailable", "the tracker MCP tool was not available to the session");
10556
10599
  }
@@ -10642,7 +10685,7 @@ function doctorSelftestLines(stored, toolchains) {
10642
10685
  }
10643
10686
 
10644
10687
  // src/cli.ts
10645
- import { resolve as resolve5, dirname as dirname22, join as join44, basename as basename13 } from "path";
10688
+ import { resolve as resolve6, dirname as dirname23, join as join46, basename as basename14 } from "path";
10646
10689
  import { fileURLToPath } from "url";
10647
10690
  import { hostname as hostname3, homedir as homedir10 } from "os";
10648
10691
  import { spawnSync as spawnSync8 } from "child_process";
@@ -12212,13 +12255,13 @@ function writeDigest(o, d, role, stateDir, runId, own) {
12212
12255
  blockerInfo: d.info
12213
12256
  };
12214
12257
  const body = role === "qa" ? qaDigest(input) : buildingDigest(input);
12215
- const text = own ? `${ownRunBanner(own)}
12258
+ const text2 = own ? `${ownRunBanner(own)}
12216
12259
 
12217
12260
  ${body}` : body;
12218
12261
  const path = digestPath(stateDir, routeSlug(o.route.route), role, runId);
12219
- writeFileSync17(path, text);
12220
- o.emit.emit(`queue digest written for ${role} (${Buffer.byteLength(text)} bytes)`, {
12221
- data: { role, bytes: Buffer.byteLength(text) }
12262
+ writeFileSync17(path, text2);
12263
+ o.emit.emit(`queue digest written for ${role} (${Buffer.byteLength(text2)} bytes)`, {
12264
+ data: { role, bytes: Buffer.byteLength(text2) }
12222
12265
  });
12223
12266
  return true;
12224
12267
  } catch (e) {
@@ -12782,10 +12825,10 @@ function renderFullConfig(a, routeBlock) {
12782
12825
  routes:
12783
12826
  ${routeBlock}`;
12784
12827
  }
12785
- function writeNewConfig(path, text) {
12828
+ function writeNewConfig(path, text2) {
12786
12829
  mkdirSync16(dirname11(path), { recursive: true });
12787
12830
  try {
12788
- writeFileSync18(path, text, { mode: 384, flag: "wx" });
12831
+ writeFileSync18(path, text2, { mode: 384, flag: "wx" });
12789
12832
  return true;
12790
12833
  } catch (e) {
12791
12834
  if (e.code === "EEXIST") return false;
@@ -12822,11 +12865,11 @@ import { homedir as homedir8 } from "os";
12822
12865
  import { dirname as dirname12, join as join28 } from "path";
12823
12866
  import { delimiter as delimiter2 } from "path";
12824
12867
  var CHOICE_EXIT_CODE = 3;
12825
- function createEmitter(json, write) {
12868
+ function createEmitter(json2, write) {
12826
12869
  return {
12827
- json,
12870
+ json: json2,
12828
12871
  emit(event, message, data = {}) {
12829
- if (!json) return;
12872
+ if (!json2) return;
12830
12873
  write(`${JSON.stringify({ ...data, event, message })}
12831
12874
  `);
12832
12875
  }
@@ -12933,8 +12976,8 @@ function matchRepoName(origin, resolved) {
12933
12976
  }
12934
12977
  return void 0;
12935
12978
  }
12936
- function routeNamesOf(text) {
12937
- const routes = parseDocument(text).get("routes", true);
12979
+ function routeNamesOf(text2) {
12980
+ const routes = parseDocument(text2).get("routes", true);
12938
12981
  if (!isSeq(routes)) return [];
12939
12982
  return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
12940
12983
  }
@@ -12968,8 +13011,8 @@ function routeMapOf(doc, route2) {
12968
13011
  const have = isSeq(routes) ? routes.items.map((i) => isMap(i) ? String(i.get("route")) : "?").join(", ") : "none";
12969
13012
  throw new ReposError(`no route "${route2}" in the config (have: ${have})`);
12970
13013
  }
12971
- function configuredRepos(text, route2, base) {
12972
- const doc = parseDocument(text);
13014
+ function configuredRepos(text2, route2, base) {
13015
+ const doc = parseDocument(text2);
12973
13016
  const m = routeMapOf(doc, route2);
12974
13017
  const repos = m.get("repos");
12975
13018
  if (isMap(repos)) {
@@ -12983,8 +13026,8 @@ function configuredRepos(text, route2, base) {
12983
13026
  const dir = m.get("dir");
12984
13027
  return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12985
13028
  }
12986
- function cloneBaseOf(text, route2, base, isCheckout) {
12987
- const m = routeMapOf(parseDocument(text), route2);
13029
+ function cloneBaseOf(text2, route2, base, isCheckout) {
13030
+ const m = routeMapOf(parseDocument(text2), route2);
12988
13031
  const repos = m.get("repos", true);
12989
13032
  if (isMap(repos) && repos.items.length > 0) return null;
12990
13033
  const dir = m.get("dir");
@@ -12992,8 +13035,8 @@ function cloneBaseOf(text, route2, base, isCheckout) {
12992
13035
  const abs = expandDir(String(dir), base);
12993
13036
  return isCheckout(abs) ? null : abs;
12994
13037
  }
12995
- function routeSetsHooks(text, route2, name) {
12996
- const m = routeMapOf(parseDocument(text), route2);
13038
+ function routeSetsHooks(text2, route2, name) {
13039
+ const m = routeMapOf(parseDocument(text2), route2);
12997
13040
  const repos = m.get("repos", true);
12998
13041
  let target = m;
12999
13042
  if (isMap(repos)) target = repos.get(name, true);
@@ -13100,12 +13143,12 @@ function planRepoAdd(i) {
13100
13143
  }
13101
13144
  return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
13102
13145
  }
13103
- function planRepoRemove(text, base, route2, path) {
13104
- const doc = parseDocument(text);
13146
+ function planRepoRemove(text2, base, route2, path) {
13147
+ const doc = parseDocument(text2);
13105
13148
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
13106
13149
  const m = routeMapOf(doc, route2);
13107
13150
  const target = expandDir(path, base);
13108
- const existing = configuredRepos(text, route2, base);
13151
+ const existing = configuredRepos(text2, route2, base);
13109
13152
  const hit = existing.find((e) => e.dir === target);
13110
13153
  if (!hit) throw new ReposError(`${target} is not configured on route ${route2}`);
13111
13154
  const repos = m.get("repos", true);
@@ -13230,8 +13273,8 @@ async function gatherHealth(c, check) {
13230
13273
  function routesInScope(routes, named) {
13231
13274
  return named ? routes.filter((r) => r.route === named) : routes;
13232
13275
  }
13233
- function planEnable(text, route2) {
13234
- const doc = parseDocument2(text);
13276
+ function planEnable(text2, route2) {
13277
+ const doc = parseDocument2(text2);
13235
13278
  routeMapOf(doc, route2).set("enabled", true);
13236
13279
  return String(doc);
13237
13280
  }
@@ -13425,13 +13468,13 @@ function realProvisionDeps(crewHome) {
13425
13468
  enableRoute(route2) {
13426
13469
  const file = configFile();
13427
13470
  if (!file) return false;
13428
- const text = readFileSync23(file, "utf8");
13429
- const doc = parseDocument3(text);
13471
+ const text2 = readFileSync23(file, "utf8");
13472
+ const doc = parseDocument3(text2);
13430
13473
  const routes = doc.get("routes");
13431
13474
  const entry = routes?.items?.find((r) => r.get("route") === route2);
13432
13475
  if (!entry) throw new Error(`route ${route2} is not in ${file}`);
13433
13476
  if (entry.get("enabled") === true) return false;
13434
- writeFileSync19(file, planEnable(text, route2));
13477
+ writeFileSync19(file, planEnable(text2, route2));
13435
13478
  return true;
13436
13479
  },
13437
13480
  installState() {
@@ -15615,18 +15658,18 @@ function readFrom(file, from) {
15615
15658
  const buf = Buffer.alloc(size - from);
15616
15659
  readSync2(fd, buf, 0, buf.length, from);
15617
15660
  closeSync4(fd);
15618
- const text = buf.toString("utf8");
15619
- const lastNewline = text.lastIndexOf("\n");
15661
+ const text2 = buf.toString("utf8");
15662
+ const lastNewline = text2.lastIndexOf("\n");
15620
15663
  if (lastNewline === -1) return { events: [], offset: from };
15621
15664
  const events = [];
15622
- for (const line of text.slice(0, lastNewline).split("\n")) {
15665
+ for (const line of text2.slice(0, lastNewline).split("\n")) {
15623
15666
  if (!line.trim()) continue;
15624
15667
  try {
15625
15668
  events.push(JSON.parse(line));
15626
15669
  } catch {
15627
15670
  }
15628
15671
  }
15629
- return { events, offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1), "utf8") };
15672
+ return { events, offset: from + Buffer.byteLength(text2.slice(0, lastNewline + 1), "utf8") };
15630
15673
  }
15631
15674
  function startWatch(o) {
15632
15675
  const out = o.out ?? ((s) => process.stdout.write(s));
@@ -15921,12 +15964,12 @@ function planNameSync(local, row, lastSynced) {
15921
15964
  if (rowMoved) return { drift: "row", action: "adopt" };
15922
15965
  return { drift: "local", action: "push" };
15923
15966
  }
15924
- function shipNameOfYaml(text) {
15925
- const v = parseDocument5(text).getIn(["ship", "name"]);
15967
+ function shipNameOfYaml(text2) {
15968
+ const v = parseDocument5(text2).getIn(["ship", "name"]);
15926
15969
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
15927
15970
  }
15928
- function setShipNameInYaml(text, name) {
15929
- const doc = parseDocument5(text);
15971
+ function setShipNameInYaml(text2, name) {
15972
+ const doc = parseDocument5(text2);
15930
15973
  doc.setIn(["ship", "name"], name);
15931
15974
  return doc.toString();
15932
15975
  }
@@ -16015,8 +16058,8 @@ async function reconcileShipName(i) {
16015
16058
  }
16016
16059
  if (result.status !== "ok") return result;
16017
16060
  if (result.action === "adopt" || result.action === "row-wins") {
16018
- const text = readFileSync27(i.configFile, "utf8");
16019
- writeFileSync22(i.configFile, setShipNameInYaml(text, result.name));
16061
+ const text2 = readFileSync27(i.configFile, "utf8");
16062
+ writeFileSync22(i.configFile, setShipNameInYaml(text2, result.name));
16020
16063
  i.ship.name = result.name;
16021
16064
  if (result.action === "row-wins") {
16022
16065
  i.emit?.warn(
@@ -16917,11 +16960,11 @@ function packageManager(f) {
16917
16960
  return "npm";
16918
16961
  }
16919
16962
  function fromPackageJson(f) {
16920
- const text = f.read("package.json");
16921
- if (text === void 0) return void 0;
16963
+ const text2 = f.read("package.json");
16964
+ if (text2 === void 0) return void 0;
16922
16965
  let pkg;
16923
16966
  try {
16924
- pkg = JSON.parse(text);
16967
+ pkg = JSON.parse(text2);
16925
16968
  } catch {
16926
16969
  return void 0;
16927
16970
  }
@@ -16944,11 +16987,11 @@ function fromPyproject(f) {
16944
16987
  return f.exists("pyproject.toml") ? { test: "pytest" } : void 0;
16945
16988
  }
16946
16989
  function fromMakefile(f) {
16947
- const text = f.read("Makefile") ?? f.read("makefile") ?? f.read("GNUmakefile");
16948
- if (text === void 0) return void 0;
16990
+ const text2 = f.read("Makefile") ?? f.read("makefile") ?? f.read("GNUmakefile");
16991
+ if (text2 === void 0) return void 0;
16949
16992
  const out = {};
16950
16993
  for (const target of ["test", "build"]) {
16951
- if (new RegExp(`^${target}\\s*:`, "m").test(text)) out[target] = `make ${target}`;
16994
+ if (new RegExp(`^${target}\\s*:`, "m").test(text2)) out[target] = `make ${target}`;
16952
16995
  }
16953
16996
  return out;
16954
16997
  }
@@ -17041,16 +17084,16 @@ function parseHooksArgs(args, routes) {
17041
17084
  if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--hook KEY=CMD] [--json]");
17042
17085
  return { route: route2, name: rest[0] };
17043
17086
  }
17044
- function repoDirOf(text, base, route2, name) {
17045
- const repos = configuredRepos(text, route2, base);
17087
+ function repoDirOf(text2, base, route2, name) {
17088
+ const repos = configuredRepos(text2, route2, base);
17046
17089
  const hit = repos.find((r) => r.name === name);
17047
17090
  if (!hit) {
17048
17091
  throw new ReposError(`route ${route2} has no repo named "${name}" (have: ${repos.map((r) => r.name).join(", ") || "none"})`);
17049
17092
  }
17050
17093
  return hit.dir;
17051
17094
  }
17052
- function planRouteHooks(text, route2, name, hooks, opts = {}) {
17053
- const doc = parseDocument6(text);
17095
+ function planRouteHooks(text2, route2, name, hooks, opts = {}) {
17096
+ const doc = parseDocument6(text2);
17054
17097
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
17055
17098
  const m = routeMapOf(doc, route2);
17056
17099
  const repos = m.get("repos", true);
@@ -17086,19 +17129,19 @@ function planRouteHooks(text, route2, name, hooks, opts = {}) {
17086
17129
  }
17087
17130
  return { text: String(doc), written, kept, replaced };
17088
17131
  }
17089
- function clearRouteHooks(text, route2, name) {
17090
- const doc = parseDocument6(text);
17132
+ function clearRouteHooks(text2, route2, name) {
17133
+ const doc = parseDocument6(text2);
17091
17134
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
17092
17135
  const m = routeMapOf(doc, route2);
17093
17136
  const repos = m.get("repos", true);
17094
17137
  let target = m;
17095
17138
  if (isMap2(repos)) {
17096
17139
  const entry = repos.get(name, true);
17097
- if (!isMap2(entry)) return { text, removed: [] };
17140
+ if (!isMap2(entry)) return { text: text2, removed: [] };
17098
17141
  target = entry;
17099
17142
  }
17100
17143
  const current = target.get("hooks", true);
17101
- if (!isMap2(current)) return { text, removed: [] };
17144
+ if (!isMap2(current)) return { text: text2, removed: [] };
17102
17145
  const removed = current.items.map((i) => String(i.key.value ?? i.key));
17103
17146
  target.delete("hooks");
17104
17147
  return { text: String(doc), removed };
@@ -17122,9 +17165,9 @@ function starterRepoConfig(hooks, release) {
17122
17165
  if (release.verifyMatch) lines.push(" verify:", ` match: ${release.verifyMatch}`);
17123
17166
  lines.push("");
17124
17167
  } else lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
17125
- const text = lines.join("\n");
17126
- parseRepoConfig(text, ".crew.yaml");
17127
- return text;
17168
+ const text2 = lines.join("\n");
17169
+ parseRepoConfig(text2, ".crew.yaml");
17170
+ return text2;
17128
17171
  }
17129
17172
  function existingRepoConfig(f) {
17130
17173
  return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
@@ -17183,7 +17226,7 @@ function agentInvocation(spec, model) {
17183
17226
  });
17184
17227
  }
17185
17228
  function parseAgentStream(out, dir) {
17186
- let text = "";
17229
+ let text2 = "";
17187
17230
  let isError = false;
17188
17231
  const files = /* @__PURE__ */ new Set();
17189
17232
  let base = dir;
@@ -17205,19 +17248,19 @@ function parseAgentStream(out, dir) {
17205
17248
  if (typeof p === "string") files.add(isAbsolute2(p) ? relative(base, p) : p);
17206
17249
  }
17207
17250
  } else if (ev?.type === "result") {
17208
- text = typeof ev.result === "string" ? ev.result : "";
17251
+ text2 = typeof ev.result === "string" ? ev.result : "";
17209
17252
  isError = ev.is_error === true;
17210
17253
  }
17211
17254
  }
17212
- return { text, isError, filesRead: [...files].sort() };
17255
+ return { text: text2, isError, filesRead: [...files].sort() };
17213
17256
  }
17214
- function parseAgentHooks(text) {
17215
- const start = text.indexOf("{");
17216
- const end = text.lastIndexOf("}");
17257
+ function parseAgentHooks(text2) {
17258
+ const start = text2.indexOf("{");
17259
+ const end = text2.lastIndexOf("}");
17217
17260
  if (start < 0 || end <= start) throw new AgentSuggestError("bad_output", "the toolchain did not answer with a JSON object");
17218
17261
  let obj;
17219
17262
  try {
17220
- obj = JSON.parse(text.slice(start, end + 1));
17263
+ obj = JSON.parse(text2.slice(start, end + 1));
17221
17264
  } catch {
17222
17265
  throw new AgentSuggestError("bad_output", "the toolchain's answer was not valid JSON");
17223
17266
  }
@@ -17249,7 +17292,7 @@ async function runAgentSuggest(o) {
17249
17292
  const before = porcelain(o.dir);
17250
17293
  const timeout = AbortSignal.timeout(o.timeoutMs ?? DEFAULT_TIMEOUT_MS);
17251
17294
  const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
17252
- const run2 = await new Promise((resolve6) => {
17295
+ const run2 = await new Promise((resolve7) => {
17253
17296
  let out = "";
17254
17297
  let err3 = "";
17255
17298
  const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
@@ -17258,7 +17301,7 @@ async function runAgentSuggest(o) {
17258
17301
  if (settled) return;
17259
17302
  settled = true;
17260
17303
  signal.removeEventListener("abort", onAbort);
17261
- resolve6(r);
17304
+ resolve7(r);
17262
17305
  };
17263
17306
  const onAbort = () => {
17264
17307
  try {
@@ -17702,7 +17745,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
17702
17745
  }
17703
17746
 
17704
17747
  // src/cli.ts
17705
- import { realpathSync as realpathSync3, readFileSync as readFileSync36, existsSync as existsSync41, mkdirSync as mkdirSync26, writeFileSync as writeFileSync28, rmSync as rmSync19, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync18 } from "fs";
17748
+ import { realpathSync as realpathSync3, readFileSync as readFileSync37, existsSync as existsSync42, mkdirSync as mkdirSync27, writeFileSync as writeFileSync29, rmSync as rmSync20, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync19 } from "fs";
17706
17749
  import { createInterface } from "readline/promises";
17707
17750
 
17708
17751
  // src/periscope-cmd.ts
@@ -17710,6 +17753,13 @@ import { existsSync as existsSync35, mkdirSync as mkdirSync21, readFileSync as r
17710
17753
  import { join as join37 } from "path";
17711
17754
  var PeriscopeError = class extends Error {
17712
17755
  };
17756
+ var LivePeriscopeError = class extends PeriscopeError {
17757
+ data;
17758
+ constructor(tag, number, live) {
17759
+ super(`${tag} already has a live periscope on ${live.ship}: ${live.url} (worktree ${live.worktree}) \u2014 run \`crew periscope down ${number} --keep|--discard\` on ${live.ship}, or resume it there with \`crew periscope up --resume ${number}\``);
17760
+ this.data = { refused: "live_periscope_exists", ...live };
17761
+ }
17762
+ };
17713
17763
  var priorDir = (stateDir) => join37(stateDir, "periscope-priors");
17714
17764
  var priorPath = (stateDir, ticketRecordId) => join37(priorDir(stateDir), `${ticketRecordId}.json`);
17715
17765
  function removePeriscopePrior(stateDir, ticketRecordId) {
@@ -17776,6 +17826,10 @@ function periscopeCaps(l) {
17776
17826
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
17777
17827
  };
17778
17828
  }
17829
+ async function assertNoLivePeriscope(e, t, number) {
17830
+ const live = (await e.liveRows(t.id))[0];
17831
+ if (live) throw new LivePeriscopeError(t.tag, number, live);
17832
+ }
17779
17833
  async function existingRefusal(e, t) {
17780
17834
  if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
17781
17835
  if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
@@ -17834,6 +17888,7 @@ async function runPeriscopeUp(d, args) {
17834
17888
  if (!ex) throw new PeriscopeError("--issue is not supported here");
17835
17889
  prior = await ex.load(args.issue);
17836
17890
  if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
17891
+ await assertNoLivePeriscope(ex, prior, args.issue);
17837
17892
  const refused = await existingRefusal(ex, prior);
17838
17893
  if (refused) throw new PeriscopeError(refused);
17839
17894
  const unsafe = d.baseProblem();
@@ -19018,7 +19073,7 @@ import { randomBytes as randomBytes2 } from "crypto";
19018
19073
  var DEFAULT_FLOOR_MS = 5e3;
19019
19074
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
19020
19075
  var ALLOWANCE_PAUSE_MS = 5 * 60 * 1e3;
19021
- var defaultSleep = (ms3) => new Promise((resolve6) => setTimeout(resolve6, ms3));
19076
+ var defaultSleep = (ms3) => new Promise((resolve7) => setTimeout(resolve7, ms3));
19022
19077
  async function runDaemonLoop(o) {
19023
19078
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
19024
19079
  const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
@@ -19075,15 +19130,15 @@ async function runDaemonLoop(o) {
19075
19130
  }
19076
19131
  }
19077
19132
  function latestMtime(path) {
19078
- let stat;
19133
+ let stat2;
19079
19134
  try {
19080
- stat = statSync12(path);
19135
+ stat2 = statSync12(path);
19081
19136
  } catch {
19082
19137
  return 0;
19083
19138
  }
19084
- if (stat.isFile()) return stat.mtimeMs;
19085
- if (!stat.isDirectory()) return 0;
19086
- let latest = stat.mtimeMs;
19139
+ if (stat2.isFile()) return stat2.mtimeMs;
19140
+ if (!stat2.isDirectory()) return 0;
19141
+ let latest = stat2.mtimeMs;
19087
19142
  for (const entry of readdirSync16(path, { withFileTypes: true })) {
19088
19143
  latest = Math.max(latest, latestMtime(join42(path, entry.name)));
19089
19144
  }
@@ -19276,8 +19331,8 @@ async function runOnePassUnguarded(o) {
19276
19331
  started.push(role);
19277
19332
  let ready = () => {
19278
19333
  };
19279
- const settled = new Promise((resolve6) => {
19280
- ready = resolve6;
19334
+ const settled = new Promise((resolve7) => {
19335
+ ready = resolve7;
19281
19336
  });
19282
19337
  const run2 = spawn9(role, decision, ctx, { ready }).then(() => {
19283
19338
  breaker.recordSuccess(role);
@@ -19729,14 +19784,18 @@ function parseLine(line) {
19729
19784
  return null;
19730
19785
  }
19731
19786
  }
19732
- async function execute(h, args, streaming) {
19733
- const finish = (r) => {
19787
+ async function execute(h, args, streaming, after) {
19788
+ const finishRaw = (r) => {
19734
19789
  if (r.code !== 0) return { status: failureStatus(r), body: { error: cliMessage(r) } };
19735
19790
  const done = lastJsonLine(r.stdout);
19736
19791
  return done ? { status: 200, body: done } : { status: 502, body: { error: "the command finished without a result" } };
19737
19792
  };
19793
+ const finish = async (r) => {
19794
+ const f = finishRaw(r);
19795
+ return f.status === 200 && after ? { status: 200, body: await after(f.body) } : f;
19796
+ };
19738
19797
  if (!streaming) {
19739
- const f = finish(await h.run(args, () => {
19798
+ const f = await finish(await h.run(args, () => {
19740
19799
  }));
19741
19800
  return reply(f.status, f.body);
19742
19801
  }
@@ -19748,7 +19807,7 @@ async function execute(h, args, streaming) {
19748
19807
  const ev = parseLine(line);
19749
19808
  if (ev) write(ev);
19750
19809
  });
19751
- const f = finish(r);
19810
+ const f = await finish(r);
19752
19811
  write(f.status === 200 ? f.body : { event: "error", status: f.status, message: f.body.error });
19753
19812
  }
19754
19813
  };
@@ -19812,7 +19871,10 @@ async function handlePeriscopes(h, q) {
19812
19871
  `--${b.outcome}`,
19813
19872
  ...b.allowEmpty === true ? ["--allow-empty"] : [],
19814
19873
  "--json"
19815
- ], q.wantsStream);
19874
+ ], q.wantsStream, h.mcp ? async (done) => {
19875
+ await h.mcp.onDown(picked, done);
19876
+ return done;
19877
+ } : void 0);
19816
19878
  }
19817
19879
  const repo = plainText(b.repo, "repo", true);
19818
19880
  const topic = plainText(b.topic, "topic", true);
@@ -19840,12 +19902,13 @@ async function handlePeriscopes(h, q) {
19840
19902
  h.previewHost,
19841
19903
  ...requestedBy ? ["--requested-by", requestedBy] : [],
19842
19904
  ...b.isolate === true ? ["--isolate"] : [],
19905
+ ...h.mcp ? ["--serve-mcp"] : [],
19843
19906
  "--no-open",
19844
19907
  "--json"
19845
- ], q.wantsStream);
19908
+ ], q.wantsStream, h.mcp ? async (done) => ({ ...done, ...await h.mcp.onUp(picked, done) }) : void 0);
19846
19909
  }
19847
19910
  function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
19848
- return new Promise((resolve6) => {
19911
+ return new Promise((resolve7) => {
19849
19912
  const child = spawn7(process.execPath, [process.argv[1] ?? "", ...args], { stdio: ["ignore", "pipe", "pipe"] });
19850
19913
  let stdout = "";
19851
19914
  let stderr = "";
@@ -19854,9 +19917,9 @@ function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
19854
19917
  stdout += d.toString("utf8");
19855
19918
  });
19856
19919
  child.stderr.on("data", (d) => {
19857
- const text = d.toString("utf8");
19858
- stderr += text;
19859
- pending += text;
19920
+ const text2 = d.toString("utf8");
19921
+ stderr += text2;
19922
+ pending += text2;
19860
19923
  let i;
19861
19924
  while ((i = pending.indexOf("\n")) >= 0) {
19862
19925
  const line = pending.slice(0, i).trim();
@@ -19867,12 +19930,12 @@ function runCrew(args, onLine, timeoutMs = 20 * 6e4) {
19867
19930
  const t = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
19868
19931
  child.on("error", (e) => {
19869
19932
  clearTimeout(t);
19870
- resolve6({ code: 127, stdout, stderr: `${stderr}${e.message}
19933
+ resolve7({ code: 127, stdout, stderr: `${stderr}${e.message}
19871
19934
  ` });
19872
19935
  });
19873
19936
  child.on("close", (code) => {
19874
19937
  clearTimeout(t);
19875
- resolve6({ code: code ?? 1, stdout, stderr });
19938
+ resolve7({ code: code ?? 1, stdout, stderr });
19876
19939
  });
19877
19940
  });
19878
19941
  }
@@ -19993,16 +20056,16 @@ function lastLines2(path, n) {
19993
20056
  const cursorAt = (off) => encodeCursor({ dev: st.dev, ino: st.ino, off });
19994
20057
  if (n <= 0 || st.size === 0) return { lines: [], cursor: cursorAt(st.size), reset: false };
19995
20058
  let start = st.size;
19996
- let text = "";
19997
- while (start > 0 && text.split("\n").length <= n + 1 && st.size - start < MAX_TAIL_BYTES) {
20059
+ let text2 = "";
20060
+ while (start > 0 && text2.split("\n").length <= n + 1 && st.size - start < MAX_TAIL_BYTES) {
19998
20061
  const step = Math.min(64 * 1024, start);
19999
20062
  start -= step;
20000
- text = readRange(path, start, step).text + text;
20063
+ text2 = readRange(path, start, step).text + text2;
20001
20064
  }
20002
- let lines = text.split("\n");
20065
+ let lines = text2.split("\n");
20003
20066
  if (start > 0) lines = lines.slice(1);
20004
20067
  let end = st.size;
20005
- if (!text.endsWith("\n")) {
20068
+ if (!text2.endsWith("\n")) {
20006
20069
  const frag = lines.pop() ?? "";
20007
20070
  end -= Buffer.byteLength(frag);
20008
20071
  } else lines.pop();
@@ -20044,9 +20107,9 @@ function readTail(live, opts) {
20044
20107
  off = 0;
20045
20108
  size = statSync13(path).size;
20046
20109
  }
20047
- const { text } = readRange(path, off, max);
20048
- const cut = text.lastIndexOf("\n");
20049
- const whole = cut < 0 ? "" : text.slice(0, cut + 1);
20110
+ const { text: text2 } = readRange(path, off, max);
20111
+ const cut = text2.lastIndexOf("\n");
20112
+ const whole = cut < 0 ? "" : text2.slice(0, cut + 1);
20050
20113
  const st = statSync13(path);
20051
20114
  return {
20052
20115
  lines: whole.split("\n").filter((l) => l.length),
@@ -20055,7 +20118,7 @@ function readTail(live, opts) {
20055
20118
  };
20056
20119
  }
20057
20120
  function waitForChange(dir, ms3, signal) {
20058
- return new Promise((resolve6) => {
20121
+ return new Promise((resolve7) => {
20059
20122
  let w;
20060
20123
  const done = () => {
20061
20124
  clearTimeout(t);
@@ -20065,7 +20128,7 @@ function waitForChange(dir, ms3, signal) {
20065
20128
  w?.close();
20066
20129
  } catch {
20067
20130
  }
20068
- resolve6();
20131
+ resolve7();
20069
20132
  };
20070
20133
  const t = setTimeout(done, ms3);
20071
20134
  const poll = setInterval(done, 500);
@@ -20212,13 +20275,13 @@ function send(res, r) {
20212
20275
  }
20213
20276
  async function socketIsStale(path) {
20214
20277
  if (!existsSync40(path)) return false;
20215
- return new Promise((resolve6) => {
20278
+ return new Promise((resolve7) => {
20216
20279
  const c = createConnection(path);
20217
20280
  c.once("connect", () => {
20218
20281
  c.destroy();
20219
- resolve6(false);
20282
+ resolve7(false);
20220
20283
  });
20221
- c.once("error", () => resolve6(true));
20284
+ c.once("error", () => resolve7(true));
20222
20285
  });
20223
20286
  }
20224
20287
  async function startControlServer(deps) {
@@ -20239,6 +20302,11 @@ async function startControlServer(deps) {
20239
20302
  res.on("close", () => abort.abort());
20240
20303
  void (async () => {
20241
20304
  try {
20305
+ const mcp = /^\/periscopes\/([^/]+)\/mcp\/?$/.exec(new URL(req.url ?? "/", "http://ship").pathname);
20306
+ if (mcp) {
20307
+ if (!deps.periscopeMcp) return send(res, err2(404, "not found"));
20308
+ return await deps.periscopeMcp(req, res, decodeURIComponent(mcp[1]));
20309
+ }
20242
20310
  const m = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");
20243
20311
  const verdict = await deps.auth.verify(m?.[1]?.trim() ?? "");
20244
20312
  if (verdict === "unauthorized") return send(res, err2(401, "invalid or revoked key"));
@@ -20252,10 +20320,10 @@ async function startControlServer(deps) {
20252
20320
  }
20253
20321
  })();
20254
20322
  });
20255
- return new Promise((resolve6) => {
20323
+ return new Promise((resolve7) => {
20256
20324
  server.once("error", (e) => {
20257
20325
  log(`control API not started: ${e.message}`);
20258
- resolve6(null);
20326
+ resolve7(null);
20259
20327
  });
20260
20328
  server.listen(path, () => {
20261
20329
  try {
@@ -20263,7 +20331,7 @@ async function startControlServer(deps) {
20263
20331
  } catch {
20264
20332
  }
20265
20333
  server.on("error", (e) => log(`control API error: ${e.message}`));
20266
- resolve6({
20334
+ resolve7({
20267
20335
  server,
20268
20336
  path,
20269
20337
  close: () => new Promise((done) => {
@@ -20294,23 +20362,456 @@ function schedulerHandler(control, run2 = runDetached) {
20294
20362
  };
20295
20363
  }
20296
20364
  function crewJson(args, timeoutMs = 6e4) {
20297
- return new Promise((resolve6) => {
20365
+ return new Promise((resolve7) => {
20298
20366
  execFile(process.execPath, [process.argv[1] ?? "", ...args], { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (_e, stdout) => {
20299
20367
  try {
20300
20368
  const v = JSON.parse(String(stdout));
20301
- resolve6(v && typeof v === "object" && !Array.isArray(v) ? v : null);
20369
+ resolve7(v && typeof v === "object" && !Array.isArray(v) ? v : null);
20302
20370
  } catch {
20303
- resolve6(null);
20371
+ resolve7(null);
20304
20372
  }
20305
20373
  });
20306
20374
  });
20307
20375
  }
20308
20376
 
20377
+ // src/periscope-mcp.ts
20378
+ import { lstat, mkdir, readdir, readFile, readlink, realpath, rm, stat, writeFile } from "fs/promises";
20379
+ import { basename as basename13, dirname as dirname22, isAbsolute as isAbsolute3, join as join45, relative as relative2, resolve as resolve5, sep } from "path";
20380
+ import { z } from "zod";
20381
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20382
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20383
+
20384
+ // src/periscope-endpoints.ts
20385
+ import { randomBytes as randomBytes3 } from "crypto";
20386
+ import { chmodSync as chmodSync5, existsSync as existsSync41, mkdirSync as mkdirSync26, readdirSync as readdirSync18, readFileSync as readFileSync36, rmSync as rmSync19, writeFileSync as writeFileSync28 } from "fs";
20387
+ import { join as join44 } from "path";
20388
+ var secretDir = (stateDir) => join44(stateDir, "periscope-mcp");
20389
+ var secretFile = (stateDir, id) => join44(secretDir(stateDir), `${id}.json`);
20390
+ var endpointDir = (stateDir) => join44(stateDir, "periscope-endpoints");
20391
+ var endpointFile = (stateDir, id) => join44(endpointDir(stateDir), `${id}.json`);
20392
+ function mintPeriscopeSecret(stateDir, ticketRecordId) {
20393
+ mkdirSync26(secretDir(stateDir), { recursive: true, mode: 448 });
20394
+ const secret = randomBytes3(32).toString("base64url");
20395
+ const file = secretFile(stateDir, ticketRecordId);
20396
+ writeFileSync28(file, `${JSON.stringify({ secret })}
20397
+ `, { mode: 384 });
20398
+ chmodSync5(file, 384);
20399
+ return secret;
20400
+ }
20401
+ function readPeriscopeSecret(stateDir, ticketRecordId) {
20402
+ try {
20403
+ const v = JSON.parse(readFileSync36(secretFile(stateDir, ticketRecordId), "utf8"));
20404
+ return typeof v.secret === "string" && v.secret ? v.secret : void 0;
20405
+ } catch {
20406
+ return void 0;
20407
+ }
20408
+ }
20409
+ function removePeriscopeSecret(stateDir, ticketRecordId) {
20410
+ rmSync19(secretFile(stateDir, ticketRecordId), { force: true });
20411
+ }
20412
+ function readPeriscopeEndpoint(stateDir, ticketRecordId) {
20413
+ try {
20414
+ return JSON.parse(readFileSync36(endpointFile(stateDir, ticketRecordId), "utf8"));
20415
+ } catch {
20416
+ return void 0;
20417
+ }
20418
+ }
20419
+ function writePeriscopeEndpoint(stateDir, rec) {
20420
+ mkdirSync26(endpointDir(stateDir), { recursive: true });
20421
+ writeFileSync28(endpointFile(stateDir, rec.ticketRecordId), `${JSON.stringify(rec, null, 2)}
20422
+ `);
20423
+ }
20424
+ function removePeriscopeEndpointRecord(stateDir, ticketRecordId) {
20425
+ rmSync19(endpointFile(stateDir, ticketRecordId), { force: true });
20426
+ }
20427
+ function listPeriscopeEndpoints(stateDir) {
20428
+ const dir = endpointDir(stateDir);
20429
+ if (!existsSync41(dir)) return [];
20430
+ const out = [];
20431
+ for (const name of readdirSync18(dir)) {
20432
+ if (!name.endsWith(".json")) continue;
20433
+ const rec = readPeriscopeEndpoint(stateDir, name.slice(0, -".json".length));
20434
+ if (rec && typeof rec.endpointId === "string") out.push(rec);
20435
+ }
20436
+ return out.sort((a, b) => a.registeredAt.localeCompare(b.registeredAt));
20437
+ }
20438
+ function periscopeEndpointUrl(relayUrl, ticket) {
20439
+ return `${relayUrl.replace(/\/+$/, "")}/control/periscopes/${encodeURIComponent(ticket)}/mcp`;
20440
+ }
20441
+ async function registerPeriscopeEndpoint(d, p) {
20442
+ const secret = readPeriscopeSecret(d.stateDir, p.ticketRecordId);
20443
+ if (!secret) return { ok: false, message: `${p.ticket} has no MCP secret (it was not opened with --mcp, or has ended)` };
20444
+ const api = d.apiFor(p.workspaceId);
20445
+ if (!api) return { ok: false, message: `no API key known for workspace ${p.workspaceId}` };
20446
+ const url = periscopeEndpointUrl(p.relayUrl, p.ticket);
20447
+ const stored = readPeriscopeEndpoint(d.stateDir, p.ticketRecordId);
20448
+ const out = await registerEndpoint(api, p.workspaceId, stored?.endpointId, {
20449
+ label: `Periscope: ${d.shipName} ${p.ticket}`,
20450
+ url,
20451
+ mcpSecret: secret
20452
+ });
20453
+ if (out.kind !== "ok") {
20454
+ return { ok: false, message: out.kind === "forbidden" ? "registering the endpoint was refused (403): the route key lacks REGISTER_MCP_ENDPOINTS" : out.message };
20455
+ }
20456
+ writePeriscopeEndpoint(d.stateDir, {
20457
+ ticket: p.ticket,
20458
+ ticketRecordId: p.ticketRecordId,
20459
+ route: p.route,
20460
+ workspaceId: p.workspaceId,
20461
+ endpointId: out.endpointId,
20462
+ url,
20463
+ registeredAt: (d.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
20464
+ });
20465
+ return { ok: true, endpointId: out.endpointId, url };
20466
+ }
20467
+ async function removeEndedPeriscopeEndpoints(d) {
20468
+ let removed = 0;
20469
+ for (const rec of listPeriscopeEndpoints(d.stateDir)) {
20470
+ if (readPeriscopeSecret(d.stateDir, rec.ticketRecordId)) continue;
20471
+ const api = d.apiFor(rec.workspaceId);
20472
+ if (!api) {
20473
+ d.warn(`no API key known for workspace ${rec.workspaceId} \u2014 cannot remove the MCP endpoint of ${rec.ticket}`);
20474
+ continue;
20475
+ }
20476
+ const out = await removeEndpoint(api, rec.workspaceId, rec.endpointId);
20477
+ if (out.kind === "ok") {
20478
+ removePeriscopeEndpointRecord(d.stateDir, rec.ticketRecordId);
20479
+ removed++;
20480
+ } else d.warn(`could not remove the MCP endpoint of ${rec.ticket}: ${out.kind === "forbidden" ? "403" : out.message}`);
20481
+ }
20482
+ return removed;
20483
+ }
20484
+
20485
+ // src/periscope-mcp.ts
20486
+ var PERISCOPE_HOOKS = ["test", "build", "ports", "handoff", "preview", "isolate"];
20487
+ var MAX_READ_BYTES = 1024 * 1024;
20488
+ var MAX_WRITE_BYTES = 4 * 1024 * 1024;
20489
+ var MAX_BODY_BYTES2 = 8 * 1024 * 1024;
20490
+ var MAX_HOOK_OUTPUT = 2e4;
20491
+ var MAX_DIFF = 2e5;
20492
+ var MAX_SEARCH_RESULTS = 500;
20493
+ var PathRefusal = class extends Error {
20494
+ };
20495
+ var isInside = (root, p) => {
20496
+ const rel = relative2(root, p);
20497
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute3(rel);
20498
+ };
20499
+ async function realAncestor(p) {
20500
+ let cur = p;
20501
+ const tail3 = [];
20502
+ let hops = 0;
20503
+ for (; ; ) {
20504
+ try {
20505
+ return { real: await realpath(cur), rest: tail3.slice().reverse().join(sep) };
20506
+ } catch (e) {
20507
+ if (e.code !== "ENOENT") throw new PathRefusal(`cannot resolve ${p}`);
20508
+ }
20509
+ let isLink = false;
20510
+ try {
20511
+ isLink = (await lstat(cur)).isSymbolicLink();
20512
+ } catch {
20513
+ }
20514
+ if (isLink) {
20515
+ if (++hops > 40) throw new PathRefusal(`too many symbolic links resolving ${p}`);
20516
+ cur = resolve5(dirname22(cur), await readlink(cur));
20517
+ continue;
20518
+ }
20519
+ const parent = dirname22(cur);
20520
+ if (parent === cur) throw new PathRefusal(`cannot resolve ${p}`);
20521
+ tail3.push(basename13(cur));
20522
+ cur = parent;
20523
+ }
20524
+ }
20525
+ async function resolveInWorktree(root, requested) {
20526
+ if (!requested || requested.includes("\0")) throw new PathRefusal("path is required");
20527
+ const realRoot = await realpath(root);
20528
+ let abs;
20529
+ if (isAbsolute3(requested)) {
20530
+ abs = resolve5(requested);
20531
+ if (isInside(root, abs)) abs = join45(realRoot, relative2(root, abs));
20532
+ } else {
20533
+ abs = resolve5(realRoot, requested);
20534
+ }
20535
+ if (!isInside(realRoot, abs)) throw new PathRefusal(`${requested} is outside this periscope's worktree`);
20536
+ const { real, rest } = await realAncestor(abs);
20537
+ const final = rest ? join45(real, rest) : real;
20538
+ if (!isInside(realRoot, final)) throw new PathRefusal(`${requested} is outside this periscope's worktree`);
20539
+ if (relative2(realRoot, final).split(sep).includes(".git")) throw new PathRefusal(".git is not reachable from a periscope");
20540
+ return final;
20541
+ }
20542
+ function globToRegExp(glob) {
20543
+ let re = "";
20544
+ for (let i = 0; i < glob.length; i++) {
20545
+ const c = glob[i];
20546
+ if (c === "*") {
20547
+ if (glob[i + 1] === "*") {
20548
+ i++;
20549
+ if (glob[i + 1] === "/") {
20550
+ i++;
20551
+ re += "(?:.*/)?";
20552
+ } else re += ".*";
20553
+ } else re += "[^/]*";
20554
+ } else if (c === "?") re += "[^/]";
20555
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
20556
+ }
20557
+ return new RegExp(`^${re}$`);
20558
+ }
20559
+ var tailText = (s, max = MAX_HOOK_OUTPUT) => s.length > max ? `\u2026(${s.length - max} earlier characters omitted)
20560
+ ${s.slice(-max)}` : s;
20561
+ var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError: true } : {} });
20562
+ async function hookEnv(d, sc, name) {
20563
+ const env = { CREW_TICKET: sc.ticket, CREW_BRANCH: sc.branch };
20564
+ if (name !== "ports") {
20565
+ const ports = await d.runHook(sc, "ports", env);
20566
+ if (ports) Object.assign(env, parseEnvLines(ports.output));
20567
+ if (sc.isolated && name !== "isolate") {
20568
+ const iso = await d.runHook(sc, "isolate", env);
20569
+ if (iso && iso.code === 0) Object.assign(env, parseEnvLines(iso.output));
20570
+ }
20571
+ }
20572
+ if (name === "preview") env.CREW_PREVIEW_HOST = sc.host || "localhost";
20573
+ return env;
20574
+ }
20575
+ function createPeriscopeMcpServer(d, sc) {
20576
+ const server = new McpServer({ name: `crew-periscope-${sc.ticket}`, version: "0.1.0" });
20577
+ const root = sc.worktree;
20578
+ const inTree = (p) => resolveInWorktree(root, p);
20579
+ const rel = async (abs) => relative2(await realpath(root), abs) || ".";
20580
+ server.registerTool("read_file", {
20581
+ title: "Read File",
20582
+ description: "Read a text file in this periscope's worktree. `head`/`tail` return only the first/last N lines.",
20583
+ inputSchema: { path: z.string(), head: z.number().int().positive().optional(), tail: z.number().int().positive().optional() },
20584
+ annotations: { readOnlyHint: true, openWorldHint: false }
20585
+ }, async ({ path, head, tail: tail3 }) => {
20586
+ if (head && tail3) return text("Cannot specify both head and tail", true);
20587
+ const abs = await inTree(path);
20588
+ const st = await stat(abs);
20589
+ if (!st.isFile()) return text(`${path} is not a file`, true);
20590
+ if (st.size > MAX_READ_BYTES) return text(`${path} is ${st.size} bytes; read it in parts with head or tail (limit ${MAX_READ_BYTES})`, true);
20591
+ const content = await readFile(abs, "utf8");
20592
+ const lines = content.split("\n");
20593
+ return text(head ? lines.slice(0, head).join("\n") : tail3 ? lines.slice(-tail3).join("\n") : content);
20594
+ });
20595
+ server.registerTool("list_directory", {
20596
+ title: "List Directory",
20597
+ description: 'List the entries directly inside a directory of the worktree ("." is its root), each marked [FILE] or [DIR].',
20598
+ inputSchema: { path: z.string().default(".") },
20599
+ annotations: { readOnlyHint: true, openWorldHint: false }
20600
+ }, async ({ path }) => {
20601
+ const abs = await inTree(path);
20602
+ const entries = (await readdir(abs, { withFileTypes: true })).filter((e) => e.name !== ".git");
20603
+ const lines = await Promise.all(entries.map(async (e) => {
20604
+ if (e.isDirectory()) return `[DIR] ${e.name}`;
20605
+ const size = (await lstat(join45(abs, e.name))).size;
20606
+ return `[FILE] ${e.name} (${size} bytes)`;
20607
+ }));
20608
+ return text(lines.join("\n") || "(empty)");
20609
+ });
20610
+ server.registerTool("search_files", {
20611
+ title: "Search Files",
20612
+ description: "Find files in the worktree whose path (relative to the worktree) matches the glob `pattern` (`*` stays within a directory, `**` crosses them). Lists files git tracks or would not ignore, so `node_modules` and build output do not appear.",
20613
+ inputSchema: { pattern: z.string() },
20614
+ annotations: { readOnlyHint: true, openWorldHint: false }
20615
+ }, async ({ pattern }) => {
20616
+ const re = globToRegExp(pattern);
20617
+ const listing = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
20618
+ const hits = listing.split("\0").filter((f) => f && re.test(f)).slice(0, MAX_SEARCH_RESULTS);
20619
+ return text(hits.length ? hits.join("\n") : "No matches found");
20620
+ });
20621
+ server.registerTool("write_file", {
20622
+ title: "Write File",
20623
+ description: "Create a file in the worktree, or replace its whole content. Parent directories are created.",
20624
+ inputSchema: { path: z.string(), content: z.string() },
20625
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
20626
+ }, async ({ path, content }) => {
20627
+ if (Buffer.byteLength(content) > MAX_WRITE_BYTES) return text(`content exceeds ${MAX_WRITE_BYTES} bytes`, true);
20628
+ const abs = await inTree(path);
20629
+ if (abs === await realpath(root)) return text("path must name a file", true);
20630
+ await mkdir(dirname22(abs), { recursive: true });
20631
+ await inTree(path);
20632
+ await writeFile(abs, content);
20633
+ return text(`Wrote ${Buffer.byteLength(content)} bytes to ${await rel(abs)}`);
20634
+ });
20635
+ server.registerTool("edit_file", {
20636
+ title: "Edit File",
20637
+ description: "Replace an exact string in a file. `oldString` must occur exactly once unless `replaceAll` is true; whitespace and line endings must match.",
20638
+ inputSchema: { path: z.string(), oldString: z.string().min(1), newString: z.string(), replaceAll: z.boolean().optional() },
20639
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
20640
+ }, async ({ path, oldString, newString, replaceAll }) => {
20641
+ const abs = await inTree(path);
20642
+ const st = await stat(abs);
20643
+ if (!st.isFile()) return text(`${path} is not a file`, true);
20644
+ if (st.size > MAX_WRITE_BYTES) return text(`${path} is too large to edit`, true);
20645
+ const before = await readFile(abs, "utf8");
20646
+ const count = before.split(oldString).length - 1;
20647
+ if (count === 0) return text(`oldString was not found in ${path}`, true);
20648
+ if (count > 1 && !replaceAll) return text(`oldString occurs ${count} times in ${path}; make it unique or set replaceAll`, true);
20649
+ const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, () => newString);
20650
+ await writeFile(abs, after);
20651
+ return text(`Edited ${await rel(abs)} (${replaceAll ? count : 1} replacement${(replaceAll ? count : 1) === 1 ? "" : "s"})`);
20652
+ });
20653
+ server.registerTool("create_directory", {
20654
+ title: "Create Directory",
20655
+ description: "Create a directory (and any missing parents) in the worktree.",
20656
+ inputSchema: { path: z.string() },
20657
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
20658
+ }, async ({ path }) => {
20659
+ const abs = await inTree(path);
20660
+ await mkdir(abs, { recursive: true });
20661
+ await inTree(path);
20662
+ return text(`Created ${await rel(abs)}`);
20663
+ });
20664
+ server.registerTool("delete_file", {
20665
+ title: "Delete File",
20666
+ description: "Delete one file (or symlink) from the worktree. Directories are not deleted.",
20667
+ inputSchema: { path: z.string() },
20668
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
20669
+ }, async ({ path }) => {
20670
+ const parent = await inTree(dirname22(path) === "" ? "." : dirname22(path));
20671
+ const abs = join45(parent, basename13(path));
20672
+ if (basename13(path) === ".git" || abs === parent) return text("path must name a file", true);
20673
+ const st = await lstat(abs).catch(() => void 0);
20674
+ if (!st) return text(`${path} does not exist`, true);
20675
+ if (st.isDirectory()) return text(`${path} is a directory; only files are deleted`, true);
20676
+ await rm(abs, { force: false });
20677
+ return text(`Deleted ${await rel(abs)}`);
20678
+ });
20679
+ server.registerTool("run_hook", {
20680
+ title: "Run Hook",
20681
+ description: `Run one of the repository's declared hooks in this worktree, with this periscope's environment, and return its output. Allowed names: ${PERISCOPE_HOOKS.join(", ")} (\`isolate\` only for a periscope opened isolated). A hook the repository does not declare is refused.`,
20682
+ inputSchema: { name: z.enum(PERISCOPE_HOOKS) },
20683
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
20684
+ }, async ({ name }) => {
20685
+ if (name === "isolate" && !sc.isolated) return text("isolate is allowed only for a periscope opened with --isolate", true);
20686
+ const env = await hookEnv(d, sc, name);
20687
+ const res = await d.runHook(sc, name, env);
20688
+ if (!res) return text(`${sc.repo} declares no \`${name}\` hook`, true);
20689
+ return text(`exit ${res.code}
20690
+ ${tailText(res.output)}`, res.code !== 0);
20691
+ });
20692
+ const branchGuard = () => {
20693
+ const head = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
20694
+ return head === sc.branch ? void 0 : `the worktree is on ${head}, not the ticket branch ${sc.branch}`;
20695
+ };
20696
+ server.registerTool("git_status", {
20697
+ title: "Git Status",
20698
+ description: "Branch and changed files (`git status --short --branch`) of this worktree.",
20699
+ inputSchema: {},
20700
+ annotations: { readOnlyHint: true, openWorldHint: false }
20701
+ }, async () => text(git(root, ["status", "--short", "--branch", "--untracked-files=all"])));
20702
+ server.registerTool("git_diff", {
20703
+ title: "Git Diff",
20704
+ description: "The uncommitted changes against HEAD (staged and unstaged), optionally limited to one path. New untracked files appear in git_status, not here.",
20705
+ inputSchema: { path: z.string().optional() },
20706
+ annotations: { readOnlyHint: true, openWorldHint: false }
20707
+ }, async ({ path }) => {
20708
+ const args = ["diff", "HEAD", "--no-ext-diff", "--no-color"];
20709
+ if (path) args.push("--", relative2(await realpath(root), await inTree(path)) || ".");
20710
+ const out = git(root, args);
20711
+ return text(out ? tailText(out, MAX_DIFF) : "(no changes)");
20712
+ });
20713
+ server.registerTool("git_commit", {
20714
+ title: "Git Commit",
20715
+ description: "Stage every change in the worktree and commit it on the ticket branch. It never pushes; ending the periscope with `--keep` does.",
20716
+ inputSchema: { message: z.string().min(1).max(2e3) },
20717
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
20718
+ }, async ({ message }) => {
20719
+ const wrong = branchGuard();
20720
+ if (wrong) return text(wrong, true);
20721
+ git(root, ["add", "-A"]);
20722
+ try {
20723
+ git(root, ["commit", "--no-verify", "-m", message]);
20724
+ } catch (e) {
20725
+ if (e instanceof GitError && /nothing to commit|no changes added/.test(e.message)) return text("Nothing to commit", true);
20726
+ throw e;
20727
+ }
20728
+ return text(`Committed ${git(root, ["rev-parse", "--short", "HEAD"])} on ${sc.branch}`);
20729
+ });
20730
+ return server;
20731
+ }
20732
+ var NOT_FOUND = { error: "not found" };
20733
+ var json = (res, status2, body) => {
20734
+ const b = JSON.stringify(body);
20735
+ res.writeHead(status2, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(b), "Cache-Control": "no-store" });
20736
+ res.end(b);
20737
+ };
20738
+ async function readBody(req) {
20739
+ const chunks = [];
20740
+ let n = 0;
20741
+ for await (const c of req) {
20742
+ n += c.length;
20743
+ if (n > MAX_BODY_BYTES2) return null;
20744
+ chunks.push(c);
20745
+ }
20746
+ return Buffer.concat(chunks).toString("utf8");
20747
+ }
20748
+ async function handlePeriscopeMcp(d, req, res, segment) {
20749
+ const sc = d.find(segment);
20750
+ const secret = sc ? readPeriscopeSecret(d.stateDir, sc.ticketRecordId) : void 0;
20751
+ const presented = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]?.trim() ?? "";
20752
+ if (!sc || !secret || !presented || !secretsMatch(presented, secret)) return json(res, 404, NOT_FOUND);
20753
+ if (req.method !== "POST") {
20754
+ res.setHeader("Allow", "POST");
20755
+ return json(res, 405, { jsonrpc: "2.0", error: { code: -32e3, message: "Method not allowed." }, id: null });
20756
+ }
20757
+ const raw = await readBody(req);
20758
+ if (raw === null) return json(res, 413, { jsonrpc: "2.0", error: { code: -32600, message: "request body too large" }, id: null });
20759
+ let parsed;
20760
+ try {
20761
+ parsed = raw ? JSON.parse(raw) : void 0;
20762
+ } catch {
20763
+ return json(res, 400, { jsonrpc: "2.0", error: { code: -32700, message: "parse error" }, id: null });
20764
+ }
20765
+ const isCall = (m) => !!m && typeof m === "object" && m.method === "tools/call";
20766
+ if (Array.isArray(parsed) ? parsed.some(isCall) : isCall(parsed)) await d.touch(sc).catch((e) => d.log?.(`periscope ${sc.ticket} activity not recorded: ${e.message}`));
20767
+ try {
20768
+ const server = createPeriscopeMcpServer(d, sc);
20769
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
20770
+ await server.connect(transport);
20771
+ res.on("close", () => {
20772
+ void transport.close();
20773
+ void server.close();
20774
+ });
20775
+ await transport.handleRequest(req, res, parsed);
20776
+ } catch (e) {
20777
+ if (!res.headersSent) json(res, 500, { jsonrpc: "2.0", error: { code: -32603, message: e.message || "internal error" }, id: null });
20778
+ }
20779
+ }
20780
+ function findPeriscope(sidecars, segment) {
20781
+ const m = /^(?:([A-Za-z]+)-)?(\d+)$/.exec(segment);
20782
+ if (!m) return void 0;
20783
+ const num = String(Number(m[2]));
20784
+ return sidecars.find((sc) => {
20785
+ const t = /^(?:([A-Za-z]+)-)?0*(\d+)$/.exec(sc.ticket);
20786
+ if (!t || t[2] !== num) return false;
20787
+ return !m[1] || !t[1] || m[1].toLowerCase() === t[1].toLowerCase();
20788
+ });
20789
+ }
20790
+
20309
20791
  // src/cli.ts
20310
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname22(process.execPath) : resolve5(dirname22(fileURLToPath(import.meta.url)), "..");
20792
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname23(process.execPath) : resolve6(dirname23(fileURLToPath(import.meta.url)), "..");
20311
20793
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
20312
20794
  var CYCLE_SECONDS2 = 120;
20313
20795
  var STALE_CYCLES = 3;
20796
+ async function duplicateLivePeriscopeProblems(route2, ship) {
20797
+ try {
20798
+ const tr = new Tracker(route2, ship);
20799
+ const rows = await tr.livePeriscopesAllShips();
20800
+ const refOf = (v) => typeof v === "string" ? v : v?.id;
20801
+ const counts = /* @__PURE__ */ new Map();
20802
+ for (const r of rows) {
20803
+ const t = refOf(r.issue_id);
20804
+ if (t) counts.set(t, (counts.get(t) ?? 0) + 1);
20805
+ }
20806
+ const dupIds = [...counts].filter(([, n]) => n > 1).map(([t]) => t);
20807
+ if (dupIds.length === 0) return [];
20808
+ const tags = new Map((await tr.ticketsByIds(dupIds)).map((t) => [t.id, t.issue_tag ?? t.issue_id]));
20809
+ const ships = new Map((await tr.shipRows()).map((x) => [x.id, x.name ?? x.id]));
20810
+ return duplicateLivePeriscopes(rows, (id) => tags.get(id) ?? id, (id) => id ? ships.get(id) ?? id : "an unknown ship", route2.route);
20811
+ } catch {
20812
+ return [];
20813
+ }
20814
+ }
20314
20815
  function renderShipLine(s, issueLabelById) {
20315
20816
  const name = s.name ?? "(unnamed)";
20316
20817
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
@@ -20428,7 +20929,7 @@ No project here has what a route needs. Install the "${template.name}" library t
20428
20929
  return installed.project.slug;
20429
20930
  }
20430
20931
  function usage(problem, requested = false) {
20431
- const text = `crew \u2014 a standing team of headless agents
20932
+ const text2 = `crew \u2014 a standing team of headless agents
20432
20933
 
20433
20934
  crew poll [route] decide a cycle and report it; writes nothing
20434
20935
  crew run [route] [--role R] run the winning role's session
@@ -20454,7 +20955,7 @@ function usage(problem, requested = false) {
20454
20955
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
20455
20956
  crew drop [route] NNN remove a merged ticket's worktree and branch
20456
20957
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
20457
- crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]
20958
+ crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--serve-mcp] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]
20458
20959
  crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
20459
20960
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
20460
20961
  crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]
@@ -20507,12 +21008,12 @@ Options:
20507
21008
  In watch: space pauses the stream, q quits.
20508
21009
  `;
20509
21010
  if (requested) {
20510
- process.stdout.write(text);
21011
+ process.stdout.write(text2);
20511
21012
  process.exit(0);
20512
21013
  }
20513
21014
  process.stderr.write(problem ? `crew: ${problem}
20514
21015
 
20515
- ${text}` : text);
21016
+ ${text2}` : text2);
20516
21017
  process.exit(2);
20517
21018
  }
20518
21019
  var argv = process.argv.slice(2);
@@ -20685,6 +21186,44 @@ if (command === "passenger-sync-daemon") {
20685
21186
  return void 0;
20686
21187
  }
20687
21188
  };
21189
+ }, periscopeEndpointDeps = function() {
21190
+ return {
21191
+ stateDir: cfg.ship.stateDir,
21192
+ shipName: cfg.ship.name,
21193
+ apiFor: endpointApiFor(cfg.routes.filter((r) => r.enabled)),
21194
+ warn: (m) => emit.warn(m, { step: "passengers" })
21195
+ };
21196
+ }, periscopeMcpDeps = function() {
21197
+ const stateDir = cfg.ship.stateDir;
21198
+ return {
21199
+ stateDir,
21200
+ find: (segment) => findPeriscope(readPeriscopeSidecars(stateDir), segment),
21201
+ runHook: async (sc, name, env) => {
21202
+ const rt = cfg.routes.find((r) => r.route === sc.route && r.enabled);
21203
+ const repo = rt && (await resolvedRepos(rt)).find((r) => r.name === sc.repo);
21204
+ const script = repo?.config.hooks[name];
21205
+ if (!repo || !script) return null;
21206
+ return runScript(script, {
21207
+ cwd: sc.worktree,
21208
+ env,
21209
+ shell: resolveShell(repo.config.shell ?? cfg.ship.shell),
21210
+ timeoutMs: name === "preview" ? 18e4 : 15 * 6e4
21211
+ });
21212
+ },
21213
+ touch: async (sc) => {
21214
+ const now = Date.now();
21215
+ if (now - (periscopeTouched.get(sc.ticketRecordId) ?? 0) < PERISCOPE_TOUCH_EVERY_MS) return;
21216
+ periscopeTouched.set(sc.ticketRecordId, now);
21217
+ const rt = cfg.routes.find((r) => r.route === sc.route && r.enabled);
21218
+ if (!rt) return;
21219
+ const { idleMinutes } = periscopeCaps(cfg.ship);
21220
+ const at = new Date(now).toISOString();
21221
+ const expiresAt = new Date(now + idleMinutes * 6e4).toISOString();
21222
+ await new Tracker(rt, cfg.ship).updatePeriscope(sc.periscopeRecordId, { last_activity_at: at, expires_at: expiresAt });
21223
+ writePeriscopeSidecar(stateDir, { ...sc, lastActivityAt: at, expiresAt });
21224
+ },
21225
+ log: (m) => emit.warn(m, { step: "passengers" })
21226
+ };
20688
21227
  }, endpointReconcileDeps = function(routes, relayHost) {
20689
21228
  return {
20690
21229
  stateDir: cfg.ship.stateDir,
@@ -20711,7 +21250,7 @@ if (command === "passenger-sync-daemon") {
20711
21250
  const unsafe = baseBranchUnsafe(out);
20712
21251
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
20713
21252
  };
20714
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
21253
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, periscopeEndpointDeps2 = periscopeEndpointDeps, periscopeMcpDeps2 = periscopeMcpDeps, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20715
21254
  let cfg;
20716
21255
  let firstRun;
20717
21256
  let firstRunFromFlags = false;
@@ -20767,7 +21306,7 @@ if (command === "passenger-sync-daemon") {
20767
21306
  } else {
20768
21307
  const toleratesNoRoutes = command === "credential" || command === "forge" || command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
20769
21308
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
20770
- if (configless && !existsSync41(findConfigFile(CREW_HOME))) {
21309
+ if (configless && !existsSync42(findConfigFile(CREW_HOME))) {
20771
21310
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
20772
21311
  } else {
20773
21312
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -20782,7 +21321,7 @@ if (command === "passenger-sync-daemon") {
20782
21321
  throw e;
20783
21322
  }
20784
21323
  if (command === "credential") {
20785
- const r = runCredential(positional[1], readFileSync36(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
21324
+ const r = runCredential(positional[1], readFileSync37(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
20786
21325
  process.stdout.write(r.out);
20787
21326
  process.stderr.write(r.err);
20788
21327
  process.exit(r.code);
@@ -20794,9 +21333,9 @@ if (command === "passenger-sync-daemon") {
20794
21333
  forges: cfg.ship.forges,
20795
21334
  stateDir: cfg.ship.stateDir,
20796
21335
  configFile: cfg.configFile,
20797
- readFile: (p) => readFileSync36(p, "utf8"),
20798
- writeFile: (p, t) => writeFileSync28(p, t),
20799
- stdin: () => readFileSync36(0, "utf8"),
21336
+ readFile: (p) => readFileSync37(p, "utf8"),
21337
+ writeFile: (p, t) => writeFileSync29(p, t),
21338
+ stdin: () => readFileSync37(0, "utf8"),
20800
21339
  env: process.env
20801
21340
  }
20802
21341
  );
@@ -20928,6 +21467,7 @@ if (command === "passenger-sync-daemon") {
20928
21467
  for (const plan of containerPlans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20929
21468
  for (const plan of plans) pending.push(reconcileRelayUrl(plan.workspaceId, apiRoutes));
20930
21469
  await Promise.all(pending);
21470
+ await reconcilePeriscopeEndpoints();
20931
21471
  } catch (e) {
20932
21472
  emit.warn(`ship tunnel sync failed: ${e.message}`, { step: "passengers" });
20933
21473
  }
@@ -20947,6 +21487,22 @@ if (command === "passenger-sync-daemon") {
20947
21487
  emit.warn(`could not write relay_url: ${e.message}`, { step: "passengers" });
20948
21488
  }
20949
21489
  }
21490
+ const periscopeTouched = /* @__PURE__ */ new Map();
21491
+ const PERISCOPE_TOUCH_EVERY_MS = 3e4;
21492
+ async function reconcilePeriscopeEndpoints() {
21493
+ const d = periscopeEndpointDeps();
21494
+ await removeEndedPeriscopeEndpoints(d);
21495
+ const have = new Set(listPeriscopeEndpoints(cfg.ship.stateDir).map((e) => e.ticketRecordId));
21496
+ for (const sc of readPeriscopeSidecars(cfg.ship.stateDir)) {
21497
+ if (have.has(sc.ticketRecordId) || !sc.route || !readPeriscopeSecret(cfg.ship.stateDir, sc.ticketRecordId)) continue;
21498
+ const rt = cfg.routes.find((r) => r.route === sc.route && r.enabled);
21499
+ const ws = rt?.resolved?.workspaceId;
21500
+ const relayUrl = rt && routeRelayUrl(rt);
21501
+ if (!rt || !ws || !relayUrl) continue;
21502
+ const out = await registerPeriscopeEndpoint(d, { ticket: sc.ticket, ticketRecordId: sc.ticketRecordId, route: sc.route, workspaceId: ws, relayUrl });
21503
+ if (!out.ok) d.warn(`could not register the MCP endpoint of ${sc.ticket}: ${out.message}`);
21504
+ }
21505
+ }
20950
21506
  async function releaseFleet(opts = {}) {
20951
21507
  if (releaseRefused()) return;
20952
21508
  precheckTickets.clear();
@@ -21062,8 +21618,8 @@ if (command === "passenger-sync-daemon") {
21062
21618
  }
21063
21619
  if (host === "linux" && hasSystemd) {
21064
21620
  const label = labelFor(CREW_HOME, "run");
21065
- const staleTimer = join44(homedir10(), ".config", "systemd", "user", `${label}.timer`);
21066
- if (existsSync41(staleTimer)) {
21621
+ const staleTimer = join46(homedir10(), ".config", "systemd", "user", `${label}.timer`);
21622
+ if (existsSync42(staleTimer)) {
21067
21623
  process.stdout.write(
21068
21624
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
21069
21625
  `
@@ -21090,7 +21646,7 @@ if (command === "passenger-sync-daemon") {
21090
21646
  const tracker = new Tracker(c, cfg.ship);
21091
21647
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
21092
21648
  for (const r of await resolvedRepos(c)) {
21093
- if (!existsSync41(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
21649
+ if (!existsSync42(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
21094
21650
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
21095
21651
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
21096
21652
  }
@@ -21108,7 +21664,7 @@ if (command === "passenger-sync-daemon") {
21108
21664
  }
21109
21665
  async function doctorFix(o) {
21110
21666
  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))));
21111
- const unitExists = (p) => existsSync41(p);
21667
+ const unitExists = (p) => existsSync42(p);
21112
21668
  const crontab = o.host === "linux" ? spawnSync8("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
21113
21669
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run", installContext()), unitExists, crontab) : true;
21114
21670
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -21125,7 +21681,7 @@ if (command === "passenger-sync-daemon") {
21125
21681
  for (const a of plan.actions) {
21126
21682
  if (a.kind === "enable") {
21127
21683
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
21128
- writeFileSync28(cfg.configFile, planEnable(readFileSync36(cfg.configFile, "utf8"), a.route));
21684
+ writeFileSync29(cfg.configFile, planEnable(readFileSync37(cfg.configFile, "utf8"), a.route));
21129
21685
  enabledSound.add(a.route);
21130
21686
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
21131
21687
  } else {
@@ -22246,7 +22802,7 @@ ${cu.tail.trimEnd()}
22246
22802
  process.exit(1);
22247
22803
  }
22248
22804
  requireArmed("run the daemon loop");
22249
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join44(CREW_HOME, "src"), join44(CREW_HOME, "dist"), join44(CREW_HOME, "bin")];
22805
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join46(CREW_HOME, "src"), join46(CREW_HOME, "dist"), join46(CREW_HOME, "bin")];
22250
22806
  const configPaths = daemonConfigPaths(cfg);
22251
22807
  watchPaths.push(...configPaths);
22252
22808
  noteDaemonStarted(cfg.ship.stateDir);
@@ -22274,8 +22830,22 @@ ${cu.tail.trimEnd()}
22274
22830
  previewHost: cfg.ship.previewHost,
22275
22831
  maxPeriscopes: cfg.ship.maxPeriscopes,
22276
22832
  run: runCrew,
22277
- verifyIn: (token, ws) => controlAuth.verifyIn(token, ws)
22833
+ verifyIn: (token, ws) => controlAuth.verifyIn(token, ws),
22834
+ mcp: {
22835
+ onUp: async (r, done) => {
22836
+ const id = typeof done.ticketRecordId === "string" ? done.ticketRecordId : "";
22837
+ const ticket = typeof done.ticket === "string" ? done.ticket : "";
22838
+ const relayUrl = routeRelayUrl(cfg.routes.find((x) => x.route === r.route));
22839
+ if (!relayUrl) return { mcpEndpointId: null, mcpWarning: "this ship has no relay URL yet, so the periscope's MCP endpoint is not registered; it is retried each cycle" };
22840
+ const out = await registerPeriscopeEndpoint(periscopeEndpointDeps(), { ticket, ticketRecordId: id, route: r.route, workspaceId: r.workspaceId, relayUrl });
22841
+ return out.ok ? { mcpEndpointId: out.endpointId, mcpUrl: out.url } : { mcpEndpointId: null, mcpWarning: out.message };
22842
+ },
22843
+ onDown: async () => {
22844
+ await removeEndedPeriscopeEndpoints(periscopeEndpointDeps());
22845
+ }
22846
+ }
22278
22847
  },
22848
+ periscopeMcp: controlRoutes.some((r) => r.enabled && r.hostPeriscopes) ? (req, res, ticket) => handlePeriscopeMcp(periscopeMcpDeps(), req, res, ticket) : void 0,
22279
22849
  status: () => crewJson(["status", route2.route, "--json"]),
22280
22850
  doctor: () => crewJson(["doctor", route2.route, "--json", "--offline"]),
22281
22851
  scheduler: schedulerHandler(planDaemonControl(CREW_HOME, hostPlatform(), detectSystemd())),
@@ -22507,10 +23077,10 @@ ${cu.tail.trimEnd()}
22507
23077
  `);
22508
23078
  if (!dryRun) {
22509
23079
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
22510
- const raw = existsSync41(resolvedPath) ? JSON.parse(readFileSync36(resolvedPath, "utf8")) : {};
23080
+ const raw = existsSync42(resolvedPath) ? JSON.parse(readFileSync37(resolvedPath, "utf8")) : {};
22511
23081
  raw.agentSkills = result.agentSkills;
22512
- mkdirSync26(dirOf2(resolvedPath), { recursive: true });
22513
- writeFileSync28(resolvedPath, `${JSON.stringify(raw, null, 2)}
23082
+ mkdirSync27(dirOf2(resolvedPath), { recursive: true });
23083
+ writeFileSync29(resolvedPath, `${JSON.stringify(raw, null, 2)}
22514
23084
  `);
22515
23085
  }
22516
23086
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -22533,7 +23103,7 @@ ${cu.tail.trimEnd()}
22533
23103
  throw e;
22534
23104
  }
22535
23105
  const oldName = cfg.ship.name;
22536
- writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), newName));
23106
+ writeFileSync29(cfg.configFile, setShipNameInYaml(readFileSync37(cfg.configFile, "utf8"), newName));
22537
23107
  cfg.ship.name = newName;
22538
23108
  let renameMachineId;
22539
23109
  try {
@@ -22580,14 +23150,14 @@ ${cu.tail.trimEnd()}
22580
23150
  process.exit(2);
22581
23151
  };
22582
23152
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
22583
- const configText = readFileSync36(cfg.configFile, "utf8");
23153
+ const configText = readFileSync37(cfg.configFile, "utf8");
22584
23154
  if (sub === "check") {
22585
23155
  try {
22586
23156
  const routeArg = value("route") ?? positional[2];
22587
23157
  const found = cfg.routes.find((r) => r.route === routeArg);
22588
23158
  if (!found) throw new ReposError(`no route "${routeArg}" in the config (have: ${cfg.routes.map((r) => r.route).join(", ") || "none"})`);
22589
23159
  const named2 = value("route") ? void 0 : positional[3];
22590
- const dir = named2 ? repoDirOf(configText, dirname22(cfg.configFile), found.route, named2) : resolvePath(positional[value("route") ? 2 : 3] ?? ".");
23160
+ const dir = named2 ? repoDirOf(configText, dirname23(cfg.configFile), found.route, named2) : resolvePath(positional[value("route") ? 2 : 3] ?? ".");
22591
23161
  const target = reposOf(found).find((t) => resolvePath(t.dir) === dir || t.name === named2);
22592
23162
  const o = target ? found.repoOverrides[target.name] : void 0;
22593
23163
  runReposCheck(dir, {
@@ -22610,17 +23180,17 @@ ${cu.tail.trimEnd()}
22610
23180
  if (sub === "hooks") {
22611
23181
  try {
22612
23182
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
22613
- const dir = repoDirOf(configText, dirname22(cfg.configFile), a.route, a.name);
22614
- if (!existsSync41(dir)) throw new ReposError(`${dir} does not exist on disk`);
22615
- const files = { exists: (r) => existsSync41(resolvePath(dir, r)), list: (r) => {
23183
+ const dir = repoDirOf(configText, dirname23(cfg.configFile), a.route, a.name);
23184
+ if (!existsSync42(dir)) throw new ReposError(`${dir} does not exist on disk`);
23185
+ const files = { exists: (r) => existsSync42(resolvePath(dir, r)), list: (r) => {
22616
23186
  try {
22617
- return readdirSync18(resolvePath(dir, r));
23187
+ return readdirSync19(resolvePath(dir, r));
22618
23188
  } catch {
22619
23189
  return [];
22620
23190
  }
22621
23191
  }, read: (r) => {
22622
23192
  try {
22623
- return readFileSync36(resolvePath(dir, r), "utf8");
23193
+ return readFileSync37(resolvePath(dir, r), "utf8");
22624
23194
  } catch {
22625
23195
  return void 0;
22626
23196
  }
@@ -22662,19 +23232,19 @@ ${cu.tail.trimEnd()}
22662
23232
  let cleared;
22663
23233
  if (clear2) {
22664
23234
  const plan = clearRouteHooks(configText, a.route, a.name);
22665
- if (plan.removed.length > 0) writeFileSync28(cfg.configFile, plan.text);
23235
+ if (plan.removed.length > 0) writeFileSync29(cfg.configFile, plan.text);
22666
23236
  cleared = { file: cfg.configFile, removed: plan.removed };
22667
23237
  }
22668
23238
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
22669
23239
  if (write === "route") {
22670
23240
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
22671
- writeFileSync28(cfg.configFile, plan.text);
23241
+ writeFileSync29(cfg.configFile, plan.text);
22672
23242
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
22673
23243
  } else if (write === "repo") {
22674
23244
  const have = existingRepoConfig(files);
22675
23245
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
22676
23246
  const file = resolvePath(dir, ".crew.yaml");
22677
- writeFileSync28(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
23247
+ writeFileSync29(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
22678
23248
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
22679
23249
  }
22680
23250
  if (flag("json")) {
@@ -22730,7 +23300,7 @@ ${cu.tail.trimEnd()}
22730
23300
  if (sub === "list" && !routeName) usage2();
22731
23301
  if (sub === "add") {
22732
23302
  try {
22733
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync41(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
23303
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync42(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
22734
23304
  routeName = a.route;
22735
23305
  addPath = a.path;
22736
23306
  addName = a.name;
@@ -22759,66 +23329,66 @@ ${cu.tail.trimEnd()}
22759
23329
  }
22760
23330
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
22761
23331
  let resolvedRemotes;
22762
- if (existsSync41(resolvedFile)) {
23332
+ if (existsSync42(resolvedFile)) {
22763
23333
  try {
22764
- resolvedRemotes = JSON.parse(readFileSync36(resolvedFile, "utf8"));
23334
+ resolvedRemotes = JSON.parse(readFileSync37(resolvedFile, "utf8"));
22765
23335
  } catch {
22766
23336
  }
22767
23337
  }
22768
23338
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
22769
- const text = configText;
22770
- const base = dirname22(cfg.configFile);
22771
- const isCheckout = (d) => existsSync41(`${d}/.git`);
23339
+ const text2 = configText;
23340
+ const base = dirname23(cfg.configFile);
23341
+ const isCheckout = (d) => existsSync42(`${d}/.git`);
22772
23342
  try {
22773
23343
  if (sub === "list") {
22774
23344
  if (flag("json")) {
22775
23345
  const rows = listRepoRows({
22776
- text,
23346
+ text: text2,
22777
23347
  route: routeName,
22778
23348
  base,
22779
23349
  resolved: resolvedRemotes,
22780
23350
  originOf,
22781
- exists: (d) => existsSync41(d),
23351
+ exists: (d) => existsSync42(d),
22782
23352
  isCheckout,
22783
23353
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
22784
23354
  try {
22785
- return Object.keys(parseRepoConfig(readFileSync36(resolvePath(d, f), "utf8"), f).hooks).length > 0;
23355
+ return Object.keys(parseRepoConfig(readFileSync37(resolvePath(d, f), "utf8"), f).hooks).length > 0;
22786
23356
  } catch {
22787
23357
  return false;
22788
23358
  }
22789
23359
  })
22790
23360
  });
22791
- process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows, cloneBase: cloneBaseOf(text, routeName, base, isCheckout) })}
23361
+ process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows, cloneBase: cloneBaseOf(text2, routeName, base, isCheckout) })}
22792
23362
  `);
22793
23363
  break;
22794
23364
  }
22795
- const cloneBase = cloneBaseOf(text, routeName, base, isCheckout);
22796
- const repos = configuredRepos(text, routeName, base).filter((r) => r.dir !== cloneBase);
23365
+ const cloneBase = cloneBaseOf(text2, routeName, base, isCheckout);
23366
+ const repos = configuredRepos(text2, routeName, base).filter((r) => r.dir !== cloneBase);
22797
23367
  if (repos.length === 0) process.stdout.write(`route ${routeName} has no checkout configured \u2014 \`crew repos add ${routeName} <path>\`
22798
23368
  `);
22799
23369
  for (const r of repos) {
22800
23370
  const origin2 = originOf(r.dir);
22801
23371
  const row = matchRepoName(origin2, resolvedRemotes);
22802
- const note = !existsSync41(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
23372
+ const note = !existsSync42(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
22803
23373
  process.stdout.write(`${r.name} ${r.dir} ${note}
22804
23374
  `);
22805
23375
  }
22806
23376
  break;
22807
23377
  }
22808
23378
  if (sub === "clone") {
22809
- const json = flag("json");
22810
- const say = (event, extra, human) => process.stdout.write(json ? `${JSON.stringify({ event, ...extra })}
23379
+ const json2 = flag("json");
23380
+ const say = (event, extra, human) => process.stdout.write(json2 ? `${JSON.stringify({ event, ...extra })}
22811
23381
  ` : `${human}
22812
23382
  `);
22813
- const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
22814
- if (existsSync41(plan2.target) && (existsSync41(`${plan2.target}/.git`) || readdirSync18(plan2.target).length > 0)) {
23383
+ const plan2 = planRepoClone({ text: text2, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
23384
+ if (existsSync42(plan2.target) && (existsSync42(`${plan2.target}/.git`) || readdirSync19(plan2.target).length > 0)) {
22815
23385
  throw new ReposError(
22816
- existsSync41(`${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`
23386
+ existsSync42(`${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`
22817
23387
  );
22818
23388
  }
22819
23389
  if (dryRun) {
22820
23390
  say("dry-run", { remote: plan2.remote, target: plan2.target }, `would clone ${plan2.remote} into ${plan2.target}`);
22821
- if (!json) process.stdout.write(`${plan2.block}
23391
+ if (!json2) process.stdout.write(`${plan2.block}
22822
23392
  `);
22823
23393
  break;
22824
23394
  }
@@ -22831,18 +23401,18 @@ ${cu.tail.trimEnd()}
22831
23401
  throw new ReposError(`${e.message}${authHint}
22832
23402
  crew.yaml was not changed`);
22833
23403
  }
22834
- writeFileSync28(cfg.configFile, plan2.text);
23404
+ writeFileSync29(cfg.configFile, plan2.text);
22835
23405
  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:
22836
23406
  `);
22837
23407
  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}`);
22838
23408
  break;
22839
23409
  }
22840
23410
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
22841
- const top = existsSync41(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
23411
+ const top = existsSync42(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
22842
23412
  if (!top) throw new ReposError(`${target} is not a git checkout`);
22843
23413
  const origin = originOf(top);
22844
23414
  const plan = planRepoAdd({
22845
- text,
23415
+ text: text2,
22846
23416
  base,
22847
23417
  route: routeName,
22848
23418
  path: top,
@@ -22890,15 +23460,15 @@ ${cu.tail.trimEnd()}
22890
23460
  const created = await rowTracker.createRepoRow(rowPayload);
22891
23461
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
22892
23462
  `);
22893
- if (existsSync41(resolvedFile)) {
22894
- const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
23463
+ if (existsSync42(resolvedFile)) {
23464
+ const raw = JSON.parse(readFileSync37(resolvedFile, "utf8"));
22895
23465
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
22896
23466
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
22897
- writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
23467
+ writeFileSync29(resolvedFile, `${JSON.stringify(raw, null, 2)}
22898
23468
  `);
22899
23469
  }
22900
23470
  }
22901
- writeFileSync28(cfg.configFile, plan.text);
23471
+ writeFileSync29(cfg.configFile, plan.text);
22902
23472
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
22903
23473
  `);
22904
23474
  } catch (e) {
@@ -23265,7 +23835,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23265
23835
  process.exit(2);
23266
23836
  }
23267
23837
  if (found.shipNameAdopted && !dryRun) {
23268
- writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), found.shipNameAdopted));
23838
+ writeFileSync29(cfg.configFile, setShipNameInYaml(readFileSync37(cfg.configFile, "utf8"), found.shipNameAdopted));
23269
23839
  cfg.ship.name = found.shipNameAdopted;
23270
23840
  }
23271
23841
  if (found.shipRow) {
@@ -23308,10 +23878,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23308
23878
  }
23309
23879
  }
23310
23880
  let previous;
23311
- const hadPreviousFile = existsSync41(resolvedPath);
23881
+ const hadPreviousFile = existsSync42(resolvedPath);
23312
23882
  if (hadPreviousFile) {
23313
23883
  try {
23314
- previous = JSON.parse(readFileSync36(resolvedPath, "utf8"));
23884
+ previous = JSON.parse(readFileSync37(resolvedPath, "utf8"));
23315
23885
  } catch {
23316
23886
  }
23317
23887
  }
@@ -23441,10 +24011,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23441
24011
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
23442
24012
  `);
23443
24013
  } else {
23444
- mkdirSync26(dirname22(resolvedPath), { recursive: true });
24014
+ mkdirSync27(dirname23(resolvedPath), { recursive: true });
23445
24015
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
23446
24016
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
23447
- writeFileSync28(tmpPath, `${JSON.stringify(resolved, null, 2)}
24017
+ writeFileSync29(tmpPath, `${JSON.stringify(resolved, null, 2)}
23448
24018
  `);
23449
24019
  renameSync4(tmpPath, resolvedPath);
23450
24020
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -23490,8 +24060,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23490
24060
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
23491
24061
  `);
23492
24062
  } else {
23493
- mkdirSync26(dirname22(apiKeyFile), { recursive: true });
23494
- writeFileSync28(apiKeyFile, `${apiKeyVar}=${apiKey}
24063
+ mkdirSync27(dirname23(apiKeyFile), { recursive: true });
24064
+ writeFileSync29(apiKeyFile, `${apiKeyVar}=${apiKey}
23495
24065
  `, { mode: 384 });
23496
24066
  process.stderr.write(`API key written to ${apiKeyFile}
23497
24067
  `);
@@ -24178,6 +24748,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
24178
24748
  },
24179
24749
  setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
24180
24750
  forgetLocal: (id) => {
24751
+ removePeriscopeSecret(cfg.ship.stateDir, id);
24181
24752
  removePeriscopeSidecar(cfg.ship.stateDir, id);
24182
24753
  removePeriscopePrior(cfg.ship.stateDir, id);
24183
24754
  },
@@ -24226,7 +24797,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
24226
24797
  const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
24227
24798
  const resumed = await runPeriscopeResume({
24228
24799
  find: async () => hit && { row: hit.row, tag: hit.tag },
24229
- worktreeExists: (p) => existsSync41(p),
24800
+ worktreeExists: (p) => existsSync42(p),
24230
24801
  caps: periscopeCaps(cfg.ship),
24231
24802
  runHook: async (name, cwd, env) => {
24232
24803
  const script = hit?.repo.config.hooks[name];
@@ -24320,10 +24891,10 @@ ${where.join("\n")}`);
24320
24891
  },
24321
24892
  prepare: async (path) => {
24322
24893
  for (const f of repoP.config.worktrees.copy) {
24323
- const src = join44(repoP.dir, f);
24324
- if (!existsSync41(src)) continue;
24325
- mkdirSync26(dirOf2(join44(path, f)), { recursive: true });
24326
- copyFileSync2(src, join44(path, f));
24894
+ const src = join46(repoP.dir, f);
24895
+ if (!existsSync42(src)) continue;
24896
+ mkdirSync27(dirOf2(join46(path, f)), { recursive: true });
24897
+ copyFileSync2(src, join46(path, f));
24327
24898
  }
24328
24899
  const setup = repoP.config.hooks.setup;
24329
24900
  return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
@@ -24335,7 +24906,10 @@ ${where.join("\n")}`);
24335
24906
  return script ? runScript(script, { cwd, env, shell: shellP, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
24336
24907
  },
24337
24908
  createRow: (fields) => trackerP.createPeriscope(fields),
24338
- recordLocal: (sc) => writePeriscopeSidecar(cfg.ship.stateDir, sc),
24909
+ recordLocal: (sc) => {
24910
+ writePeriscopeSidecar(cfg.ship.stateDir, { ...sc, route: routeP.route });
24911
+ if (flag("serve-mcp")) mintPeriscopeSecret(cfg.ship.stateDir, sc.ticketRecordId);
24912
+ },
24339
24913
  comment: (ticketId, body) => trackerP.postComment(ticketId, body, memberId),
24340
24914
  teardown: async (path, branch) => {
24341
24915
  const n = Number(path.match(/(\d+)\/?$/)?.[1]);
@@ -24371,6 +24945,10 @@ ${where.join("\n")}`);
24371
24945
  const row = crewP.find((r) => r.id === assigneeId);
24372
24946
  return row?.agent_id ? row.name ?? assigneeId : void 0;
24373
24947
  },
24948
+ liveRows: async (ticketRecordId) => {
24949
+ const [rows, ships] = [await trackerP.livePeriscopesForTicket(ticketRecordId), await trackerP.shipRows()];
24950
+ return rows.map((r) => ({ ship: ships.find((x) => x.id === r.ship_id)?.name ?? r.ship_id ?? "an unknown ship", url: r.url ?? "", worktree: r.worktree }));
24951
+ },
24374
24952
  hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
24375
24953
  hold: async (id) => {
24376
24954
  const auth = await trackerP.authorshipProbe();
@@ -24394,6 +24972,7 @@ ${where.join("\n")}`);
24394
24972
  if (a.issue) {
24395
24973
  const t = await deps.existing.load(a.issue);
24396
24974
  if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
24975
+ await assertNoLivePeriscope(deps.existing, t, a.issue);
24397
24976
  const refused = await existingRefusal(deps.existing, t);
24398
24977
  if (refused) throw new PeriscopeError(refused);
24399
24978
  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`);
@@ -24417,6 +24996,8 @@ ${where.join("\n")}`);
24417
24996
  } catch (e) {
24418
24997
  if (e instanceof PeriscopeError || e instanceof ConfigError) {
24419
24998
  process.stderr.write(`crew periscope: ${e.message}
24999
+ `);
25000
+ if (e instanceof LivePeriscopeError && jsonP) process.stdout.write(`${JSON.stringify(e.data)}
24420
25001
  `);
24421
25002
  process.exit(1);
24422
25003
  }
@@ -24606,7 +25187,7 @@ ${where.join("\n")}`);
24606
25187
  };
24607
25188
  const droppedRoutes = [];
24608
25189
  try {
24609
- const rawCfg = parseYaml2(readFileSync36(cfg.configFile, "utf8"));
25190
+ const rawCfg = parseYaml2(readFileSync37(cfg.configFile, "utf8"));
24610
25191
  for (const r of rawCfg?.routes ?? []) {
24611
25192
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
24612
25193
  try {
@@ -24648,8 +25229,8 @@ ${where.join("\n")}`);
24648
25229
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
24649
25230
  break;
24650
25231
  }
24651
- mkdirSync26(cfg.ship.stateDir, { recursive: true });
24652
- writeFileSync28(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
25232
+ mkdirSync27(cfg.ship.stateDir, { recursive: true });
25233
+ writeFileSync29(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
24653
25234
  `);
24654
25235
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
24655
25236
  if (flag("now") && control?.stopCommand && control.startCommand) {
@@ -24700,8 +25281,8 @@ ${where.join("\n")}`);
24700
25281
  }
24701
25282
  case "log": {
24702
25283
  try {
24703
- const text = readFileSync36(cfg.ship.logFile, "utf8").split("\n");
24704
- process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
25284
+ const text2 = readFileSync37(cfg.ship.logFile, "utf8").split("\n");
25285
+ process.stdout.write(`${text2.slice(-(Number(value("tail")) || 40)).join("\n")}
24705
25286
  `);
24706
25287
  } catch {
24707
25288
  process.stdout.write(`no log yet at ${cfg.ship.logFile}
@@ -24710,12 +25291,12 @@ ${where.join("\n")}`);
24710
25291
  break;
24711
25292
  }
24712
25293
  case "sample-ticket": {
24713
- const json = flag("json");
24714
- const say = (event, extra, human) => process.stdout.write(json ? `${JSON.stringify({ event, ...extra })}
25294
+ const json2 = flag("json");
25295
+ const say = (event, extra, human) => process.stdout.write(json2 ? `${JSON.stringify({ event, ...extra })}
24715
25296
  ` : `${human}
24716
25297
  `);
24717
25298
  const fail = (msg) => {
24718
- if (json) process.stdout.write(`${JSON.stringify({ event: "error", message: msg })}
25299
+ if (json2) process.stdout.write(`${JSON.stringify({ event: "error", message: msg })}
24719
25300
  `);
24720
25301
  else process.stderr.write(`crew sample-ticket: ${msg}
24721
25302
  `);
@@ -24723,8 +25304,8 @@ ${where.join("\n")}`);
24723
25304
  };
24724
25305
  if (!route2.resolved) fail(`route ${route2.route} is not connected \u2014 run \`crew connect\` first`);
24725
25306
  const tracker = new Tracker(route2, cfg.ship);
24726
- const configText = readFileSync36(cfg.configFile, "utf8");
24727
- const cfgBase = dirname22(cfg.configFile);
25307
+ const configText = readFileSync37(cfg.configFile, "utf8");
25308
+ const cfgBase = dirname23(cfg.configFile);
24728
25309
  const configured2 = configuredRepos(configText, route2.route, cfgBase);
24729
25310
  const dir = configured2.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured2.map((r) => r.dir), route2.reposBasePath, route2.route.split("/")[0]);
24730
25311
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, route2.route);
@@ -24752,7 +25333,7 @@ ${where.join("\n")}`);
24752
25333
  }
24753
25334
  if (configured2.some((r) => r.dir === dir)) {
24754
25335
  try {
24755
- writeFileSync28(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
25336
+ writeFileSync29(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
24756
25337
  } catch (e) {
24757
25338
  if (e instanceof ReposError) fail(e.message);
24758
25339
  throw e;
@@ -24760,29 +25341,29 @@ ${where.join("\n")}`);
24760
25341
  }
24761
25342
  if (row) {
24762
25343
  await tracker.deleteRepoRow(row.id);
24763
- if (existsSync41(resolvedFile)) {
24764
- const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
25344
+ if (existsSync42(resolvedFile)) {
25345
+ const raw = JSON.parse(readFileSync37(resolvedFile, "utf8"));
24765
25346
  if (raw.repoNames) delete raw.repoNames[row.id];
24766
25347
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
24767
- writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
25348
+ writeFileSync29(resolvedFile, `${JSON.stringify(raw, null, 2)}
24768
25349
  `);
24769
25350
  }
24770
25351
  }
24771
- if (existsSync41(join44(dir, ".git")) && basename13(dir) === SAMPLE_REPO) rmSync19(dir, { recursive: true, force: true });
25352
+ if (existsSync42(join46(dir, ".git")) && basename14(dir) === SAMPLE_REPO) rmSync20(dir, { recursive: true, force: true });
24772
25353
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
24773
25354
  break;
24774
25355
  }
24775
25356
  const projectId = route2.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
24776
- const fresh = !existsSync41(join44(dir, ".git"));
24777
- if (existsSync41(dir) && fresh && readdirSync18(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
25357
+ const fresh = !existsSync42(join46(dir, ".git"));
25358
+ if (existsSync42(dir) && fresh && readdirSync19(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
24778
25359
  if (dryRun) {
24779
25360
  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`);
24780
25361
  break;
24781
25362
  }
24782
25363
  if (fresh) {
24783
- mkdirSync26(dir, { recursive: true });
24784
- writeFileSync28(join44(dir, "README.md"), SAMPLE_README);
24785
- writeFileSync28(join44(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
25364
+ mkdirSync27(dir, { recursive: true });
25365
+ writeFileSync29(join46(dir, "README.md"), SAMPLE_README);
25366
+ writeFileSync29(join46(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
24786
25367
  const g = (...a) => {
24787
25368
  const r = spawnSync8("git", a, { cwd: dir, encoding: "utf8" });
24788
25369
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -24800,17 +25381,17 @@ ${where.join("\n")}`);
24800
25381
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
24801
25382
  });
24802
25383
  repoId = (await tracker.createRepoRow(payload)).id;
24803
- if (existsSync41(resolvedFile)) {
24804
- const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
25384
+ if (existsSync42(resolvedFile)) {
25385
+ const raw = JSON.parse(readFileSync37(resolvedFile, "utf8"));
24805
25386
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
24806
25387
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
24807
- writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
25388
+ writeFileSync29(resolvedFile, `${JSON.stringify(raw, null, 2)}
24808
25389
  `);
24809
25390
  }
24810
25391
  }
24811
25392
  if (!configured2.some((r) => r.dir === dir)) {
24812
25393
  try {
24813
- writeFileSync28(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
25394
+ writeFileSync29(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
24814
25395
  } catch (e) {
24815
25396
  if (e instanceof ReposError) fail(e.message);
24816
25397
  throw e;
@@ -25043,7 +25624,7 @@ ${where.join("\n")}`);
25043
25624
  // `synthesis-tabl-123`. `worktreePrefix` above is kept for
25044
25625
  // config-provenance display; this is the field that actually
25045
25626
  // describes where a worktree lands by default.
25046
- worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename13(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
25627
+ worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename14(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
25047
25628
  worktreeParent: resolvePath(r.dir, "..")
25048
25629
  })),
25049
25630
  waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
@@ -25148,7 +25729,7 @@ watermark: ${state.watermark()}
25148
25729
  }
25149
25730
  for (const r of repos) {
25150
25731
  const remote = r.config.branch.remote;
25151
- if (existsSync41(r.dir)) {
25732
+ if (existsSync42(r.dir)) {
25152
25733
  pushAuth.push({
25153
25734
  route: c.route,
25154
25735
  repo: r.name,
@@ -25206,6 +25787,7 @@ watermark: ${state.watermark()}
25206
25787
  const { settled } = await settleRepos(reposOf(route2), (t) => t.name, (t) => resolveOneRepo(route2, t));
25207
25788
  const tablePresent = flag("offline") ? void 0 : (await new Tracker(route2, cfg.ship).periscopesStatus().catch(() => ({ present: true }))).present;
25208
25789
  problems.push(...routePeriscopeProblems({ route: route2.route, hosts: true, repos: settled.map((r) => ({ name: r.name, hasPreviewHook: !!r.config.hooks.preview })), tablePresent }));
25790
+ if (!flag("offline")) problems.push(...await duplicateLivePeriscopeProblems(route2, cfg.ship));
25209
25791
  }
25210
25792
  problems.push(...attentionProblems(openShipAttention(state), problems));
25211
25793
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
@@ -25233,7 +25815,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25233
25815
  }
25234
25816
  try {
25235
25817
  const evFile = eventFileFor(cfg.ship.stateDir);
25236
- const evSize = existsSync41(evFile) ? statSync14(evFile).size : 0;
25818
+ const evSize = existsSync42(evFile) ? statSync14(evFile).size : 0;
25237
25819
  process.stdout.write(
25238
25820
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
25239
25821
  ` : "events log: ok\n"
@@ -25244,7 +25826,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25244
25826
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run", installContext());
25245
25827
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
25246
25828
  try {
25247
- return readFileSync36(p, "utf8");
25829
+ return readFileSync37(p, "utf8");
25248
25830
  } catch {
25249
25831
  return null;
25250
25832
  }
@@ -25287,7 +25869,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25287
25869
  );
25288
25870
  for (const r of await resolvedRepos(route2)) {
25289
25871
  const { config } = r;
25290
- const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename13(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
25872
+ const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename14(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
25291
25873
  process.stdout.write(
25292
25874
  `${`repo ${r.name}:`.padEnd(19)}${r.dir}
25293
25875
  worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
@@ -25351,6 +25933,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25351
25933
  process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25352
25934
  `);
25353
25935
  }
25936
+ if (!flag("offline")) {
25937
+ for (const f of await duplicateLivePeriscopeProblems(route2, cfg.ship)) {
25938
+ process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25939
+ `);
25940
+ }
25941
+ }
25354
25942
  }
25355
25943
  process.stdout.write(
25356
25944
  !periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
@@ -25668,6 +26256,11 @@ ${foreign.join("\n")}
25668
26256
  );
25669
26257
  }
25670
26258
  }
26259
+ for (const e of listPeriscopeEndpoints(cfg.ship.stateDir)) {
26260
+ const secret = readPeriscopeSecret(cfg.ship.stateDir, e.ticketRecordId) ? "live" : "ended, awaiting removal";
26261
+ process.stdout.write(`periscope mcp: ${e.ticket} ${e.route} ${e.url} (${secret})
26262
+ `);
26263
+ }
25671
26264
  if (cfg.ship.relayHost && !flag("offline")) {
25672
26265
  const relayUrl = routeRelayUrl(route2);
25673
26266
  if (!relayUrl) {
@@ -25715,6 +26308,8 @@ var parseTicketNumber2;
25715
26308
  var routeRelayUrl2;
25716
26309
  var passengerApiKeys2;
25717
26310
  var endpointApiFor2;
26311
+ var periscopeEndpointDeps2;
26312
+ var periscopeMcpDeps2;
25718
26313
  var endpointReconcileDeps2;
25719
26314
  var releaseRefused2;
25720
26315
  var doctorMachineId2;