@sideboard-ai/core 0.1.241 → 0.1.243

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 (52) hide show
  1. package/dist/{abletime-VSV6AUBV.js → abletime-CWIJ7INC.js} +4 -4
  2. package/dist/{abletime-UX4VUENQ.js → abletime-OI6FNSXX.js} +4 -4
  3. package/dist/{abletime-oauth-DKBUZV2C.js → abletime-oauth-CHVTUZBR.js} +3 -3
  4. package/dist/{abletime-oauth-QMDV45JC.js → abletime-oauth-PUIPZTA4.js} +3 -3
  5. package/dist/{abletime-rest-NL56UVK5.js → abletime-rest-32NIVXAY.js} +3 -3
  6. package/dist/{abletime-rest-VPBC2XS5.js → abletime-rest-APJUKFKL.js} +3 -3
  7. package/dist/{agents-CDQCQVWU.js → agents-CPME6O2H.js} +9 -5
  8. package/dist/{agents-PBRIHUYM.js → agents-VKO5UZO6.js} +9 -5
  9. package/dist/{app-settings-AGDLF75Z.js → app-settings-652J57CX.js} +1 -1
  10. package/dist/{app-settings-I27UBVI4.js → app-settings-VOPXJMGV.js} +1 -1
  11. package/dist/{chunk-BILEJG3V.js → chunk-3P5OCI2X.js} +3 -3
  12. package/dist/{chunk-3PGFAVFL.js → chunk-43F44EKX.js} +2 -2
  13. package/dist/{chunk-3AJUH66R.js → chunk-6I5YQTNK.js} +2 -1
  14. package/dist/{chunk-NJ5OXOTN.js → chunk-7HOKP4GR.js} +2 -1
  15. package/dist/{chunk-TB6UMHIF.js → chunk-7UCZ4DRF.js} +2 -2
  16. package/dist/{chunk-DXY4K5MP.js → chunk-BL2UGKWU.js} +3 -3
  17. package/dist/{chunk-CWHMLCLN.js → chunk-C64YQAA6.js} +3 -3
  18. package/dist/{chunk-O3UAQAYA.js → chunk-CCD2QIFK.js} +2 -2
  19. package/dist/{chunk-63UHRQ64.js → chunk-DARSRQUE.js} +1523 -1450
  20. package/dist/{chunk-OKHOYI53.js → chunk-ECZTXNYR.js} +3 -3
  21. package/dist/{chunk-CRAXJAYJ.js → chunk-G45QP2ZD.js} +3 -3
  22. package/dist/{chunk-3Q4PVZ3U.js → chunk-HR4RNC73.js} +32 -5
  23. package/dist/{chunk-YXOOSFAF.js → chunk-IMO7AZI4.js} +2 -2
  24. package/dist/{chunk-QXYWLO6W.js → chunk-ISZERVDT.js} +28 -5
  25. package/dist/{chunk-EE3REM4B.js → chunk-NEOAHPMK.js} +1582 -1505
  26. package/dist/{chunk-DJU54RYW.js → chunk-PGPSIEMW.js} +13 -0
  27. package/dist/{chunk-P3DCZRER.js → chunk-PPJ5JTWA.js} +1 -0
  28. package/dist/{chunk-5ELC3KK3.js → chunk-QNJXJIWJ.js} +5 -5
  29. package/dist/{chunk-JFOPOMK7.js → chunk-WDDUIGFK.js} +13 -0
  30. package/dist/{chunk-IVXPXYH2.js → chunk-WURF67ID.js} +5 -5
  31. package/dist/{chunk-QLWMHQPH.js → chunk-YLGBQVRK.js} +1 -0
  32. package/dist/{coordinator-prompt-5UINTEA6.js → coordinator-prompt-AJ3E6LBX.js} +4 -4
  33. package/dist/{coordinator-prompt-ATK73HYU.js → coordinator-prompt-PYIRQGU7.js} +4 -4
  34. package/dist/{global-workspace-TD5SARNW.js → global-workspace-GIQ6F6IJ.js} +4 -4
  35. package/dist/{global-workspace-ZJBBICFJ.js → global-workspace-GQM653DP.js} +4 -4
  36. package/dist/index.cjs +609 -463
  37. package/dist/index.d.cts +56 -2
  38. package/dist/index.d.ts +56 -2
  39. package/dist/index.js +74 -38
  40. package/dist/mcp/run-stdio.cjs +1029 -908
  41. package/dist/mcp/run-stdio.js +50 -36
  42. package/dist/{orchestrator-ZUOEBJFN.js → orchestrator-3IOISDFS.js} +7 -7
  43. package/dist/{orchestrator-7AJEFWGQ.js → orchestrator-YDPDMXUY.js} +7 -7
  44. package/dist/{plan-file-UUKCZEYP.js → plan-file-7MMIPV7Y.js} +9 -1
  45. package/dist/{plan-file-2EPHYRX7.js → plan-file-CKXUFTQ2.js} +9 -1
  46. package/dist/{thread-store-YOPKY74K.js → thread-store-JQB7KZJR.js} +1 -1
  47. package/dist/{thread-store-AYPBEZT5.js → thread-store-SCV2IZYS.js} +1 -1
  48. package/dist/{workspaces-4SFSCYKT.js → workspaces-OIBSD5B6.js} +4 -4
  49. package/dist/{workspaces-JX5R4V24.js → workspaces-WYC5TLC3.js} +4 -4
  50. package/dist/{worktree-Y5FES5D6.js → worktree-5VRZVGP3.js} +3 -3
  51. package/dist/{worktree-AGVPKDBH.js → worktree-DGDM7LGV.js} +3 -3
  52. package/package.json +1 -1
@@ -4,17 +4,17 @@ import {
4
4
  extractPresentedPlan,
5
5
  readPlanFile,
6
6
  writePlanFile
7
- } from "./chunk-JFOPOMK7.js";
7
+ } from "./chunk-WDDUIGFK.js";
8
8
  import {
9
9
  releaseCaffeinateHoldForThread
10
10
  } from "./chunk-DQN5U2VH.js";
11
11
  import {
12
- PLAN_MODE_INSTRUCTION,
13
12
  adjustBrightsyTurnExit,
14
13
  applyAgentEvent,
15
14
  applyAgentRunnerHeapEnv,
16
15
  clipAgentEventForPaint,
17
16
  clipToolResultForStore,
17
+ compactSummaryPart,
18
18
  fallbackTurnFailDetail,
19
19
  finalizeParts,
20
20
  formatAgentErrorContinuePrompt,
@@ -24,6 +24,7 @@ import {
24
24
  isInternalAgentStatusText,
25
25
  isPollWrapperToolName,
26
26
  isSessionQuotaLimit,
27
+ lastAssistantMessageText,
27
28
  liveActivitySummary,
28
29
  looksLikeAgentFailureMessage,
29
30
  looksLikeInvalidAgentSession,
@@ -33,6 +34,7 @@ import {
33
34
  parseBrightsyCliLine,
34
35
  parseSessionQuotaResetAt,
35
36
  partsToAssistantText,
37
+ planModeTurnInstruction,
36
38
  pushTurnStderr,
37
39
  resolveQuotaFallbackAgent,
38
40
  shouldFeedErrorBackToAgent,
@@ -42,7 +44,7 @@ import {
42
44
  summarizeTurnStderr,
43
45
  toolDescription,
44
46
  turnFailChatText
45
- } from "./chunk-3Q4PVZ3U.js";
47
+ } from "./chunk-HR4RNC73.js";
46
48
  import {
47
49
  SLACK_REPLY_FORMATTING,
48
50
  addWorkspace,
@@ -65,7 +67,7 @@ import {
65
67
  stageAbsolutePathsAsAttachments,
66
68
  stageBuffersAsAttachments,
67
69
  syncWorkspacesFromThreads
68
- } from "./chunk-OKHOYI53.js";
70
+ } from "./chunk-ECZTXNYR.js";
69
71
  import {
70
72
  addPrStackLayer,
71
73
  allocateTeamName,
@@ -114,7 +116,7 @@ import {
114
116
  worktreeDisplayLabelForGroup,
115
117
  worktreeNameFromPath,
116
118
  worktreeSlugForTicket
117
- } from "./chunk-TB6UMHIF.js";
119
+ } from "./chunk-7UCZ4DRF.js";
118
120
  import {
119
121
  ATTACHMENTS_DIR,
120
122
  DETACHED_JOBS_DIR,
@@ -132,7 +134,7 @@ import {
132
134
  updateThread,
133
135
  withThreadLock,
134
136
  writeThread
135
- } from "./chunk-NJ5OXOTN.js";
137
+ } from "./chunk-7HOKP4GR.js";
136
138
  import {
137
139
  ensureWorktreeSideboardIgnored
138
140
  } from "./chunk-DD67JWTN.js";
@@ -173,7 +175,7 @@ import {
173
175
  sumUsageList,
174
176
  updateAdvancedSettings,
175
177
  usageOnLimit
176
- } from "./chunk-QLWMHQPH.js";
178
+ } from "./chunk-YLGBQVRK.js";
177
179
  import {
178
180
  writePrivateFile
179
181
  } from "./chunk-NSTQ6QKD.js";
@@ -488,9 +490,9 @@ async function spawnAgentTurn(thread, input, onEvent) {
488
490
  `Cannot spawn ${thread.agent}: thread ${thread.id} has no worktreePath`
489
491
  );
490
492
  }
491
- const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-TD5SARNW.js");
493
+ const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-GIQ6F6IJ.js");
492
494
  if (isGlobalThread2(thread)) {
493
- const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-ATK73HYU.js");
495
+ const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-PYIRQGU7.js");
494
496
  ensureGlobalCoordinatorCwd2(
495
497
  isOrchestratorThread(thread) ? { orchestratorThreadId: thread.id } : void 0
496
498
  );
@@ -791,15 +793,15 @@ function shouldAutoArchiveOnPrMerge(opts) {
791
793
  // src/hook/conductor.ts
792
794
  import {
793
795
  copyFileSync,
794
- existsSync as existsSync3,
795
- mkdirSync as mkdirSync4,
796
- readdirSync,
797
- readFileSync as readFileSync3,
796
+ existsSync as existsSync4,
797
+ mkdirSync as mkdirSync5,
798
+ readdirSync as readdirSync2,
799
+ readFileSync as readFileSync4,
798
800
  realpathSync
799
801
  } from "fs";
800
802
  import { execFileSync } from "child_process";
801
803
  import { createServer } from "net";
802
- import { basename, dirname as dirname3, join as join5, resolve, sep } from "path";
804
+ import { basename, dirname as dirname4, join as join6, resolve, sep } from "path";
803
805
  import { execa as execa2 } from "execa";
804
806
  import { createInterface as createInterface2 } from "readline";
805
807
 
@@ -1172,12 +1174,38 @@ ${match.text}`, "");
1172
1174
  blocks.push("Continue from this context. Do not repeat the summary unless asked.");
1173
1175
  return blocks.join("\n");
1174
1176
  }
1177
+ function compactArchiveProse(message) {
1178
+ if (message.origin === "continue") return null;
1179
+ if (message.role === "summary") return null;
1180
+ let text = (message.text ?? "").trim();
1181
+ if (!text && message.role === "agent") {
1182
+ text = lastAssistantMessageText(message.parts).trim();
1183
+ }
1184
+ if (!text || isInternalAgentStatusText(text)) return null;
1185
+ return { role: message.role, text, ts: message.ts };
1186
+ }
1187
+ function messagesForCompactArchive(older) {
1188
+ const out = [];
1189
+ for (const message of older) {
1190
+ const prose = compactArchiveProse(message);
1191
+ if (prose) out.push(prose);
1192
+ }
1193
+ return out;
1194
+ }
1195
+ function appendCompactedMessages(existing, older) {
1196
+ const next = messagesForCompactArchive(older);
1197
+ if (!existing?.length) return next;
1198
+ if (!next.length) return existing;
1199
+ return [...existing, ...next];
1200
+ }
1175
1201
  function applyCompaction(messages, summaryText, thresholds = {}) {
1176
1202
  const { older, recent } = splitForCompaction(messages, thresholds);
1177
1203
  if (older.length === 0) return messages;
1204
+ const text = summaryText.trim();
1178
1205
  const summary = {
1179
1206
  role: "summary",
1180
- text: summaryText.trim(),
1207
+ text,
1208
+ parts: [compactSummaryPart(text)],
1181
1209
  ts: (/* @__PURE__ */ new Date()).toISOString()
1182
1210
  };
1183
1211
  return [summary, ...recent];
@@ -1205,6 +1233,7 @@ async function maybeCompactContext(thread, thresholds = {}, summarize = summariz
1205
1233
  cwd: thread.worktreePath
1206
1234
  });
1207
1235
  let messages = applyCompaction(thread.messages, summary, thresholds);
1236
+ const compactedMessages = appendCompactedMessages(thread.compactedMessages, older);
1208
1237
  const resetSession = shouldResetSessionForOccupancy({ messages: thread.messages });
1209
1238
  if (resetSession) {
1210
1239
  messages = applyForwardOccupancy(messages);
@@ -1212,6 +1241,7 @@ async function maybeCompactContext(thread, thresholds = {}, summarize = summariz
1212
1241
  const next = {
1213
1242
  ...thread,
1214
1243
  messages,
1244
+ ...compactedMessages.length ? { compactedMessages } : { compactedMessages: void 0 },
1215
1245
  sessionId: resetSession ? null : thread.sessionId,
1216
1246
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1217
1247
  };
@@ -1719,1595 +1749,1615 @@ function saveWorktreeRunPorts(worktreePath, scriptName, ports) {
1719
1749
  `, "utf8");
1720
1750
  }
1721
1751
 
1722
- // src/hook/conductor.ts
1723
- var PORT_RANGE_SIZE = 10;
1724
- var RECLAIM_RETRY_DELAY_MS = 40;
1725
- var RECLAIM_TRIES = 8;
1726
- function matchSimpleGlob(pattern, name) {
1727
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1728
- return new RegExp(`^${escaped}$`).test(name);
1729
- }
1730
- function readWorktreeInclude(repoPath) {
1731
- const path = join5(repoPath, ".worktreeinclude");
1732
- if (!existsSync3(path)) return [];
1733
- return readFileSync3(path, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
1752
+ // src/mcp/wait-for-job.ts
1753
+ import {
1754
+ appendFileSync as appendFileSync2,
1755
+ existsSync as existsSync3,
1756
+ mkdirSync as mkdirSync4,
1757
+ readdirSync,
1758
+ readFileSync as readFileSync3,
1759
+ writeFileSync as writeFileSync3
1760
+ } from "fs";
1761
+ import { dirname as dirname3, join as join5 } from "path";
1762
+
1763
+ // src/mcp/wait-for-turn.ts
1764
+ var MCP_WAIT_FOR_TURN_MAX_MS = 45e3;
1765
+ function mcpWaitForTurnTimeoutMs(requested) {
1766
+ const n = requested ?? MCP_WAIT_FOR_TURN_MAX_MS;
1767
+ if (!Number.isFinite(n)) return MCP_WAIT_FOR_TURN_MAX_MS;
1768
+ return Math.min(Math.max(1e3, Math.floor(n)), MCP_WAIT_FOR_TURN_MAX_MS);
1734
1769
  }
1735
- function resolveFilesToCopy(repoPath) {
1736
- const fromInclude = readWorktreeInclude(repoPath);
1737
- if (fromInclude.length) return fromInclude;
1738
- const settings = loadRepoSettings(repoPath);
1739
- if (settings?.filesToCopy?.length) return settings.filesToCopy;
1740
- if (settings?.fileIncludeGlobs?.length) {
1741
- const matched = [];
1742
- try {
1743
- for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
1744
- if (!entry.isFile()) continue;
1745
- for (const glob of settings.fileIncludeGlobs) {
1746
- if (matchSimpleGlob(glob, entry.name) || matchSimpleGlob(basename(glob), entry.name)) {
1747
- matched.push(entry.name);
1748
- break;
1749
- }
1750
- }
1751
- }
1752
- } catch {
1753
- }
1754
- if (matched.length) return [...new Set(matched)];
1755
- }
1756
- const defaults = [];
1757
- try {
1758
- for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
1759
- if (entry.isFile() && entry.name.startsWith(".env")) {
1760
- defaults.push(entry.name);
1761
- }
1762
- }
1763
- } catch {
1770
+ var MCP_WAIT_STILL_RUNNING_HINT = "Child is still working. Call wait_for_turn again. Do not send_to_thread a check-in (that steers / interrupts) or assume a hang while progress is updating.";
1771
+ var MCP_WAIT_QUEUED_HINT = "Child is queued waiting for a concurrency slot \u2014 it has not started yet. Call wait_for_turn again. Do not send_to_thread a check-in (that steers / interrupts), force_stop, or assume it failed to start.";
1772
+ var MCP_WAIT_STOPPED_HINT = "Child was stopped before the turn finished. Do not treat this as success. send_to_thread to resume, or tell the user.";
1773
+ var MCP_WAIT_BROKEN_HINT = "Child worktree is broken (missing on disk). Tell the user \u2014 do not treat this as success.";
1774
+ var MCP_WAIT_ERROR_HINT = "Child turn failed. lastError/text is the failure \u2014 switch agent, tell the user, or retry. Do not treat empty text as success.";
1775
+ var MCP_WAIT_INPUT_REQUIRED_HINT = "Child asked the user a question (ask_user). taskState is input-required. Wait for the user to answer in that chat \u2014 do not send_to_thread a check-in.";
1776
+ function mcpWaitTaskHint(taskState, status) {
1777
+ switch (taskState) {
1778
+ case "submitted":
1779
+ return MCP_WAIT_QUEUED_HINT;
1780
+ case "working":
1781
+ return MCP_WAIT_STILL_RUNNING_HINT;
1782
+ case "input-required":
1783
+ return MCP_WAIT_INPUT_REQUIRED_HINT;
1784
+ case "canceled":
1785
+ return MCP_WAIT_STOPPED_HINT;
1786
+ case "failed":
1787
+ return status === "broken" ? MCP_WAIT_BROKEN_HINT : MCP_WAIT_ERROR_HINT;
1788
+ default:
1789
+ return void 0;
1764
1790
  }
1765
- if (defaults.length) return defaults;
1766
- return [".env.local", ".env"];
1767
1791
  }
1768
- function copyConfiguredFiles(repoPath, worktreePath) {
1769
- const patterns = resolveFilesToCopy(repoPath);
1770
- const copied = [];
1771
- for (const rel of patterns) {
1772
- const src = join5(repoPath, rel);
1773
- if (!existsSync3(src)) continue;
1774
- const dest = join5(worktreePath, rel);
1775
- mkdirSync4(dirname3(dest), { recursive: true });
1776
- copyFileSync(src, dest);
1777
- copied.push(rel);
1792
+
1793
+ // src/mcp/wait-for-job.ts
1794
+ var MAX_JOB_CONTINUES = 8;
1795
+ var MAX_JOB_DELTA_LINES = 80;
1796
+ var MAX_JOB_DELTA_CHARS = 4e3;
1797
+ var DEFERRED_DONE_MAX_CHARS = 480;
1798
+ var GH_WATCH_REFRESH = "Refreshing run status every 3 seconds.";
1799
+ var MCP_WAIT_JOB_STILL_RUNNING_HINT = "Job is still running. The type=log pane already appended this delta. Call wait_for_job again. If it is hanging, producing no useful output, or doing the wrong thing, call stop_job (same id) instead of looping forever. Do not end the turn or tell the user you will let them know later.";
1800
+ var MCP_STOP_JOB_HINT = "Stopped. The type=log pane is status=failed with the last delta. Do not wait again unless you start a new command.";
1801
+ var JOB_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
1802
+ function mcpWaitForJobTimeoutMs(requested) {
1803
+ return mcpWaitForTurnTimeoutMs(requested);
1804
+ }
1805
+ function sanitizeDetachedJobId(id) {
1806
+ const s = id.trim();
1807
+ if (!JOB_ID_RE.test(s)) {
1808
+ throw new Error(`detached-job id must be 1\u201364 chars [A-Za-z0-9._-], got ${JSON.stringify(id)}`);
1778
1809
  }
1779
- return copied;
1810
+ return s;
1780
1811
  }
1781
- var cachedLoginEnv = null;
1782
- async function captureLoginEnv() {
1783
- if (cachedLoginEnv) return { ...cachedLoginEnv };
1784
- const shell = process.env.SHELL?.trim() || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
1812
+ function jobAlive(pid) {
1813
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1785
1814
  try {
1786
- const result = await execa2(shell, ["-l", "-c", "env -0"], {
1787
- reject: false,
1788
- timeout: 5e3
1789
- });
1790
- if (result.exitCode === 0 && result.stdout) {
1791
- const env = { ...process.env };
1792
- for (const entry of result.stdout.split("\0")) {
1793
- if (!entry) continue;
1794
- const eq = entry.indexOf("=");
1795
- if (eq <= 0) continue;
1796
- const key = entry.slice(0, eq);
1797
- const value = entry.slice(eq + 1);
1798
- if (key) env[key] = value;
1799
- }
1800
- cachedLoginEnv = env;
1801
- return { ...env };
1802
- }
1815
+ process.kill(pid, 0);
1816
+ return true;
1803
1817
  } catch {
1818
+ return false;
1804
1819
  }
1805
- cachedLoginEnv = { ...process.env };
1806
- return { ...cachedLoginEnv };
1807
1820
  }
1808
- function buildWorkspaceScriptEnv(opts, baseEnv) {
1809
- const env = stripNestedElectronEnv({
1810
- ...baseEnv ?? process.env
1811
- });
1812
- const name = opts.workspaceName ?? basename(opts.worktreePath);
1813
- const ports = opts.ports ?? [];
1814
- const primary = ports[0];
1815
- env.SIDEBOARD_WORKSPACE_NAME = name;
1816
- env.SIDEBOARD_WORKSPACE_PATH = opts.worktreePath;
1817
- env.SIDEBOARD_ROOT_PATH = opts.repoPath;
1818
- env.SIDEBOARD_IS_LOCAL = "1";
1819
- if (opts.defaultBranch) env.SIDEBOARD_DEFAULT_BRANCH = opts.defaultBranch;
1820
- env.CONDUCTOR_WORKSPACE_NAME = name;
1821
- env.CONDUCTOR_WORKSPACE_PATH = opts.worktreePath;
1822
- env.CONDUCTOR_ROOT_PATH = opts.repoPath;
1823
- env.CONDUCTOR_IS_LOCAL = "1";
1824
- if (opts.defaultBranch) env.CONDUCTOR_DEFAULT_BRANCH = opts.defaultBranch;
1825
- if (primary != null) {
1826
- env.SIDEBOARD_PORT = String(primary);
1827
- env.CONDUCTOR_PORT = String(primary);
1828
- env.PORT = String(primary);
1829
- for (let i = 1; i < ports.length; i++) {
1830
- const p = ports[i];
1831
- env[`SIDEBOARD_PORT_${i}`] = String(p);
1832
- env[`CONDUCTOR_PORT_${i}`] = String(p);
1833
- }
1821
+ function processGroupAlive(pgid) {
1822
+ if (process.platform === "win32") return false;
1823
+ if (!Number.isInteger(pgid) || pgid <= 1) return false;
1824
+ try {
1825
+ process.kill(-pgid, 0);
1826
+ return true;
1827
+ } catch (err) {
1828
+ return err && typeof err === "object" && "code" in err && err.code === "EPERM";
1834
1829
  }
1835
- applyWorktreePkgCacheEnv(env, opts.worktreePath);
1836
- return env;
1837
- }
1838
- function pipeLines(stream, onLine) {
1839
- if (!stream || !onLine) return;
1840
- const rl = createInterface2({ input: stream });
1841
- rl.on("line", onLine);
1842
1830
  }
1843
- function killableListenerPids(raw, selfPid = process.pid) {
1844
- const pids = [];
1845
- for (const token of raw.trim().split(/\s+/)) {
1846
- const pid = Number(token);
1847
- if (!Number.isFinite(pid) || pid <= 0 || pid === selfPid) continue;
1848
- pids.push(pid);
1849
- }
1850
- return pids;
1831
+ function jobTreeAlive(pid) {
1832
+ if (pid == null) return false;
1833
+ return jobAlive(pid) || processGroupAlive(pid);
1851
1834
  }
1852
- function listUnixListenerPids(port, selfPid) {
1853
- try {
1854
- const raw = execFileSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
1855
- encoding: "utf8",
1856
- stdio: ["ignore", "pipe", "ignore"],
1857
- timeout: 2e3
1858
- });
1859
- return killableListenerPids(raw, selfPid);
1860
- } catch {
1861
- return [];
1862
- }
1835
+ function readIntFile(file) {
1836
+ if (!existsSync3(file)) return null;
1837
+ const n = Number.parseInt(readFileSync3(file, "utf8").trim(), 10);
1838
+ return Number.isInteger(n) ? n : null;
1863
1839
  }
1864
- function listenerCwd(pid) {
1865
- if (!Number.isFinite(pid) || pid <= 0) return null;
1866
- try {
1867
- const raw = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
1868
- encoding: "utf8",
1869
- stdio: ["ignore", "pipe", "ignore"],
1870
- timeout: 2e3
1871
- });
1872
- for (const line of raw.split("\n")) {
1873
- if (line.startsWith("n") && line.length > 1) return line.slice(1);
1874
- }
1875
- } catch {
1876
- }
1877
- return null;
1840
+ function jobDir(root, id, legacy = false) {
1841
+ return join5(root, legacy ? LEGACY_DETACHED_JOBS_DIR : DETACHED_JOBS_DIR, id);
1878
1842
  }
1879
- function realpathOrResolve(path) {
1880
- try {
1881
- return realpathSync(path);
1882
- } catch {
1883
- return resolve(path);
1884
- }
1843
+ function resolveJobDir(root, id) {
1844
+ const modern = jobDir(root, id, false);
1845
+ if (existsSync3(modern)) return modern;
1846
+ const legacy = jobDir(root, id, true);
1847
+ if (existsSync3(legacy)) return legacy;
1848
+ return modern;
1885
1849
  }
1886
- function isOtherWorktreeListenerCwd(cwd, worktreePath) {
1887
- const wt = realpathOrResolve(worktreePath);
1888
- const c = realpathOrResolve(cwd);
1889
- if (c === wt || c.startsWith(wt + sep)) return false;
1890
- const parent = dirname3(wt);
1891
- const thisName = basename(wt);
1892
- if (!thisName || c === parent) return false;
1893
- if (!c.startsWith(parent + sep)) return false;
1894
- const first = c.slice(parent.length + 1).split(sep)[0];
1895
- return Boolean(first) && first !== thisName;
1850
+ function listJobIdsIn(root, rel) {
1851
+ const dir = join5(root, rel);
1852
+ if (!existsSync3(dir)) return [];
1853
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && JOB_ID_RE.test(e.name)).map((e) => e.name);
1896
1854
  }
1897
- function signalPid(pid, signal) {
1898
- try {
1899
- process.kill(pid, signal);
1900
- } catch {
1855
+ function listRunningDetachedJobs(worktreePath) {
1856
+ const root = worktreePath.trim();
1857
+ if (!root) return [];
1858
+ const ids = /* @__PURE__ */ new Set([
1859
+ ...listJobIdsIn(root, DETACHED_JOBS_DIR),
1860
+ ...listJobIdsIn(root, LEGACY_DETACHED_JOBS_DIR)
1861
+ ]);
1862
+ const running = [];
1863
+ for (const id of ids) {
1864
+ const dir = resolveJobDir(root, id);
1865
+ const pid = readIntFile(join5(dir, "pid"));
1866
+ if (jobTreeAlive(pid)) running.push(id);
1901
1867
  }
1868
+ return running.sort();
1902
1869
  }
1903
- function killListenersOnPorts(ports) {
1904
- const selfPid = process.pid;
1905
- for (const port of ports) {
1906
- if (!Number.isFinite(port) || port <= 0) continue;
1907
- try {
1908
- if (process.platform === "win32") {
1909
- execFileSync(
1910
- "powershell",
1911
- [
1912
- "-NoProfile",
1913
- "-Command",
1914
- `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne ${selfPid} } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`
1915
- ],
1916
- { stdio: "ignore", timeout: 3e3 }
1917
- );
1918
- continue;
1919
- }
1920
- for (const pid of listUnixListenerPids(port, selfPid)) {
1921
- signalPid(pid, "SIGTERM");
1922
- }
1923
- } catch {
1924
- }
1925
- }
1870
+ function looksLikeDeferredDonePromise(text) {
1871
+ const t = (text ?? "").trim();
1872
+ if (!t || t.length > DEFERRED_DONE_MAX_CHARS) return false;
1873
+ const unquoted = t.replace(/`[^`]*`/g, " ").replace(/["“”][^"“”]*["“”]/g, " ").replace(/\s+/g, " ").trim();
1874
+ if (!unquoted) return false;
1875
+ return /\b(i['’]?ll|i will)\s+let you know\b/i.test(unquoted) || /\blet you know when\b/i.test(unquoted) || /\b(check back|ping me)\s+when\b/i.test(unquoted) || /\bi(?:['’]ll| will)\s+(report|update you)\s+when\b/i.test(unquoted);
1926
1876
  }
1927
- function killStaleWorktreeListeners(ports, worktreePath, signal = "SIGTERM") {
1928
- if (!worktreePath.trim()) {
1929
- killListenersOnPorts(ports);
1930
- return;
1931
- }
1932
- if (process.platform === "win32") {
1933
- killListenersOnPorts(ports);
1934
- return;
1935
- }
1936
- const selfPid = process.pid;
1937
- for (const port of ports) {
1938
- if (!Number.isFinite(port) || port <= 0) continue;
1939
- for (const pid of listUnixListenerPids(port, selfPid)) {
1940
- const cwd = listenerCwd(pid);
1941
- if (cwd && isOtherWorktreeListenerCwd(cwd, worktreePath)) continue;
1942
- signalPid(pid, signal);
1943
- }
1944
- }
1877
+ function isJobContinuePrompt(text) {
1878
+ if (typeof text !== "string") return false;
1879
+ const t = text.trim();
1880
+ return t.startsWith("Detached job still running:") || t.startsWith("You ended the turn after promising to report later");
1945
1881
  }
1946
- function killScriptTree(child, ports = []) {
1947
- const pid = child.pid;
1948
- if (pid) {
1949
- try {
1950
- if (process.platform === "win32") {
1951
- void execa2("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
1952
- } else {
1953
- process.kill(-pid, "SIGTERM");
1954
- setTimeout(() => {
1955
- try {
1956
- process.kill(-pid, "SIGKILL");
1957
- } catch {
1958
- }
1959
- }, 2500).unref?.();
1960
- }
1961
- } catch {
1962
- try {
1963
- child.kill("SIGTERM");
1964
- } catch {
1965
- }
1966
- }
1967
- }
1968
- killListenersOnPorts(ports);
1882
+ function collapseGhRunWatchLog(text) {
1883
+ if (!text.includes(GH_WATCH_REFRESH)) return text;
1884
+ const frames = text.split(/(?=Refreshing run status every 3 seconds\.)/);
1885
+ const watchFrames = frames.filter((f) => f.includes(GH_WATCH_REFRESH));
1886
+ if (watchFrames.length <= 1) return text;
1887
+ const prefix = frames[0]?.includes(GH_WATCH_REFRESH) ? "" : frames[0] ?? "";
1888
+ const last = watchFrames[watchFrames.length - 1] ?? "";
1889
+ const omitted = watchFrames.length - 1;
1890
+ const note = `(${omitted} identical gh run watch refresh${omitted === 1 ? "" : "es"} omitted)
1891
+ `;
1892
+ return [prefix.trimEnd(), note, last.trimStart()].filter(Boolean).join("\n");
1969
1893
  }
1970
- async function spawnWorkspaceScript(command, opts) {
1971
- const loginEnv = await captureLoginEnv();
1972
- const env = buildWorkspaceScriptEnv(
1973
- {
1974
- worktreePath: opts.worktreePath,
1975
- repoPath: opts.repoPath,
1976
- workspaceName: opts.workspaceName,
1977
- defaultBranch: opts.defaultBranch,
1978
- ports: opts.ports
1979
- },
1980
- loginEnv
1981
- );
1982
- try {
1983
- mergeAgentGitAuthEnv(
1984
- env,
1985
- await resolveAgentGitAuthEnv(env, { cwd: opts.worktreePath })
1986
- );
1987
- } catch {
1988
- }
1989
- if (opts.heldPorts?.length) {
1990
- await Promise.all(opts.heldPorts.map((h) => h.release()));
1894
+ function capJobLogDelta(text) {
1895
+ const collapsed = collapseGhRunWatchLog(text);
1896
+ const lines = collapsed.split("\n");
1897
+ let out = lines.length > MAX_JOB_DELTA_LINES ? `\u2026(${lines.length - MAX_JOB_DELTA_LINES} earlier lines omitted)
1898
+ ${lines.slice(-MAX_JOB_DELTA_LINES).join("\n")}` : collapsed;
1899
+ if (out.length > MAX_JOB_DELTA_CHARS) {
1900
+ out = `\u2026(truncated ${out.length} chars)
1901
+ ${out.slice(-MAX_JOB_DELTA_CHARS)}`;
1991
1902
  }
1992
- const shell = process.platform === "darwin" ? "zsh" : "bash";
1993
- const child = execa2(shell, ["-lc", command], {
1994
- cwd: opts.worktreePath,
1995
- reject: false,
1996
- env,
1997
- // Own process group so Stop can tear down the whole tree (not just the shell).
1998
- // There is no settings.toml `stop=` / teardown hook for run scripts.
1999
- ...process.platform === "win32" ? {} : { detached: true }
2000
- });
2001
- pipeLines(child.stdout, opts.onLine);
2002
- pipeLines(child.stderr, opts.onLine);
2003
- const ports = opts.ports ?? [];
2004
- return {
2005
- pid: child.pid,
2006
- kill: () => killScriptTree(child, ports),
2007
- done: child.then((r) => r.exitCode ?? null),
2008
- child
2009
- };
1903
+ return out;
2010
1904
  }
2011
- async function attachAbort(handle, signal) {
2012
- if (signal) {
2013
- const onAbort = () => handle.kill();
2014
- if (signal.aborted) onAbort();
2015
- else signal.addEventListener("abort", onAbort, { once: true });
2016
- }
2017
- return handle.done;
1905
+ function formatJobStillRunningContinuePrompt(jobIds) {
1906
+ const ids = jobIds.join(", ");
1907
+ return [
1908
+ `Detached job still running: ${ids}.`,
1909
+ "Do not end this turn. Loop wait_for_job (same id) until stillRunning is false. The log pane updates from each wait result.",
1910
+ "If the job is hanging or doing the wrong thing, call stop_job (same id) instead of looping forever.",
1911
+ "Then report the result. Do not tell the user you will let them know later."
1912
+ ].join(" ");
2018
1913
  }
2019
- async function runSetupScript(repoPath, worktreePath, onLine, opts) {
2020
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2021
- if (!settings?.setup) return { ran: false, exitCode: null, source: null };
2022
- const source = workspaceSettingsSourceLabel(worktreePath, repoPath);
2023
- onLine?.(`[setup] ${source ?? "settings.toml"}`);
2024
- const handle = await spawnWorkspaceScript(settings.setup, {
2025
- worktreePath,
2026
- repoPath,
2027
- defaultBranch: opts?.defaultBranch,
2028
- onLine
2029
- });
2030
- const exitCode = await attachAbort(handle, opts?.signal);
2031
- return {
2032
- ran: true,
2033
- exitCode,
2034
- source,
2035
- kill: handle.kill
2036
- };
1914
+ function formatDeferredDoneContinuePrompt() {
1915
+ return [
1916
+ "You ended the turn after promising to report later, but no detached job is running.",
1917
+ "If tests/pack/deploy still need to run: start once with detached-job.cjs, then loop wait_for_job until stillRunning is false (the log pane updates from wait JSON).",
1918
+ "Do not say you will let the user know later."
1919
+ ].join(" ");
2037
1920
  }
2038
- async function runConventionSetup(repoPath, worktreePath, onLine, opts) {
2039
- const found = findConventionSetup(worktreePath, repoPath);
2040
- if (!found) return { ran: false, exitCode: null, source: null };
2041
- onLine?.(`[setup] ${found.source}`);
2042
- const handle = await spawnWorkspaceScript(found.command, {
2043
- worktreePath,
2044
- repoPath,
2045
- defaultBranch: opts?.defaultBranch,
2046
- onLine
1921
+ function turnWatchedDetachedJob(parts) {
1922
+ return parts.some((p) => {
1923
+ if (p.type !== "tool") return false;
1924
+ if (/wait_for_job$/i.test(p.name ?? "") || /stop_job$/i.test(p.name ?? "")) return true;
1925
+ const blob = [p.name, p.detail, p.description, p.input ? JSON.stringify(p.input) : ""].filter(Boolean).join(" ");
1926
+ return /detached-job\.(?:js|cjs)\b/i.test(blob);
2047
1927
  });
2048
- const exitCode = await attachAbort(handle, opts?.signal);
2049
- return {
2050
- ran: true,
2051
- exitCode,
2052
- source: found.source,
2053
- kill: handle.kill
2054
- };
2055
1928
  }
2056
- async function runWorkspaceSetup(repoPath, worktreePath, onLine, opts) {
2057
- await ensureWorktreeSideboardIgnored(worktreePath);
2058
- const review = ensureReviewGuidelinesFile(worktreePath, repoPath);
2059
- if (review.wrote) {
2060
- onLine?.(`[setup] wrote ${review.path} \u2014 local Review guidelines`);
2061
- }
2062
- let setup = await runSetupScript(repoPath, worktreePath, onLine, opts);
2063
- if (!setup.ran) {
2064
- setup = await runCursorWorktreeSetup(repoPath, worktreePath, onLine);
1929
+ function planJobContinue(opts) {
1930
+ if (opts.isOrchestrator) return { action: "none" };
1931
+ if (opts.agent === "brightsy") return { action: "none" };
1932
+ if (opts.queueLength > 0) return { action: "none" };
1933
+ if (opts.continueCount >= MAX_JOB_CONTINUES) return { action: "none" };
1934
+ const farewell = looksLikeDeferredDonePromise(opts.chatText);
1935
+ if (opts.runningJobIds.length > 0 && (farewell || opts.watchedJob)) {
1936
+ return {
1937
+ action: "wait",
1938
+ jobIds: opts.runningJobIds,
1939
+ prompt: formatJobStillRunningContinuePrompt(opts.runningJobIds)
1940
+ };
2065
1941
  }
2066
- if (!setup.ran) {
2067
- setup = await runConventionSetup(repoPath, worktreePath, onLine, opts);
1942
+ if (looksLikeDeferredDonePromise(opts.chatText) && !opts.alreadyNudged) {
1943
+ return { action: "nudge", prompt: formatDeferredDoneContinuePrompt() };
2068
1944
  }
2069
- return setup;
2070
- }
2071
- async function runArchiveScript(repoPath, worktreePath, onLine) {
2072
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2073
- if (!settings?.archive) return { ran: false, exitCode: null };
2074
- const handle = await spawnWorkspaceScript(settings.archive, {
2075
- worktreePath,
2076
- repoPath,
2077
- onLine
2078
- });
2079
- const exitCode = await handle.done;
2080
- return { ran: true, exitCode };
1945
+ return { action: "none" };
2081
1946
  }
2082
- function listRunScripts(worktreePath, repoPath) {
2083
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2084
- if (!settings?.runScripts.length) return [];
2085
- return settings.runScripts.filter((s) => {
2086
- if (!s.availableIn?.length) return true;
2087
- return s.availableIn.includes("local");
2088
- });
1947
+ function tailProgress(logFile, maxLines = 12) {
1948
+ if (!existsSync3(logFile)) return "(no log yet)";
1949
+ const lines = readFileSync3(logFile, "utf8").split("\n");
1950
+ return lines.slice(-maxLines).join("\n");
2089
1951
  }
2090
- function getDefaultRunScript(worktreePath, repoPath) {
2091
- const scripts = listRunScripts(worktreePath, repoPath);
2092
- if (!scripts.length) return null;
2093
- return scripts.find((s) => s.default === true) ?? scripts.find((s) => s.name === "dev") ?? scripts.find((s) => s.name !== "all") ?? scripts[0] ?? null;
1952
+ function lastProgressLine(progress) {
1953
+ const last = progress.split("\n").map((l) => l.trim()).filter((l) => l && l !== "(no log yet)").at(-1);
1954
+ return last ? last.slice(0, 72) : void 0;
2094
1955
  }
2095
- function getRunScript(worktreePath, repoPath, name) {
2096
- const scripts = listRunScripts(worktreePath, repoPath);
2097
- if (!scripts.length) return null;
2098
- if (name) {
2099
- return scripts.find((s) => s.name === name) ?? null;
2100
- }
2101
- return getDefaultRunScript(worktreePath, repoPath);
1956
+ function readLogLines(file) {
1957
+ if (!existsSync3(file)) return [];
1958
+ const lines = readFileSync3(file, "utf8").split("\n");
1959
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
1960
+ return lines;
2102
1961
  }
2103
- function getRunMode(worktreePath, repoPath) {
2104
- return loadWorkspaceSettings(worktreePath, repoPath)?.runMode ?? "concurrent";
2105
- }
2106
- async function reservePort() {
2107
- return new Promise((resolve2, reject) => {
2108
- const server = createServer();
2109
- let released = false;
2110
- const release = () => new Promise((res) => {
2111
- if (released) {
2112
- res();
2113
- return;
2114
- }
2115
- released = true;
2116
- server.close(() => res());
2117
- });
2118
- server.listen(0, "127.0.0.1", () => {
2119
- const addr = server.address();
2120
- if (!addr || typeof addr === "string") {
2121
- void release().then(() => reject(new Error("Failed to allocate port")));
2122
- return;
2123
- }
2124
- resolve2({ port: addr.port, release });
2125
- });
2126
- server.on("error", reject);
2127
- });
1962
+ function takeDelta(logFile, cursorFile) {
1963
+ const lines = readLogLines(logFile);
1964
+ const cursor = readIntFile(cursorFile) ?? 0;
1965
+ const start = Math.min(Math.max(0, cursor), lines.length);
1966
+ return { delta: capJobLogDelta(lines.slice(start).join("\n")), nextCursor: lines.length };
2128
1967
  }
2129
- async function tryReservePort(port) {
2130
- if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
2131
- return new Promise((resolve2) => {
2132
- const server = createServer();
2133
- let released = false;
2134
- const release = () => new Promise((res) => {
2135
- if (released) {
2136
- res();
2137
- return;
2138
- }
2139
- released = true;
2140
- server.close(() => res());
2141
- });
2142
- server.once("error", () => {
2143
- try {
2144
- server.close();
2145
- } catch {
2146
- }
2147
- resolve2(null);
2148
- });
2149
- server.listen(port, "127.0.0.1", () => {
2150
- resolve2({ port, release });
2151
- });
2152
- });
1968
+ function snapshotJob(dir) {
1969
+ const pid = readIntFile(join5(dir, "pid"));
1970
+ const running = jobTreeAlive(pid);
1971
+ return {
1972
+ pid,
1973
+ running,
1974
+ exitCode: readIntFile(join5(dir, "exit")),
1975
+ log: join5(dir, "log"),
1976
+ cursor: join5(dir, "present.cursor"),
1977
+ progress: tailProgress(join5(dir, "log"))
1978
+ };
2153
1979
  }
2154
- function normalizePreferredPorts(ports, size) {
2155
- if (!ports?.length) return [];
2156
- const seen = /* @__PURE__ */ new Set();
2157
- const out = [];
2158
- for (const p of ports) {
2159
- if (!Number.isInteger(p) || p <= 0 || p > 65535 || seen.has(p)) continue;
2160
- seen.add(p);
2161
- out.push(p);
2162
- if (out.length >= size) break;
1980
+ function toResult(id, snap, extra) {
1981
+ const failed = extra?.failed === true || !snap.running && snap.exitCode != null && snap.exitCode !== 0;
1982
+ const ok = !snap.running && snap.exitCode === 0;
1983
+ const stillRunning = snap.running && !ok;
1984
+ const { delta, nextCursor } = takeDelta(snap.log, snap.cursor);
1985
+ try {
1986
+ mkdirSync4(dirname3(snap.cursor), { recursive: true });
1987
+ writeFileSync3(snap.cursor, `${nextCursor}
1988
+ `);
1989
+ } catch {
2163
1990
  }
2164
- return out;
1991
+ const status = ok ? "ok" : failed && !stillRunning ? "failed" : stillRunning ? "running" : "idle";
1992
+ const progress = extra?.progress ?? snap.progress;
1993
+ return {
1994
+ stillRunning,
1995
+ ok,
1996
+ failed: Boolean(failed && !stillRunning && !ok),
1997
+ status,
1998
+ id,
1999
+ pid: snap.pid,
2000
+ exitCode: snap.exitCode ?? void 0,
2001
+ phase: lastProgressLine(progress),
2002
+ delta,
2003
+ progress,
2004
+ hint: stillRunning ? MCP_WAIT_JOB_STILL_RUNNING_HINT : void 0
2005
+ };
2165
2006
  }
2166
- function delay(ms) {
2167
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2007
+ async function sleepMs(ms) {
2008
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
2168
2009
  }
2169
- async function fillPortRange(held, size) {
2170
- if (held.length >= size) return held;
2171
- const base = held[0]?.port;
2172
- const taken = new Set(held.map((h) => h.port));
2173
- try {
2174
- let offset = 0;
2175
- while (held.length < size) {
2176
- const candidate = base != null ? base + offset : null;
2177
- offset += 1;
2178
- if (candidate != null && taken.has(candidate)) continue;
2179
- const next = candidate != null ? await tryReservePort(candidate) : null;
2180
- if (next) {
2181
- held.push(next);
2182
- taken.add(next.port);
2183
- } else {
2184
- const ephemeral = await reservePort();
2185
- held.push(ephemeral);
2186
- taken.add(ephemeral.port);
2187
- }
2188
- }
2189
- return held;
2190
- } catch (err) {
2191
- await Promise.all(held.map((h) => h.release()));
2192
- throw err;
2010
+ async function waitForDetachedJob(cwd, id, opts) {
2011
+ const jobId = sanitizeDetachedJobId(id);
2012
+ const root = cwd.trim() || process.cwd();
2013
+ const dir = resolveJobDir(root, jobId);
2014
+ const timeoutMs = mcpWaitForJobTimeoutMs(opts?.timeoutMs);
2015
+ const sleep = opts?.sleep ?? sleepMs;
2016
+ if (!existsSync3(dir)) {
2017
+ return {
2018
+ stillRunning: false,
2019
+ ok: false,
2020
+ failed: true,
2021
+ status: "failed",
2022
+ id: jobId,
2023
+ delta: "",
2024
+ progress: "No detached job. Start one first.",
2025
+ hint: "Start with detached-job.cjs start <id> -- <command>, then call wait_for_job again."
2026
+ };
2193
2027
  }
2194
- }
2195
- async function tryReserveAvailablePorts(ports) {
2196
- const held = [];
2197
- for (const port of ports) {
2198
- const next = await tryReservePort(port);
2199
- if (next) held.push(next);
2028
+ const deadline = Date.now() + timeoutMs;
2029
+ let snap = snapshotJob(dir);
2030
+ if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
2031
+ if (!snap.running && snap.pid == null && snap.progress === "(no log yet)") {
2032
+ return toResult(jobId, snap, {
2033
+ failed: true,
2034
+ progress: "No detached job. Start one first."
2035
+ });
2200
2036
  }
2201
- return held;
2202
- }
2203
- async function reclaimPreferredPorts(ports, worktreePath) {
2204
- const primary = ports[0];
2205
- if (primary == null) return null;
2206
- const takePrimary = async () => {
2207
- const held2 = await tryReserveAvailablePorts(ports);
2208
- if (held2.some((h) => h.port === primary)) return held2;
2209
- await Promise.all(held2.map((h) => h.release()));
2210
- return null;
2211
- };
2212
- let held = await takePrimary();
2213
- if (held) return held;
2214
- if (worktreePath) {
2215
- killStaleWorktreeListeners(ports, worktreePath, "SIGTERM");
2216
- } else {
2217
- killListenersOnPorts(ports);
2037
+ while (Date.now() < deadline) {
2038
+ snap = snapshotJob(dir);
2039
+ if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
2040
+ if (!snap.running) return toResult(jobId, snap, { failed: true });
2041
+ await sleep(Math.min(2e3, Math.max(50, deadline - Date.now())));
2218
2042
  }
2219
- for (let i = 0; i < RECLAIM_TRIES; i++) {
2220
- await delay(RECLAIM_RETRY_DELAY_MS);
2221
- held = await takePrimary();
2222
- if (held) return held;
2223
- if (i === 2 && worktreePath) {
2224
- killStaleWorktreeListeners(ports, worktreePath, "SIGKILL");
2043
+ snap = snapshotJob(dir);
2044
+ return toResult(jobId, snap);
2045
+ }
2046
+ function killProcessTree(pid, signal) {
2047
+ if (!Number.isInteger(pid) || pid <= 0) return;
2048
+ if (process.platform !== "win32") {
2049
+ try {
2050
+ process.kill(-pid, signal);
2051
+ return;
2052
+ } catch {
2225
2053
  }
2226
2054
  }
2227
- return null;
2228
- }
2229
- async function reservePortRange(size = PORT_RANGE_SIZE, opts) {
2230
- const preferred = normalizePreferredPorts(opts?.preferred, size);
2231
- if (preferred.length) {
2232
- const reused = await reclaimPreferredPorts(preferred, opts?.worktreePath);
2233
- if (reused) return fillPortRange(reused, size);
2055
+ try {
2056
+ process.kill(pid, signal);
2057
+ } catch {
2234
2058
  }
2235
- const held = [await reservePort()];
2236
- return fillPortRange(held, size);
2237
2059
  }
2238
- async function startDevServer(repoPath, worktreePath, onLine, opts) {
2239
- const script = getRunScript(worktreePath, repoPath, opts?.scriptName);
2240
- if (!script) return null;
2241
- const preferred = loadWorktreeRunPorts(worktreePath, script.name) ?? opts?.preferredPorts;
2242
- const held = await reservePortRange(PORT_RANGE_SIZE, {
2243
- preferred: preferred ?? void 0,
2244
- worktreePath
2245
- });
2246
- const ports = held.map((h) => h.port);
2247
- saveWorktreeRunPorts(worktreePath, script.name, ports);
2060
+ function appendJobLog(logFile, line) {
2248
2061
  try {
2249
- const handle = await spawnWorkspaceScript(script.command, {
2250
- worktreePath,
2251
- repoPath,
2252
- ports,
2253
- heldPorts: held,
2254
- defaultBranch: opts?.defaultBranch,
2255
- onLine
2256
- });
2062
+ mkdirSync4(dirname3(logFile), { recursive: true });
2063
+ const body = line.endsWith("\n") ? line : `${line}
2064
+ `;
2065
+ appendFileSync2(logFile, body);
2066
+ } catch {
2067
+ }
2068
+ }
2069
+ async function stopDetachedJob(cwd, id, opts) {
2070
+ const jobId = sanitizeDetachedJobId(id);
2071
+ const root = cwd.trim() || process.cwd();
2072
+ const dir = resolveJobDir(root, jobId);
2073
+ const sleep = opts?.sleep ?? sleepMs;
2074
+ const why = opts?.reason?.trim() ?? "";
2075
+ if (!existsSync3(dir)) {
2257
2076
  return {
2258
- pid: handle.pid,
2259
- port: ports[0],
2260
- ports,
2261
- scriptName: script.name,
2262
- kill: handle.kill,
2263
- done: handle.done
2077
+ stillRunning: false,
2078
+ ok: false,
2079
+ failed: true,
2080
+ status: "failed",
2081
+ id: jobId,
2082
+ delta: "",
2083
+ progress: "No detached job. Start one first.",
2084
+ hint: "Start with detached-job.cjs start <id> -- <command>, then wait_for_job or stop_job.",
2085
+ stopped: false,
2086
+ reason: "not-found"
2264
2087
  };
2265
- } catch (err) {
2266
- await Promise.all(held.map((h) => h.release()));
2267
- throw err;
2268
2088
  }
2269
- }
2270
-
2271
- // src/store/setup-log.ts
2272
- import { createHash } from "crypto";
2273
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2274
- import { join as join6 } from "path";
2275
- var EMPTY = {
2276
- output: "",
2277
- running: false,
2278
- exitCode: null,
2279
- source: null
2280
- };
2281
- var MAX_SETUP_LOG_CHARS = 256e3;
2282
- function appendSetupOutput(prev, line, max = MAX_SETUP_LOG_CHARS) {
2283
- const next = prev ? `${prev}
2284
- ${line}` : line;
2285
- if (next.length <= max) return next;
2286
- const cut = next.slice(next.length - max);
2287
- const nl = cut.indexOf("\n");
2288
- return nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2289
- }
2290
- var memory = /* @__PURE__ */ new Map();
2291
- var persistTimers = /* @__PURE__ */ new Map();
2292
- function fromSnapshot(snap) {
2293
- return {
2294
- chunks: snap.output ? [snap.output] : [],
2295
- length: snap.output.length,
2296
- running: snap.running,
2297
- exitCode: snap.exitCode,
2298
- source: snap.source
2299
- };
2300
- }
2301
- function materialize(mem) {
2302
- let output = mem.chunks.join("\n");
2303
- if (output.length > MAX_SETUP_LOG_CHARS) {
2304
- output = appendSetupOutput("", output);
2305
- mem.chunks = [output];
2306
- mem.length = output.length;
2089
+ let snap = snapshotJob(dir);
2090
+ if (!snap.running) {
2091
+ const result2 = toResult(jobId, snap);
2092
+ return {
2093
+ ...result2,
2094
+ stopped: false,
2095
+ reason: "not-running",
2096
+ hint: "That job is already finished. Read the log; do not wait again unless you start a new command."
2097
+ };
2098
+ }
2099
+ appendJobLog(snap.log, `$ stop${why ? `: ${why}` : ""}`);
2100
+ const targetPid = snap.pid;
2101
+ if (targetPid != null) killProcessTree(targetPid, "SIGTERM");
2102
+ const graceMs = Number.isFinite(opts?.graceMs) ? Math.max(50, opts.graceMs) : 2e3;
2103
+ const deadline = Date.now() + graceMs;
2104
+ while (Date.now() < deadline && jobTreeAlive(targetPid)) {
2105
+ await sleep(Math.min(100, Math.max(20, deadline - Date.now())));
2307
2106
  }
2107
+ if (targetPid != null) {
2108
+ killProcessTree(targetPid, "SIGKILL");
2109
+ await sleep(100);
2110
+ }
2111
+ const exitFile = join5(dir, "exit");
2112
+ if (!existsSync3(exitFile)) {
2113
+ try {
2114
+ writeFileSync3(exitFile, "1\n");
2115
+ } catch {
2116
+ }
2117
+ }
2118
+ snap = snapshotJob(dir);
2119
+ const stillRunning = jobTreeAlive(targetPid) || snap.running;
2120
+ const result = toResult(jobId, snap, { failed: true });
2121
+ const stopped = !stillRunning;
2308
2122
  return {
2309
- output,
2310
- running: mem.running,
2311
- exitCode: mem.exitCode,
2312
- source: mem.source
2123
+ ...result,
2124
+ stillRunning,
2125
+ ok: false,
2126
+ failed: true,
2127
+ status: stillRunning ? "running" : "failed",
2128
+ stopped,
2129
+ reason: stopped ? "stopped" : "still-running",
2130
+ stopReason: why || void 0,
2131
+ hint: stopped ? MCP_STOP_JOB_HINT : MCP_WAIT_JOB_STILL_RUNNING_HINT
2313
2132
  };
2314
2133
  }
2315
- function setupLogPath(threadId) {
2316
- return join6(threadsDir(), `${threadId}.setup.log.json`);
2317
- }
2318
- function setupLogKeyForWorktree(worktreePath) {
2319
- const norm = normalizeWorktreePath(worktreePath);
2320
- return `wt-${createHash("sha1").update(norm).digest("hex").slice(0, 16)}`;
2134
+
2135
+ // src/hook/conductor.ts
2136
+ var PORT_RANGE_SIZE = 10;
2137
+ var RECLAIM_RETRY_DELAY_MS = 40;
2138
+ var RECLAIM_TRIES = 8;
2139
+ function matchSimpleGlob(pattern, name) {
2140
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
2141
+ return new RegExp(`^${escaped}$`).test(name);
2321
2142
  }
2322
- function emptySetupLog() {
2323
- return { ...EMPTY };
2143
+ function readWorktreeInclude(repoPath) {
2144
+ const path = join6(repoPath, ".worktreeinclude");
2145
+ if (!existsSync4(path)) return [];
2146
+ return readFileSync4(path, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
2324
2147
  }
2325
- var PERSIST_DEBOUNCE_MS = 500;
2326
- function persistNow(threadId) {
2327
- const timer = persistTimers.get(threadId);
2328
- if (timer) {
2329
- clearTimeout(timer);
2330
- persistTimers.delete(threadId);
2148
+ function resolveFilesToCopy(repoPath) {
2149
+ const fromInclude = readWorktreeInclude(repoPath);
2150
+ if (fromInclude.length) return fromInclude;
2151
+ const settings = loadRepoSettings(repoPath);
2152
+ if (settings?.filesToCopy?.length) return settings.filesToCopy;
2153
+ if (settings?.fileIncludeGlobs?.length) {
2154
+ const matched = [];
2155
+ try {
2156
+ for (const entry of readdirSync2(repoPath, { withFileTypes: true })) {
2157
+ if (!entry.isFile()) continue;
2158
+ for (const glob of settings.fileIncludeGlobs) {
2159
+ if (matchSimpleGlob(glob, entry.name) || matchSimpleGlob(basename(glob), entry.name)) {
2160
+ matched.push(entry.name);
2161
+ break;
2162
+ }
2163
+ }
2164
+ }
2165
+ } catch {
2166
+ }
2167
+ if (matched.length) return [...new Set(matched)];
2331
2168
  }
2332
- const mem = memory.get(threadId);
2333
- if (!mem) return;
2169
+ const defaults = [];
2334
2170
  try {
2335
- writeFileSync3(
2336
- setupLogPath(threadId),
2337
- `${JSON.stringify(materialize(mem))}
2338
- `,
2339
- "utf8"
2340
- );
2171
+ for (const entry of readdirSync2(repoPath, { withFileTypes: true })) {
2172
+ if (entry.isFile() && entry.name.startsWith(".env")) {
2173
+ defaults.push(entry.name);
2174
+ }
2175
+ }
2341
2176
  } catch {
2342
2177
  }
2178
+ if (defaults.length) return defaults;
2179
+ return [".env.local", ".env"];
2343
2180
  }
2344
- function schedulePersist(threadId) {
2345
- if (persistTimers.has(threadId)) return;
2346
- persistTimers.set(
2347
- threadId,
2348
- setTimeout(() => {
2349
- persistTimers.delete(threadId);
2350
- persistNow(threadId);
2351
- }, PERSIST_DEBOUNCE_MS)
2352
- );
2181
+ function copyConfiguredFiles(repoPath, worktreePath) {
2182
+ const patterns = resolveFilesToCopy(repoPath);
2183
+ const copied = [];
2184
+ for (const rel of patterns) {
2185
+ const src = join6(repoPath, rel);
2186
+ if (!existsSync4(src)) continue;
2187
+ const dest = join6(worktreePath, rel);
2188
+ mkdirSync5(dirname4(dest), { recursive: true });
2189
+ copyFileSync(src, dest);
2190
+ copied.push(rel);
2191
+ }
2192
+ return copied;
2353
2193
  }
2354
- function loadFromDisk(threadId) {
2355
- const path = setupLogPath(threadId);
2356
- if (!existsSync4(path)) return null;
2194
+ var cachedLoginEnv = null;
2195
+ async function captureLoginEnv() {
2196
+ if (cachedLoginEnv) return { ...cachedLoginEnv };
2197
+ const shell = process.env.SHELL?.trim() || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
2357
2198
  try {
2358
- const raw = JSON.parse(readFileSync4(path, "utf8"));
2359
- return {
2360
- output: typeof raw.output === "string" ? raw.output : "",
2361
- running: Boolean(raw.running),
2362
- exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2363
- source: typeof raw.source === "string" ? raw.source : null
2364
- };
2199
+ const result = await execa2(shell, ["-l", "-c", "env -0"], {
2200
+ reject: false,
2201
+ timeout: 5e3
2202
+ });
2203
+ if (result.exitCode === 0 && result.stdout) {
2204
+ const env = { ...process.env };
2205
+ for (const entry of result.stdout.split("\0")) {
2206
+ if (!entry) continue;
2207
+ const eq = entry.indexOf("=");
2208
+ if (eq <= 0) continue;
2209
+ const key = entry.slice(0, eq);
2210
+ const value = entry.slice(eq + 1);
2211
+ if (key) env[key] = value;
2212
+ }
2213
+ cachedLoginEnv = env;
2214
+ return { ...env };
2215
+ }
2365
2216
  } catch {
2366
- return null;
2367
2217
  }
2218
+ cachedLoginEnv = { ...process.env };
2219
+ return { ...cachedLoginEnv };
2368
2220
  }
2369
- function memFor(threadId) {
2370
- const cached = memory.get(threadId);
2371
- if (cached) return cached;
2372
- const mem = fromSnapshot(loadFromDisk(threadId) ?? emptySetupLog());
2373
- memory.set(threadId, mem);
2374
- return mem;
2375
- }
2376
- function readSetupLog(threadId) {
2377
- const cached = memory.get(threadId);
2378
- if (cached) return materialize(cached);
2379
- const disk = loadFromDisk(threadId);
2380
- if (disk) {
2381
- memory.set(threadId, fromSnapshot(disk));
2382
- return disk;
2221
+ function buildWorkspaceScriptEnv(opts, baseEnv) {
2222
+ const env = stripNestedElectronEnv({
2223
+ ...baseEnv ?? process.env
2224
+ });
2225
+ const name = opts.workspaceName ?? basename(opts.worktreePath);
2226
+ const ports = opts.ports ?? [];
2227
+ const primary = ports[0];
2228
+ env.SIDEBOARD_WORKSPACE_NAME = name;
2229
+ env.SIDEBOARD_WORKSPACE_PATH = opts.worktreePath;
2230
+ env.SIDEBOARD_ROOT_PATH = opts.repoPath;
2231
+ env.SIDEBOARD_IS_LOCAL = "1";
2232
+ if (opts.defaultBranch) env.SIDEBOARD_DEFAULT_BRANCH = opts.defaultBranch;
2233
+ env.CONDUCTOR_WORKSPACE_NAME = name;
2234
+ env.CONDUCTOR_WORKSPACE_PATH = opts.worktreePath;
2235
+ env.CONDUCTOR_ROOT_PATH = opts.repoPath;
2236
+ env.CONDUCTOR_IS_LOCAL = "1";
2237
+ if (opts.defaultBranch) env.CONDUCTOR_DEFAULT_BRANCH = opts.defaultBranch;
2238
+ if (primary != null) {
2239
+ env.SIDEBOARD_PORT = String(primary);
2240
+ env.CONDUCTOR_PORT = String(primary);
2241
+ env.PORT = String(primary);
2242
+ for (let i = 1; i < ports.length; i++) {
2243
+ const p = ports[i];
2244
+ env[`SIDEBOARD_PORT_${i}`] = String(p);
2245
+ env[`CONDUCTOR_PORT_${i}`] = String(p);
2246
+ }
2383
2247
  }
2384
- return emptySetupLog();
2248
+ applyWorktreePkgCacheEnv(env, opts.worktreePath);
2249
+ return env;
2385
2250
  }
2386
- function beginSetupLog(threadId) {
2387
- const mem = {
2388
- chunks: [],
2389
- length: 0,
2390
- running: true,
2391
- exitCode: null,
2392
- source: null
2393
- };
2394
- memory.set(threadId, mem);
2395
- persistNow(threadId);
2396
- return materialize(mem);
2251
+ function pipeLines(stream, onLine) {
2252
+ if (!stream || !onLine) return;
2253
+ const rl = createInterface2({ input: stream });
2254
+ rl.on("line", onLine);
2397
2255
  }
2398
- function appendSetupLog(threadId, line) {
2399
- const mem = memFor(threadId);
2400
- mem.chunks.push(line);
2401
- mem.length += line.length + 1;
2402
- mem.running = true;
2403
- if (mem.length > MAX_SETUP_LOG_CHARS * 2) materialize(mem);
2404
- schedulePersist(threadId);
2256
+ function killableListenerPids(raw, selfPid = process.pid) {
2257
+ const pids = [];
2258
+ for (const token of raw.trim().split(/\s+/)) {
2259
+ const pid = Number(token);
2260
+ if (!Number.isFinite(pid) || pid <= 0 || pid === selfPid) continue;
2261
+ pids.push(pid);
2262
+ }
2263
+ return pids;
2405
2264
  }
2406
- function finishSetupLog(threadId, exitCode, source) {
2407
- const mem = memFor(threadId);
2408
- mem.running = false;
2409
- mem.exitCode = exitCode;
2410
- mem.source = source ?? mem.source;
2411
- persistNow(threadId);
2412
- return materialize(mem);
2413
- }
2414
-
2415
- // src/store/run-log.ts
2416
- import { createHash as createHash2 } from "crypto";
2417
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2418
- import { join as join7 } from "path";
2419
- var EMPTY2 = {
2420
- output: "",
2421
- running: false,
2422
- exitCode: null,
2423
- scriptName: null
2424
- };
2425
- var MAX_RUN_LOG_CHARS = 256e3;
2426
- var DEFAULT_RUN_LOG_TAIL_CHARS = 16e3;
2427
- var memory2 = /* @__PURE__ */ new Map();
2428
- var persistTimers2 = /* @__PURE__ */ new Map();
2429
- function fromSnapshot2(snap) {
2430
- return {
2431
- chunks: snap.output ? [snap.output] : [],
2432
- length: snap.output.length,
2433
- running: snap.running,
2434
- exitCode: snap.exitCode,
2435
- scriptName: snap.scriptName
2436
- };
2437
- }
2438
- function materialize2(mem) {
2439
- let output = mem.chunks.join("\n");
2440
- if (output.length > MAX_RUN_LOG_CHARS) {
2441
- output = appendSetupOutput("", output, MAX_RUN_LOG_CHARS);
2442
- mem.chunks = [output];
2443
- mem.length = output.length;
2265
+ function listUnixListenerPids(port, selfPid) {
2266
+ try {
2267
+ const raw = execFileSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
2268
+ encoding: "utf8",
2269
+ stdio: ["ignore", "pipe", "ignore"],
2270
+ timeout: 2e3
2271
+ });
2272
+ return killableListenerPids(raw, selfPid);
2273
+ } catch {
2274
+ return [];
2444
2275
  }
2445
- return {
2446
- output,
2447
- running: mem.running,
2448
- exitCode: mem.exitCode,
2449
- scriptName: mem.scriptName
2450
- };
2451
- }
2452
- function safeScriptToken(scriptName) {
2453
- const token = scriptName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
2454
- return token || "dev";
2455
- }
2456
- function runLogPath(key) {
2457
- return join7(threadsDir(), `${key}.run.log.json`);
2458
- }
2459
- function runLogKeyForWorktree(worktreePath, scriptName) {
2460
- const norm = normalizeWorktreePath(worktreePath);
2461
- const hash = createHash2("sha1").update(norm).digest("hex").slice(0, 16);
2462
- return `wt-${hash}-run-${safeScriptToken(scriptName)}`;
2463
- }
2464
- function emptyRunLog() {
2465
- return { ...EMPTY2 };
2466
- }
2467
- function sliceRunLogOutput(output, tailChars = DEFAULT_RUN_LOG_TAIL_CHARS) {
2468
- const max = Math.max(1, tailChars);
2469
- const outputChars = output.length;
2470
- if (outputChars <= max) return { output, truncated: false, outputChars };
2471
- const cut = output.slice(outputChars - max);
2472
- const nl = cut.indexOf("\n");
2473
- const text = nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2474
- return { output: text, truncated: true, outputChars };
2475
2276
  }
2476
- var PERSIST_DEBOUNCE_MS2 = 500;
2477
- function persistNow2(key) {
2478
- const timer = persistTimers2.get(key);
2479
- if (timer) {
2480
- clearTimeout(timer);
2481
- persistTimers2.delete(key);
2482
- }
2483
- const mem = memory2.get(key);
2484
- if (!mem) return;
2277
+ function listenerCwd(pid) {
2278
+ if (!Number.isFinite(pid) || pid <= 0) return null;
2485
2279
  try {
2486
- writeFileSync4(runLogPath(key), `${JSON.stringify(materialize2(mem))}
2487
- `, "utf8");
2280
+ const raw = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
2281
+ encoding: "utf8",
2282
+ stdio: ["ignore", "pipe", "ignore"],
2283
+ timeout: 2e3
2284
+ });
2285
+ for (const line of raw.split("\n")) {
2286
+ if (line.startsWith("n") && line.length > 1) return line.slice(1);
2287
+ }
2488
2288
  } catch {
2489
2289
  }
2290
+ return null;
2490
2291
  }
2491
- function schedulePersist2(key) {
2492
- if (persistTimers2.has(key)) return;
2493
- persistTimers2.set(
2494
- key,
2495
- setTimeout(() => {
2496
- persistTimers2.delete(key);
2497
- persistNow2(key);
2498
- }, PERSIST_DEBOUNCE_MS2)
2499
- );
2500
- }
2501
- function loadFromDisk2(key) {
2502
- const path = runLogPath(key);
2503
- if (!existsSync5(path)) return null;
2292
+ function realpathOrResolve(path) {
2504
2293
  try {
2505
- const raw = JSON.parse(readFileSync5(path, "utf8"));
2506
- return {
2507
- output: typeof raw.output === "string" ? raw.output : "",
2508
- running: Boolean(raw.running),
2509
- exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2510
- scriptName: typeof raw.scriptName === "string" ? raw.scriptName : null
2511
- };
2294
+ return realpathSync(path);
2512
2295
  } catch {
2513
- return null;
2296
+ return resolve(path);
2514
2297
  }
2515
2298
  }
2516
- function memFor2(key) {
2517
- const cached = memory2.get(key);
2518
- if (cached) return cached;
2519
- const mem = fromSnapshot2(loadFromDisk2(key) ?? emptyRunLog());
2520
- memory2.set(key, mem);
2521
- return mem;
2299
+ function isOtherWorktreeListenerCwd(cwd, worktreePath) {
2300
+ const wt = realpathOrResolve(worktreePath);
2301
+ const c = realpathOrResolve(cwd);
2302
+ if (c === wt || c.startsWith(wt + sep)) return false;
2303
+ const parent = dirname4(wt);
2304
+ const thisName = basename(wt);
2305
+ if (!thisName || c === parent) return false;
2306
+ if (!c.startsWith(parent + sep)) return false;
2307
+ const first = c.slice(parent.length + 1).split(sep)[0];
2308
+ return Boolean(first) && first !== thisName;
2522
2309
  }
2523
- function readRunLog(key) {
2524
- const cached = memory2.get(key);
2525
- if (cached) return materialize2(cached);
2526
- const disk = loadFromDisk2(key);
2527
- if (disk) {
2528
- memory2.set(key, fromSnapshot2(disk));
2529
- return disk;
2310
+ function signalPid(pid, signal) {
2311
+ try {
2312
+ process.kill(pid, signal);
2313
+ } catch {
2530
2314
  }
2531
- return emptyRunLog();
2532
- }
2533
- function beginRunLog(key, scriptName) {
2534
- const mem = {
2535
- chunks: [],
2536
- length: 0,
2537
- running: true,
2538
- exitCode: null,
2539
- scriptName
2540
- };
2541
- memory2.set(key, mem);
2542
- persistNow2(key);
2543
- return materialize2(mem);
2544
- }
2545
- function appendRunLog(key, line) {
2546
- const mem = memFor2(key);
2547
- mem.chunks.push(line);
2548
- mem.length += line.length + 1;
2549
- mem.running = true;
2550
- if (mem.length > MAX_RUN_LOG_CHARS * 2) materialize2(mem);
2551
- schedulePersist2(key);
2552
- }
2553
- function finishRunLog(key, exitCode) {
2554
- const mem = memFor2(key);
2555
- mem.running = false;
2556
- mem.exitCode = exitCode;
2557
- persistNow2(key);
2558
- return materialize2(mem);
2559
- }
2560
- function flushRunLog(key) {
2561
- persistNow2(key);
2562
- }
2563
-
2564
- // src/git/orphan-cleanup.ts
2565
- import { existsSync as existsSync6, readdirSync as readdirSync2, statSync } from "fs";
2566
- import { join as join8 } from "path";
2567
- function isSideboardWorktreePath(path) {
2568
- return path.includes("/.sideboard/worktrees/") || path.includes("/sideboard/workspaces/");
2569
2315
  }
2570
- async function findOrphanWorktrees(repoPaths) {
2571
- const threads = listThreads({ includeArchived: true });
2572
- const known = new Set(threads.map((t) => t.worktreePath.replace(/\/$/, "")));
2573
- const repos = new Set(
2574
- repoPaths?.length ? repoPaths : threads.map((t) => t.repoPath).filter(Boolean)
2575
- );
2576
- const homeRoot = sideboardWorkspacesDir();
2577
- if (existsSync6(homeRoot)) {
2578
- try {
2579
- for (const entry of readdirSync2(homeRoot, { withFileTypes: true })) {
2580
- if (!entry.isDirectory()) continue;
2581
- void entry;
2582
- }
2583
- } catch {
2584
- }
2316
+ function isThisWorktreeListenerCwd(cwd, worktreePath) {
2317
+ const wt = realpathOrResolve(worktreePath);
2318
+ const c = realpathOrResolve(cwd);
2319
+ return c === wt || c.startsWith(wt + sep);
2320
+ }
2321
+ function killDetachedRunTree(pid, worktreePath) {
2322
+ if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) return;
2323
+ const cwd = listenerCwd(pid);
2324
+ if (cwd) {
2325
+ if (!isThisWorktreeListenerCwd(cwd, worktreePath)) return;
2326
+ } else if (!processGroupAlive(pid)) {
2327
+ return;
2585
2328
  }
2586
- const orphans = [];
2587
- const seen = /* @__PURE__ */ new Set();
2588
- for (const repoPath of repos) {
2589
- if (!repoPath || !existsSync6(repoPath)) continue;
2329
+ try {
2330
+ process.kill(-pid, "SIGTERM");
2331
+ } catch {
2332
+ signalPid(pid, "SIGTERM");
2333
+ }
2334
+ }
2335
+ function killListenersOnPorts(ports) {
2336
+ const selfPid = process.pid;
2337
+ for (const port of ports) {
2338
+ if (!Number.isFinite(port) || port <= 0) continue;
2590
2339
  try {
2591
- const wts = await listWorktrees(repoPath);
2592
- for (const wt of wts) {
2593
- const path = wt.path.replace(/\/$/, "");
2594
- if (!isSideboardWorktreePath(path)) continue;
2595
- if (known.has(path)) continue;
2596
- if (seen.has(path)) continue;
2597
- seen.add(path);
2598
- let mtimeMs = 0;
2599
- try {
2600
- mtimeMs = statSync(path).mtimeMs;
2601
- } catch {
2602
- mtimeMs = 0;
2603
- }
2604
- orphans.push({ path, repoPath, mtimeMs });
2340
+ if (process.platform === "win32") {
2341
+ execFileSync(
2342
+ "powershell",
2343
+ [
2344
+ "-NoProfile",
2345
+ "-Command",
2346
+ `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne ${selfPid} } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`
2347
+ ],
2348
+ { stdio: "ignore", timeout: 3e3 }
2349
+ );
2350
+ continue;
2605
2351
  }
2606
- } catch {
2607
- }
2608
- try {
2609
- const root = worktreesRoot(repoPath);
2610
- if (existsSync6(root)) {
2611
- for (const entry of readdirSync2(root, { withFileTypes: true })) {
2612
- if (!entry.isDirectory()) continue;
2613
- const path = join8(root, entry.name).replace(/\/$/, "");
2614
- if (known.has(path) || seen.has(path)) continue;
2615
- if (!existsSync6(join8(path, ".git"))) continue;
2616
- seen.add(path);
2617
- let mtimeMs = 0;
2618
- try {
2619
- mtimeMs = statSync(path).mtimeMs;
2620
- } catch {
2621
- mtimeMs = Date.now();
2622
- }
2623
- orphans.push({ path, repoPath, mtimeMs });
2624
- }
2352
+ for (const pid of listUnixListenerPids(port, selfPid)) {
2353
+ signalPid(pid, "SIGTERM");
2625
2354
  }
2626
2355
  } catch {
2627
2356
  }
2628
2357
  }
2629
- return orphans.sort((a, b) => a.mtimeMs - b.mtimeMs);
2630
2358
  }
2631
- async function cleanupOrphanWorktrees(opts) {
2632
- const settings = loadAppSettings();
2633
- const maxCount = opts?.maxCount ?? settings.advanced.worktreeMaxCount ?? 25;
2634
- const orphans = await findOrphanWorktrees(opts?.repoPaths);
2635
- const activeCount = listThreads().filter((t) => t.status !== "archived").length;
2636
- const budget = Math.max(0, maxCount - activeCount);
2637
- const sortedNewestFirst = [...orphans].sort((a, b) => b.mtimeMs - a.mtimeMs);
2638
- const keep = new Set(sortedNewestFirst.slice(0, budget).map((o) => o.path));
2639
- const removed = [];
2640
- const kept = [];
2641
- for (const orphan of orphans) {
2642
- if (keep.has(orphan.path)) {
2643
- kept.push(orphan.path);
2644
- continue;
2645
- }
2646
- if (opts?.dryRun) {
2647
- removed.push(orphan.path);
2648
- continue;
2359
+ function killStaleWorktreeListeners(ports, worktreePath, signal = "SIGTERM") {
2360
+ if (!worktreePath.trim()) {
2361
+ killListenersOnPorts(ports);
2362
+ return;
2363
+ }
2364
+ if (process.platform === "win32") {
2365
+ killListenersOnPorts(ports);
2366
+ return;
2367
+ }
2368
+ const selfPid = process.pid;
2369
+ for (const port of ports) {
2370
+ if (!Number.isFinite(port) || port <= 0) continue;
2371
+ for (const pid of listUnixListenerPids(port, selfPid)) {
2372
+ const cwd = listenerCwd(pid);
2373
+ if (cwd && isOtherWorktreeListenerCwd(cwd, worktreePath)) continue;
2374
+ signalPid(pid, signal);
2649
2375
  }
2376
+ }
2377
+ }
2378
+ function killScriptTree(child, ports = []) {
2379
+ const pid = child.pid;
2380
+ if (pid) {
2650
2381
  try {
2651
- await removeWorktree(orphan.repoPath, orphan.path);
2652
- removed.push(orphan.path);
2382
+ if (process.platform === "win32") {
2383
+ void execa2("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
2384
+ } else {
2385
+ process.kill(-pid, "SIGTERM");
2386
+ setTimeout(() => {
2387
+ try {
2388
+ process.kill(-pid, "SIGKILL");
2389
+ } catch {
2390
+ }
2391
+ }, 2500).unref?.();
2392
+ }
2653
2393
  } catch {
2654
- kept.push(orphan.path);
2394
+ try {
2395
+ child.kill("SIGTERM");
2396
+ } catch {
2397
+ }
2655
2398
  }
2656
2399
  }
2657
- if (!opts?.dryRun) {
2658
- updateAdvancedSettings({
2659
- worktreeLastCleanupAt: (/* @__PURE__ */ new Date()).toISOString()
2660
- });
2661
- }
2662
- return { removed, kept, orphans };
2663
- }
2664
- function shouldRunWorktreeCleanup(settings = loadAppSettings()) {
2665
- const intervalHours = settings.advanced.worktreeCleanupIntervalHours ?? 6;
2666
- const last = settings.advanced.worktreeLastCleanupAt;
2667
- if (!last) return true;
2668
- const elapsed = Date.now() - Date.parse(last);
2669
- return elapsed >= intervalHours * 36e5;
2400
+ killListenersOnPorts(ports);
2670
2401
  }
2671
-
2672
- // src/store/history-cleanup.ts
2673
- function stripArchivedTranscript(thread) {
2674
- writeThread(
2402
+ async function spawnWorkspaceScript(command, opts) {
2403
+ const loginEnv = await captureLoginEnv();
2404
+ const env = buildWorkspaceScriptEnv(
2675
2405
  {
2676
- ...thread,
2677
- attachments: [],
2678
- pendingTurnAttachments: [],
2679
- queueAttachments: [],
2680
- messages: [historyRetentionStubMessage(thread)]
2406
+ worktreePath: opts.worktreePath,
2407
+ repoPath: opts.repoPath,
2408
+ workspaceName: opts.workspaceName,
2409
+ defaultBranch: opts.defaultBranch,
2410
+ ports: opts.ports
2681
2411
  },
2682
- { touch: false }
2412
+ loginEnv
2683
2413
  );
2684
- }
2685
- function cleanupArchivedHistory(opts = {}) {
2686
- const settings = loadAppSettings();
2687
- if (!opts.force && opts.purgeOlderThanDays == null && !autoCleanupHistoryEnabled(settings)) {
2688
- return { stripped: [], purged: [] };
2414
+ try {
2415
+ mergeAgentGitAuthEnv(
2416
+ env,
2417
+ await resolveAgentGitAuthEnv(env, { cwd: opts.worktreePath })
2418
+ );
2419
+ } catch {
2689
2420
  }
2690
- const archived = listThreads({ includeArchived: true }).filter((t) => t.status === "archived");
2691
- const nowMs = opts.nowMs ?? Date.now();
2692
- const plan = opts.purgeOlderThanDays != null ? { stripIds: [], purgeIds: planHistoryAgePurge(archived, opts.purgeOlderThanDays, nowMs) } : planHistoryRetention(archived, {
2693
- maxCount: opts.maxCount ?? historyMaxCount(settings),
2694
- maxDays: opts.maxDays ?? historyMaxDays(settings),
2695
- nowMs
2421
+ if (opts.heldPorts?.length) {
2422
+ await Promise.all(opts.heldPorts.map((h) => h.release()));
2423
+ }
2424
+ const shell = process.platform === "darwin" ? "zsh" : "bash";
2425
+ const child = execa2(shell, ["-lc", command], {
2426
+ cwd: opts.worktreePath,
2427
+ reject: false,
2428
+ env,
2429
+ // Own process group so Stop can tear down the whole tree (not just the shell).
2430
+ // There is no settings.toml `stop=` / teardown hook for run scripts.
2431
+ ...process.platform === "win32" ? {} : { detached: true }
2696
2432
  });
2697
- const stripped = [];
2698
- const purged = [];
2699
- if (opts.dryRun) {
2700
- return { stripped: plan.stripIds, purged: plan.purgeIds };
2433
+ pipeLines(child.stdout, opts.onLine);
2434
+ pipeLines(child.stderr, opts.onLine);
2435
+ const ports = opts.ports ?? [];
2436
+ return {
2437
+ pid: child.pid,
2438
+ kill: () => killScriptTree(child, ports),
2439
+ done: child.then((r) => r.exitCode ?? null),
2440
+ child
2441
+ };
2442
+ }
2443
+ async function attachAbort(handle, signal) {
2444
+ if (signal) {
2445
+ const onAbort = () => handle.kill();
2446
+ if (signal.aborted) onAbort();
2447
+ else signal.addEventListener("abort", onAbort, { once: true });
2701
2448
  }
2702
- for (const id of plan.stripIds) {
2703
- const thread = readThread(id);
2704
- if (!thread || thread.status !== "archived") continue;
2705
- try {
2706
- stripArchivedTranscript(thread);
2707
- stripped.push(id);
2708
- } catch {
2709
- }
2449
+ return handle.done;
2450
+ }
2451
+ async function runSetupScript(repoPath, worktreePath, onLine, opts) {
2452
+ const settings = loadWorkspaceSettings(worktreePath, repoPath);
2453
+ if (!settings?.setup) return { ran: false, exitCode: null, source: null };
2454
+ const source = workspaceSettingsSourceLabel(worktreePath, repoPath);
2455
+ onLine?.(`[setup] ${source ?? "settings.toml"}`);
2456
+ const handle = await spawnWorkspaceScript(settings.setup, {
2457
+ worktreePath,
2458
+ repoPath,
2459
+ defaultBranch: opts?.defaultBranch,
2460
+ onLine
2461
+ });
2462
+ const exitCode = await attachAbort(handle, opts?.signal);
2463
+ return {
2464
+ ran: true,
2465
+ exitCode,
2466
+ source,
2467
+ kill: handle.kill
2468
+ };
2469
+ }
2470
+ async function runConventionSetup(repoPath, worktreePath, onLine, opts) {
2471
+ const found = findConventionSetup(worktreePath, repoPath);
2472
+ if (!found) return { ran: false, exitCode: null, source: null };
2473
+ onLine?.(`[setup] ${found.source}`);
2474
+ const handle = await spawnWorkspaceScript(found.command, {
2475
+ worktreePath,
2476
+ repoPath,
2477
+ defaultBranch: opts?.defaultBranch,
2478
+ onLine
2479
+ });
2480
+ const exitCode = await attachAbort(handle, opts?.signal);
2481
+ return {
2482
+ ran: true,
2483
+ exitCode,
2484
+ source: found.source,
2485
+ kill: handle.kill
2486
+ };
2487
+ }
2488
+ async function runWorkspaceSetup(repoPath, worktreePath, onLine, opts) {
2489
+ await ensureWorktreeSideboardIgnored(worktreePath);
2490
+ const review = ensureReviewGuidelinesFile(worktreePath, repoPath);
2491
+ if (review.wrote) {
2492
+ onLine?.(`[setup] wrote ${review.path} \u2014 local Review guidelines`);
2710
2493
  }
2711
- for (const id of plan.purgeIds) {
2712
- const thread = readThread(id);
2713
- if (!thread || thread.status !== "archived") continue;
2714
- try {
2715
- deleteThreadRecord(id);
2716
- purged.push(id);
2717
- } catch {
2718
- }
2494
+ let setup = await runSetupScript(repoPath, worktreePath, onLine, opts);
2495
+ if (!setup.ran) {
2496
+ setup = await runCursorWorktreeSetup(repoPath, worktreePath, onLine);
2719
2497
  }
2720
- if (stripped.length > 0 || purged.length > 0) {
2721
- updateAdvancedSettings({
2722
- historyLastCleanupAt: new Date(nowMs).toISOString()
2723
- });
2498
+ if (!setup.ran) {
2499
+ setup = await runConventionSetup(repoPath, worktreePath, onLine, opts);
2724
2500
  }
2725
- return { stripped, purged };
2501
+ return setup;
2726
2502
  }
2727
- function shouldRunHistoryCleanup(settings = loadAppSettings()) {
2728
- if (!autoCleanupHistoryEnabled(settings)) return false;
2729
- const intervalHours = settings.advanced.historyCleanupIntervalHours ?? HISTORY_CLEANUP_INTERVAL_HOURS_DEFAULT;
2730
- const last = settings.advanced.historyLastCleanupAt;
2731
- if (!last) return true;
2732
- const elapsed = Date.now() - Date.parse(last);
2733
- return Number.isFinite(elapsed) && elapsed >= intervalHours * 36e5;
2503
+ async function runArchiveScript(repoPath, worktreePath, onLine) {
2504
+ const settings = loadWorkspaceSettings(worktreePath, repoPath);
2505
+ if (!settings?.archive) return { ran: false, exitCode: null };
2506
+ const handle = await spawnWorkspaceScript(settings.archive, {
2507
+ worktreePath,
2508
+ repoPath,
2509
+ onLine
2510
+ });
2511
+ const exitCode = await handle.done;
2512
+ return { ran: true, exitCode };
2734
2513
  }
2735
-
2736
- // src/git/apply-into-main.ts
2737
- import { execa as execa3 } from "execa";
2738
- async function applyThreadIntoMain(thread, opts) {
2739
- const method = opts?.method ?? "merge";
2740
- const target = opts?.targetBranch?.trim() || await resolveDefaultBranch(thread.repoPath);
2741
- const status = await execa3("git", ["status", "--porcelain"], {
2742
- cwd: thread.repoPath,
2743
- reject: false
2514
+ function listRunScripts(worktreePath, repoPath) {
2515
+ const settings = loadWorkspaceSettings(worktreePath, repoPath);
2516
+ if (!settings?.runScripts.length) return [];
2517
+ return settings.runScripts.filter((s) => {
2518
+ if (!s.availableIn?.length) return true;
2519
+ return s.availableIn.includes("local");
2744
2520
  });
2745
- if (status.stdout.trim()) {
2746
- throw new Error(
2747
- `Main checkout is dirty \u2014 commit or stash before apply-into-main (${thread.repoPath})`
2748
- );
2521
+ }
2522
+ function getDefaultRunScript(worktreePath, repoPath) {
2523
+ const scripts = listRunScripts(worktreePath, repoPath);
2524
+ if (!scripts.length) return null;
2525
+ return scripts.find((s) => s.default === true) ?? scripts.find((s) => s.name === "dev") ?? scripts.find((s) => s.name !== "all") ?? scripts[0] ?? null;
2526
+ }
2527
+ function getRunScript(worktreePath, repoPath, name) {
2528
+ const scripts = listRunScripts(worktreePath, repoPath);
2529
+ if (!scripts.length) return null;
2530
+ if (name) {
2531
+ return scripts.find((s) => s.name === name) ?? null;
2749
2532
  }
2750
- const checkout = await execa3("git", ["checkout", target], {
2751
- cwd: thread.repoPath,
2752
- reject: false
2753
- });
2754
- if (checkout.exitCode !== 0) {
2755
- throw new Error(
2756
- `Failed to checkout ${target}: ${checkout.stderr.trim() || checkout.stdout.trim()}`
2757
- );
2533
+ return getDefaultRunScript(worktreePath, repoPath);
2534
+ }
2535
+ function getRunMode(worktreePath, repoPath) {
2536
+ return loadWorkspaceSettings(worktreePath, repoPath)?.runMode ?? "concurrent";
2537
+ }
2538
+ async function reservePort() {
2539
+ return new Promise((resolve2, reject) => {
2540
+ const server = createServer();
2541
+ let released = false;
2542
+ const release = () => new Promise((res) => {
2543
+ if (released) {
2544
+ res();
2545
+ return;
2546
+ }
2547
+ released = true;
2548
+ server.close(() => res());
2549
+ });
2550
+ server.listen(0, "127.0.0.1", () => {
2551
+ const addr = server.address();
2552
+ if (!addr || typeof addr === "string") {
2553
+ void release().then(() => reject(new Error("Failed to allocate port")));
2554
+ return;
2555
+ }
2556
+ resolve2({ port: addr.port, release });
2557
+ });
2558
+ server.on("error", reject);
2559
+ });
2560
+ }
2561
+ async function tryReservePort(port) {
2562
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
2563
+ return new Promise((resolve2) => {
2564
+ const server = createServer();
2565
+ let released = false;
2566
+ const release = () => new Promise((res) => {
2567
+ if (released) {
2568
+ res();
2569
+ return;
2570
+ }
2571
+ released = true;
2572
+ server.close(() => res());
2573
+ });
2574
+ server.once("error", () => {
2575
+ try {
2576
+ server.close();
2577
+ } catch {
2578
+ }
2579
+ resolve2(null);
2580
+ });
2581
+ server.listen(port, "127.0.0.1", () => {
2582
+ resolve2({ port, release });
2583
+ });
2584
+ });
2585
+ }
2586
+ function normalizePreferredPorts(ports, size) {
2587
+ if (!ports?.length) return [];
2588
+ const seen = /* @__PURE__ */ new Set();
2589
+ const out = [];
2590
+ for (const p of ports) {
2591
+ if (!Number.isInteger(p) || p <= 0 || p > 65535 || seen.has(p)) continue;
2592
+ seen.add(p);
2593
+ out.push(p);
2594
+ if (out.length >= size) break;
2758
2595
  }
2759
- if (method === "cherry-pick") {
2760
- const log = await execa3(
2761
- "git",
2762
- ["log", "--reverse", "--format=%H", `${target}..${thread.branchName}`],
2763
- { cwd: thread.repoPath, reject: false }
2764
- );
2765
- const shas = log.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
2766
- if (!shas.length) {
2767
- return {
2768
- applied: false,
2769
- method,
2770
- targetBranch: target,
2771
- message: `No commits on ${thread.branchName} not already in ${target}`
2772
- };
2773
- }
2774
- for (const sha of shas) {
2775
- const cp = await execa3("git", ["cherry-pick", sha], {
2776
- cwd: thread.repoPath,
2777
- reject: false
2778
- });
2779
- if (cp.exitCode !== 0) {
2780
- await execa3("git", ["cherry-pick", "--abort"], {
2781
- cwd: thread.repoPath,
2782
- reject: false
2783
- });
2784
- throw new Error(
2785
- `Cherry-pick ${sha.slice(0, 8)} failed: ${cp.stderr.trim() || cp.stdout.trim()}`
2786
- );
2596
+ return out;
2597
+ }
2598
+ function delay(ms) {
2599
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2600
+ }
2601
+ async function fillPortRange(held, size) {
2602
+ if (held.length >= size) return held;
2603
+ const base = held[0]?.port;
2604
+ const taken = new Set(held.map((h) => h.port));
2605
+ try {
2606
+ let offset = 0;
2607
+ while (held.length < size) {
2608
+ const candidate = base != null ? base + offset : null;
2609
+ offset += 1;
2610
+ if (candidate != null && taken.has(candidate)) continue;
2611
+ const next = candidate != null ? await tryReservePort(candidate) : null;
2612
+ if (next) {
2613
+ held.push(next);
2614
+ taken.add(next.port);
2615
+ } else {
2616
+ const ephemeral = await reservePort();
2617
+ held.push(ephemeral);
2618
+ taken.add(ephemeral.port);
2787
2619
  }
2788
2620
  }
2789
- return {
2790
- applied: true,
2791
- method,
2792
- targetBranch: target,
2793
- message: `Cherry-picked ${shas.length} commit(s) from ${thread.branchName} into ${target}`
2794
- };
2795
- }
2796
- const merge = await execa3(
2797
- "git",
2798
- ["merge", "--no-ff", "-m", `Apply Sideboard thread ${thread.branchName}`, thread.branchName],
2799
- { cwd: thread.repoPath, reject: false }
2800
- );
2801
- if (merge.exitCode !== 0) {
2802
- await execa3("git", ["merge", "--abort"], {
2803
- cwd: thread.repoPath,
2804
- reject: false
2805
- });
2806
- throw new Error(
2807
- `Merge failed: ${merge.stderr.trim() || merge.stdout.trim()}`
2808
- );
2621
+ return held;
2622
+ } catch (err) {
2623
+ await Promise.all(held.map((h) => h.release()));
2624
+ throw err;
2809
2625
  }
2810
- return {
2811
- applied: true,
2812
- method: "merge",
2813
- targetBranch: target,
2814
- message: `Merged ${thread.branchName} into ${target} at ${thread.repoPath}`
2815
- };
2816
2626
  }
2817
-
2818
- // src/git/clone-repo.ts
2819
- import { existsSync as existsSync7 } from "fs";
2820
- import { basename as basename2, join as join9 } from "path";
2821
- import { execa as execa4 } from "execa";
2822
- async function cloneRepoIntoSideboard(opts) {
2823
- const url = opts.url.trim();
2824
- if (!url) throw new Error("Clone URL is required");
2825
- let name = opts.name?.trim();
2826
- if (!name) {
2827
- const leaf = basename2(url.replace(/\/$/, "").replace(/\.git$/, ""));
2828
- name = leaf || "repo";
2627
+ async function tryReserveAvailablePorts(ports) {
2628
+ const held = [];
2629
+ for (const port of ports) {
2630
+ const next = await tryReservePort(port);
2631
+ if (next) held.push(next);
2829
2632
  }
2830
- name = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
2831
- const dest = join9(sideboardReposDir(), name);
2832
- if (existsSync7(dest)) {
2833
- const repoPath2 = await resolveRepoRoot(dest);
2834
- const workspace2 = await ensureWorkspace(repoPath2);
2835
- return { repoPath: repoPath2, workspace: workspace2 };
2633
+ return held;
2634
+ }
2635
+ async function reclaimPreferredPorts(ports, worktreePath) {
2636
+ const primary = ports[0];
2637
+ if (primary == null) return null;
2638
+ const takePrimary = async () => {
2639
+ const held2 = await tryReserveAvailablePorts(ports);
2640
+ if (held2.some((h) => h.port === primary)) return held2;
2641
+ await Promise.all(held2.map((h) => h.release()));
2642
+ return null;
2643
+ };
2644
+ let held = await takePrimary();
2645
+ if (held) return held;
2646
+ if (worktreePath) {
2647
+ killStaleWorktreeListeners(ports, worktreePath, "SIGTERM");
2648
+ } else {
2649
+ killListenersOnPorts(ports);
2836
2650
  }
2837
- const result = await execa4("git", ["clone", url, dest], { reject: false });
2838
- if (result.exitCode !== 0) {
2839
- throw new Error(
2840
- `git clone failed: ${result.stderr?.trim() || result.stdout?.trim() || `exit ${result.exitCode}`}`
2841
- );
2651
+ for (let i = 0; i < RECLAIM_TRIES; i++) {
2652
+ await delay(RECLAIM_RETRY_DELAY_MS);
2653
+ held = await takePrimary();
2654
+ if (held) return held;
2655
+ if (i === 2 && worktreePath) {
2656
+ killStaleWorktreeListeners(ports, worktreePath, "SIGKILL");
2657
+ }
2842
2658
  }
2843
- const repoPath = await resolveRepoRoot(dest);
2844
- const workspace = await ensureWorkspace(repoPath);
2845
- return { repoPath, workspace };
2846
- }
2847
-
2848
- // src/types/thread.ts
2849
- function isAdoptableRunScriptRequest(req) {
2850
- return Boolean(req && !req.fulfilledAt && !req.error);
2851
- }
2852
-
2853
- // src/store/desktop-host.ts
2854
- import { readFileSync as readFileSync6, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
2855
- import { join as join10 } from "path";
2856
- function desktopHostPidPath() {
2857
- return join10(appDataDir(), "desktop-host.pid");
2659
+ return null;
2858
2660
  }
2859
- function readDesktopHostPid() {
2860
- try {
2861
- const pid = Number.parseInt(readFileSync6(desktopHostPidPath(), "utf8").trim(), 10);
2862
- if (!Number.isFinite(pid) || pid <= 0) return null;
2863
- return pid;
2864
- } catch {
2865
- return null;
2661
+ async function reservePortRange(size = PORT_RANGE_SIZE, opts) {
2662
+ const preferred = normalizePreferredPorts(opts?.preferred, size);
2663
+ if (preferred.length) {
2664
+ const reused = await reclaimPreferredPorts(preferred, opts?.worktreePath);
2665
+ if (reused) return fillPortRange(reused, size);
2866
2666
  }
2667
+ const held = [await reservePort()];
2668
+ return fillPortRange(held, size);
2867
2669
  }
2868
- function pidAlive(pid) {
2670
+ async function startDevServer(repoPath, worktreePath, onLine, opts) {
2671
+ const script = getRunScript(worktreePath, repoPath, opts?.scriptName);
2672
+ if (!script) return null;
2673
+ const preferred = loadWorktreeRunPorts(worktreePath, script.name) ?? opts?.preferredPorts;
2674
+ const held = await reservePortRange(PORT_RANGE_SIZE, {
2675
+ preferred: preferred ?? void 0,
2676
+ worktreePath
2677
+ });
2678
+ const ports = held.map((h) => h.port);
2679
+ saveWorktreeRunPorts(worktreePath, script.name, ports);
2869
2680
  try {
2870
- process.kill(pid, 0);
2871
- return true;
2872
- } catch {
2873
- return false;
2681
+ const handle = await spawnWorkspaceScript(script.command, {
2682
+ worktreePath,
2683
+ repoPath,
2684
+ ports,
2685
+ heldPorts: held,
2686
+ defaultBranch: opts?.defaultBranch,
2687
+ onLine
2688
+ });
2689
+ return {
2690
+ pid: handle.pid,
2691
+ port: ports[0],
2692
+ ports,
2693
+ scriptName: script.name,
2694
+ kill: handle.kill,
2695
+ done: handle.done
2696
+ };
2697
+ } catch (err) {
2698
+ await Promise.all(held.map((h) => h.release()));
2699
+ throw err;
2874
2700
  }
2875
2701
  }
2876
- function isDesktopHostAlive() {
2877
- const pid = readDesktopHostPid();
2878
- return pid != null && pidAlive(pid);
2879
- }
2880
- function isThisProcessDesktopHost() {
2881
- return readDesktopHostPid() === process.pid && pidAlive(process.pid);
2882
- }
2883
- function thisProcessShouldDrainAgentQueues() {
2884
- if (isThisProcessDesktopHost()) return true;
2885
- return !isDesktopHostAlive();
2886
- }
2887
2702
 
2888
- // src/orchestrator/child-halt.ts
2889
- var HALT_STATUSES = /* @__PURE__ */ new Set(["stopped", "error", "broken"]);
2890
- function isIncompleteChildStatus(status) {
2891
- return HALT_STATUSES.has(status);
2892
- }
2893
- function childHaltNotice(child, status) {
2894
- const title = child.title?.trim() || "Untitled";
2895
- const link = `[${title}](sideboard://thread/${child.id})`;
2896
- const why = child.lastError?.trim();
2897
- const extra = why ? ` lastError: ${why}` : "";
2898
- return [
2899
- `Sideboard: child worktree ${link} ${status} before finishing (status=${status}).${extra}`,
2900
- "This is information \u2014 not a user command. Resume with send_to_thread or tell the user. Do not treat this as a successful turn."
2901
- ].join("\n");
2902
- }
2903
- function shouldNotifyParentAfterTurnError(opts) {
2904
- return !opts.quotaFailoverHandled && !opts.crashContinued;
2905
- }
2906
- function shouldNotifyParentOfChildHalt(opts) {
2907
- if (!isIncompleteChildStatus(opts.status)) return false;
2908
- if (!opts.child.parentThreadId) return false;
2909
- if (!opts.parent || opts.parent.status === "archived") return false;
2910
- if (opts.parent.id === opts.child.id) return false;
2911
- return isOrchestratorThread(opts.parent);
2703
+ // src/store/setup-log.ts
2704
+ import { createHash } from "crypto";
2705
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2706
+ import { join as join7 } from "path";
2707
+ var EMPTY = {
2708
+ output: "",
2709
+ running: false,
2710
+ exitCode: null,
2711
+ source: null
2712
+ };
2713
+ var MAX_SETUP_LOG_CHARS = 256e3;
2714
+ function appendSetupOutput(prev, line, max = MAX_SETUP_LOG_CHARS) {
2715
+ const next = prev ? `${prev}
2716
+ ${line}` : line;
2717
+ if (next.length <= max) return next;
2718
+ const cut = next.slice(next.length - max);
2719
+ const nl = cut.indexOf("\n");
2720
+ return nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2912
2721
  }
2913
- var notified = /* @__PURE__ */ new Set();
2914
- function noticeKey(childId, status) {
2915
- return `${childId}:${status}`;
2722
+ var memory = /* @__PURE__ */ new Map();
2723
+ var persistTimers = /* @__PURE__ */ new Map();
2724
+ function fromSnapshot(snap) {
2725
+ return {
2726
+ chunks: snap.output ? [snap.output] : [],
2727
+ length: snap.output.length,
2728
+ running: snap.running,
2729
+ exitCode: snap.exitCode,
2730
+ source: snap.source
2731
+ };
2916
2732
  }
2917
- function notifyParentOfChildHalt(child, status, send) {
2918
- const parent = child.parentThreadId ? readThread(child.parentThreadId) : null;
2919
- if (!shouldNotifyParentOfChildHalt({ child, parent, status })) return false;
2920
- const key = noticeKey(child.id, status);
2921
- if (notified.has(key)) return false;
2922
- notified.add(key);
2923
- const parentId = parent.id;
2924
- void send(parentId, childHaltNotice(child, status)).catch(() => {
2925
- notified.delete(key);
2926
- });
2927
- return true;
2733
+ function materialize(mem) {
2734
+ let output = mem.chunks.join("\n");
2735
+ if (output.length > MAX_SETUP_LOG_CHARS) {
2736
+ output = appendSetupOutput("", output);
2737
+ mem.chunks = [output];
2738
+ mem.length = output.length;
2739
+ }
2740
+ return {
2741
+ output,
2742
+ running: mem.running,
2743
+ exitCode: mem.exitCode,
2744
+ source: mem.source
2745
+ };
2928
2746
  }
2929
-
2930
- // src/mcp/wait-for-job.ts
2931
- import {
2932
- appendFileSync as appendFileSync2,
2933
- existsSync as existsSync8,
2934
- mkdirSync as mkdirSync5,
2935
- readdirSync as readdirSync3,
2936
- readFileSync as readFileSync7,
2937
- writeFileSync as writeFileSync6
2938
- } from "fs";
2939
- import { dirname as dirname4, join as join11 } from "path";
2940
-
2941
- // src/mcp/wait-for-turn.ts
2942
- var MCP_WAIT_FOR_TURN_MAX_MS = 45e3;
2943
- function mcpWaitForTurnTimeoutMs(requested) {
2944
- const n = requested ?? MCP_WAIT_FOR_TURN_MAX_MS;
2945
- if (!Number.isFinite(n)) return MCP_WAIT_FOR_TURN_MAX_MS;
2946
- return Math.min(Math.max(1e3, Math.floor(n)), MCP_WAIT_FOR_TURN_MAX_MS);
2747
+ function setupLogPath(threadId) {
2748
+ return join7(threadsDir(), `${threadId}.setup.log.json`);
2947
2749
  }
2948
- var MCP_WAIT_STILL_RUNNING_HINT = "Child is still working. Call wait_for_turn again. Do not send_to_thread a check-in (that steers / interrupts) or assume a hang while progress is updating.";
2949
- var MCP_WAIT_QUEUED_HINT = "Child is queued waiting for a concurrency slot \u2014 it has not started yet. Call wait_for_turn again. Do not send_to_thread a check-in (that steers / interrupts), force_stop, or assume it failed to start.";
2950
- var MCP_WAIT_STOPPED_HINT = "Child was stopped before the turn finished. Do not treat this as success. send_to_thread to resume, or tell the user.";
2951
- var MCP_WAIT_BROKEN_HINT = "Child worktree is broken (missing on disk). Tell the user \u2014 do not treat this as success.";
2952
- var MCP_WAIT_ERROR_HINT = "Child turn failed. lastError/text is the failure \u2014 switch agent, tell the user, or retry. Do not treat empty text as success.";
2953
- var MCP_WAIT_INPUT_REQUIRED_HINT = "Child asked the user a question (ask_user). taskState is input-required. Wait for the user to answer in that chat \u2014 do not send_to_thread a check-in.";
2954
- function mcpWaitTaskHint(taskState, status) {
2955
- switch (taskState) {
2956
- case "submitted":
2957
- return MCP_WAIT_QUEUED_HINT;
2958
- case "working":
2959
- return MCP_WAIT_STILL_RUNNING_HINT;
2960
- case "input-required":
2961
- return MCP_WAIT_INPUT_REQUIRED_HINT;
2962
- case "canceled":
2963
- return MCP_WAIT_STOPPED_HINT;
2964
- case "failed":
2965
- return status === "broken" ? MCP_WAIT_BROKEN_HINT : MCP_WAIT_ERROR_HINT;
2966
- default:
2967
- return void 0;
2968
- }
2750
+ function setupLogKeyForWorktree(worktreePath) {
2751
+ const norm = normalizeWorktreePath(worktreePath);
2752
+ return `wt-${createHash("sha1").update(norm).digest("hex").slice(0, 16)}`;
2969
2753
  }
2970
-
2971
- // src/mcp/wait-for-job.ts
2972
- var MAX_JOB_CONTINUES = 8;
2973
- var MAX_JOB_DELTA_LINES = 80;
2974
- var MAX_JOB_DELTA_CHARS = 4e3;
2975
- var DEFERRED_DONE_MAX_CHARS = 480;
2976
- var GH_WATCH_REFRESH = "Refreshing run status every 3 seconds.";
2977
- var MCP_WAIT_JOB_STILL_RUNNING_HINT = "Job is still running. The type=log pane already appended this delta. Call wait_for_job again. If it is hanging, producing no useful output, or doing the wrong thing, call stop_job (same id) instead of looping forever. Do not end the turn or tell the user you will let them know later.";
2978
- var MCP_STOP_JOB_HINT = "Stopped. The type=log pane is status=failed with the last delta. Do not wait again unless you start a new command.";
2979
- var JOB_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
2980
- function mcpWaitForJobTimeoutMs(requested) {
2981
- return mcpWaitForTurnTimeoutMs(requested);
2754
+ function emptySetupLog() {
2755
+ return { ...EMPTY };
2982
2756
  }
2983
- function sanitizeDetachedJobId(id) {
2984
- const s = id.trim();
2985
- if (!JOB_ID_RE.test(s)) {
2986
- throw new Error(`detached-job id must be 1\u201364 chars [A-Za-z0-9._-], got ${JSON.stringify(id)}`);
2757
+ var PERSIST_DEBOUNCE_MS = 500;
2758
+ function persistNow(threadId) {
2759
+ const timer = persistTimers.get(threadId);
2760
+ if (timer) {
2761
+ clearTimeout(timer);
2762
+ persistTimers.delete(threadId);
2987
2763
  }
2988
- return s;
2989
- }
2990
- function jobAlive(pid) {
2991
- if (!Number.isInteger(pid) || pid <= 0) return false;
2764
+ const mem = memory.get(threadId);
2765
+ if (!mem) return;
2992
2766
  try {
2993
- process.kill(pid, 0);
2994
- return true;
2767
+ writeFileSync4(
2768
+ setupLogPath(threadId),
2769
+ `${JSON.stringify(materialize(mem))}
2770
+ `,
2771
+ "utf8"
2772
+ );
2995
2773
  } catch {
2996
- return false;
2997
2774
  }
2998
2775
  }
2999
- function processGroupAlive(pgid) {
3000
- if (process.platform === "win32") return false;
3001
- if (!Number.isInteger(pgid) || pgid <= 1) return false;
2776
+ function schedulePersist(threadId) {
2777
+ if (persistTimers.has(threadId)) return;
2778
+ persistTimers.set(
2779
+ threadId,
2780
+ setTimeout(() => {
2781
+ persistTimers.delete(threadId);
2782
+ persistNow(threadId);
2783
+ }, PERSIST_DEBOUNCE_MS)
2784
+ );
2785
+ }
2786
+ function loadFromDisk(threadId) {
2787
+ const path = setupLogPath(threadId);
2788
+ if (!existsSync5(path)) return null;
3002
2789
  try {
3003
- process.kill(-pgid, 0);
3004
- return true;
3005
- } catch (err) {
3006
- return err && typeof err === "object" && "code" in err && err.code === "EPERM";
2790
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
2791
+ return {
2792
+ output: typeof raw.output === "string" ? raw.output : "",
2793
+ running: Boolean(raw.running),
2794
+ exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2795
+ source: typeof raw.source === "string" ? raw.source : null
2796
+ };
2797
+ } catch {
2798
+ return null;
3007
2799
  }
3008
2800
  }
3009
- function jobTreeAlive(pid) {
3010
- if (pid == null) return false;
3011
- return jobAlive(pid) || processGroupAlive(pid);
3012
- }
3013
- function readIntFile(file) {
3014
- if (!existsSync8(file)) return null;
3015
- const n = Number.parseInt(readFileSync7(file, "utf8").trim(), 10);
3016
- return Number.isInteger(n) ? n : null;
2801
+ function memFor(threadId) {
2802
+ const cached = memory.get(threadId);
2803
+ if (cached) return cached;
2804
+ const mem = fromSnapshot(loadFromDisk(threadId) ?? emptySetupLog());
2805
+ memory.set(threadId, mem);
2806
+ return mem;
3017
2807
  }
3018
- function jobDir(root, id, legacy = false) {
3019
- return join11(root, legacy ? LEGACY_DETACHED_JOBS_DIR : DETACHED_JOBS_DIR, id);
2808
+ function readSetupLog(threadId) {
2809
+ const cached = memory.get(threadId);
2810
+ if (cached) return materialize(cached);
2811
+ const disk = loadFromDisk(threadId);
2812
+ if (disk) {
2813
+ memory.set(threadId, fromSnapshot(disk));
2814
+ return disk;
2815
+ }
2816
+ return emptySetupLog();
3020
2817
  }
3021
- function resolveJobDir(root, id) {
3022
- const modern = jobDir(root, id, false);
3023
- if (existsSync8(modern)) return modern;
3024
- const legacy = jobDir(root, id, true);
3025
- if (existsSync8(legacy)) return legacy;
3026
- return modern;
2818
+ function beginSetupLog(threadId) {
2819
+ const mem = {
2820
+ chunks: [],
2821
+ length: 0,
2822
+ running: true,
2823
+ exitCode: null,
2824
+ source: null
2825
+ };
2826
+ memory.set(threadId, mem);
2827
+ persistNow(threadId);
2828
+ return materialize(mem);
3027
2829
  }
3028
- function listJobIdsIn(root, rel) {
3029
- const dir = join11(root, rel);
3030
- if (!existsSync8(dir)) return [];
3031
- return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && JOB_ID_RE.test(e.name)).map((e) => e.name);
2830
+ function appendSetupLog(threadId, line) {
2831
+ const mem = memFor(threadId);
2832
+ mem.chunks.push(line);
2833
+ mem.length += line.length + 1;
2834
+ mem.running = true;
2835
+ if (mem.length > MAX_SETUP_LOG_CHARS * 2) materialize(mem);
2836
+ schedulePersist(threadId);
3032
2837
  }
3033
- function listRunningDetachedJobs(worktreePath) {
3034
- const root = worktreePath.trim();
3035
- if (!root) return [];
3036
- const ids = /* @__PURE__ */ new Set([
3037
- ...listJobIdsIn(root, DETACHED_JOBS_DIR),
3038
- ...listJobIdsIn(root, LEGACY_DETACHED_JOBS_DIR)
3039
- ]);
3040
- const running = [];
3041
- for (const id of ids) {
3042
- const dir = resolveJobDir(root, id);
3043
- const pid = readIntFile(join11(dir, "pid"));
3044
- if (jobTreeAlive(pid)) running.push(id);
3045
- }
3046
- return running.sort();
2838
+ function finishSetupLog(threadId, exitCode, source) {
2839
+ const mem = memFor(threadId);
2840
+ mem.running = false;
2841
+ mem.exitCode = exitCode;
2842
+ mem.source = source ?? mem.source;
2843
+ persistNow(threadId);
2844
+ return materialize(mem);
3047
2845
  }
3048
- function looksLikeDeferredDonePromise(text) {
3049
- const t = (text ?? "").trim();
3050
- if (!t || t.length > DEFERRED_DONE_MAX_CHARS) return false;
3051
- const unquoted = t.replace(/`[^`]*`/g, " ").replace(/["“”][^"“”]*["“”]/g, " ").replace(/\s+/g, " ").trim();
3052
- if (!unquoted) return false;
3053
- return /\b(i['’]?ll|i will)\s+let you know\b/i.test(unquoted) || /\blet you know when\b/i.test(unquoted) || /\b(check back|ping me)\s+when\b/i.test(unquoted) || /\bi(?:['’]ll| will)\s+(report|update you)\s+when\b/i.test(unquoted);
2846
+
2847
+ // src/store/run-log.ts
2848
+ import { createHash as createHash2 } from "crypto";
2849
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2850
+ import { join as join8 } from "path";
2851
+ var EMPTY2 = {
2852
+ output: "",
2853
+ running: false,
2854
+ exitCode: null,
2855
+ scriptName: null
2856
+ };
2857
+ var MAX_RUN_LOG_CHARS = 256e3;
2858
+ var DEFAULT_RUN_LOG_TAIL_CHARS = 16e3;
2859
+ var memory2 = /* @__PURE__ */ new Map();
2860
+ var persistTimers2 = /* @__PURE__ */ new Map();
2861
+ function fromSnapshot2(snap) {
2862
+ return {
2863
+ chunks: snap.output ? [snap.output] : [],
2864
+ length: snap.output.length,
2865
+ running: snap.running,
2866
+ exitCode: snap.exitCode,
2867
+ scriptName: snap.scriptName
2868
+ };
3054
2869
  }
3055
- function isJobContinuePrompt(text) {
3056
- if (typeof text !== "string") return false;
3057
- const t = text.trim();
3058
- return t.startsWith("Detached job still running:") || t.startsWith("You ended the turn after promising to report later");
2870
+ function materialize2(mem) {
2871
+ let output = mem.chunks.join("\n");
2872
+ if (output.length > MAX_RUN_LOG_CHARS) {
2873
+ output = appendSetupOutput("", output, MAX_RUN_LOG_CHARS);
2874
+ mem.chunks = [output];
2875
+ mem.length = output.length;
2876
+ }
2877
+ return {
2878
+ output,
2879
+ running: mem.running,
2880
+ exitCode: mem.exitCode,
2881
+ scriptName: mem.scriptName
2882
+ };
3059
2883
  }
3060
- function collapseGhRunWatchLog(text) {
3061
- if (!text.includes(GH_WATCH_REFRESH)) return text;
3062
- const frames = text.split(/(?=Refreshing run status every 3 seconds\.)/);
3063
- const watchFrames = frames.filter((f) => f.includes(GH_WATCH_REFRESH));
3064
- if (watchFrames.length <= 1) return text;
3065
- const prefix = frames[0]?.includes(GH_WATCH_REFRESH) ? "" : frames[0] ?? "";
3066
- const last = watchFrames[watchFrames.length - 1] ?? "";
3067
- const omitted = watchFrames.length - 1;
3068
- const note = `(${omitted} identical gh run watch refresh${omitted === 1 ? "" : "es"} omitted)
3069
- `;
3070
- return [prefix.trimEnd(), note, last.trimStart()].filter(Boolean).join("\n");
2884
+ function safeScriptToken(scriptName) {
2885
+ const token = scriptName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
2886
+ return token || "dev";
3071
2887
  }
3072
- function capJobLogDelta(text) {
3073
- const collapsed = collapseGhRunWatchLog(text);
3074
- const lines = collapsed.split("\n");
3075
- let out = lines.length > MAX_JOB_DELTA_LINES ? `\u2026(${lines.length - MAX_JOB_DELTA_LINES} earlier lines omitted)
3076
- ${lines.slice(-MAX_JOB_DELTA_LINES).join("\n")}` : collapsed;
3077
- if (out.length > MAX_JOB_DELTA_CHARS) {
3078
- out = `\u2026(truncated ${out.length} chars)
3079
- ${out.slice(-MAX_JOB_DELTA_CHARS)}`;
3080
- }
3081
- return out;
2888
+ function runLogPath(key) {
2889
+ return join8(threadsDir(), `${key}.run.log.json`);
3082
2890
  }
3083
- function formatJobStillRunningContinuePrompt(jobIds) {
3084
- const ids = jobIds.join(", ");
3085
- return [
3086
- `Detached job still running: ${ids}.`,
3087
- "Do not end this turn. Loop wait_for_job (same id) until stillRunning is false. The log pane updates from each wait result.",
3088
- "If the job is hanging or doing the wrong thing, call stop_job (same id) instead of looping forever.",
3089
- "Then report the result. Do not tell the user you will let them know later."
3090
- ].join(" ");
2891
+ function runLogKeyForWorktree(worktreePath, scriptName) {
2892
+ const norm = normalizeWorktreePath(worktreePath);
2893
+ const hash = createHash2("sha1").update(norm).digest("hex").slice(0, 16);
2894
+ return `wt-${hash}-run-${safeScriptToken(scriptName)}`;
3091
2895
  }
3092
- function formatDeferredDoneContinuePrompt() {
3093
- return [
3094
- "You ended the turn after promising to report later, but no detached job is running.",
3095
- "If tests/pack/deploy still need to run: start once with detached-job.cjs, then loop wait_for_job until stillRunning is false (the log pane updates from wait JSON).",
3096
- "Do not say you will let the user know later."
3097
- ].join(" ");
2896
+ function emptyRunLog() {
2897
+ return { ...EMPTY2 };
3098
2898
  }
3099
- function turnWatchedDetachedJob(parts) {
3100
- return parts.some((p) => {
3101
- if (p.type !== "tool") return false;
3102
- if (/wait_for_job$/i.test(p.name ?? "") || /stop_job$/i.test(p.name ?? "")) return true;
3103
- const blob = [p.name, p.detail, p.description, p.input ? JSON.stringify(p.input) : ""].filter(Boolean).join(" ");
3104
- return /detached-job\.(?:js|cjs)\b/i.test(blob);
3105
- });
2899
+ function sliceRunLogOutput(output, tailChars = DEFAULT_RUN_LOG_TAIL_CHARS) {
2900
+ const max = Math.max(1, tailChars);
2901
+ const outputChars = output.length;
2902
+ if (outputChars <= max) return { output, truncated: false, outputChars };
2903
+ const cut = output.slice(outputChars - max);
2904
+ const nl = cut.indexOf("\n");
2905
+ const text = nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2906
+ return { output: text, truncated: true, outputChars };
3106
2907
  }
3107
- function planJobContinue(opts) {
3108
- if (opts.isOrchestrator) return { action: "none" };
3109
- if (opts.agent === "brightsy") return { action: "none" };
3110
- if (opts.queueLength > 0) return { action: "none" };
3111
- if (opts.continueCount >= MAX_JOB_CONTINUES) return { action: "none" };
3112
- const farewell = looksLikeDeferredDonePromise(opts.chatText);
3113
- if (opts.runningJobIds.length > 0 && (farewell || opts.watchedJob)) {
2908
+ var PERSIST_DEBOUNCE_MS2 = 500;
2909
+ function persistNow2(key) {
2910
+ const timer = persistTimers2.get(key);
2911
+ if (timer) {
2912
+ clearTimeout(timer);
2913
+ persistTimers2.delete(key);
2914
+ }
2915
+ const mem = memory2.get(key);
2916
+ if (!mem) return;
2917
+ try {
2918
+ writeFileSync5(runLogPath(key), `${JSON.stringify(materialize2(mem))}
2919
+ `, "utf8");
2920
+ } catch {
2921
+ }
2922
+ }
2923
+ function schedulePersist2(key) {
2924
+ if (persistTimers2.has(key)) return;
2925
+ persistTimers2.set(
2926
+ key,
2927
+ setTimeout(() => {
2928
+ persistTimers2.delete(key);
2929
+ persistNow2(key);
2930
+ }, PERSIST_DEBOUNCE_MS2)
2931
+ );
2932
+ }
2933
+ function loadFromDisk2(key) {
2934
+ const path = runLogPath(key);
2935
+ if (!existsSync6(path)) return null;
2936
+ try {
2937
+ const raw = JSON.parse(readFileSync6(path, "utf8"));
3114
2938
  return {
3115
- action: "wait",
3116
- jobIds: opts.runningJobIds,
3117
- prompt: formatJobStillRunningContinuePrompt(opts.runningJobIds)
2939
+ output: typeof raw.output === "string" ? raw.output : "",
2940
+ running: Boolean(raw.running),
2941
+ exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2942
+ scriptName: typeof raw.scriptName === "string" ? raw.scriptName : null
3118
2943
  };
2944
+ } catch {
2945
+ return null;
3119
2946
  }
3120
- if (looksLikeDeferredDonePromise(opts.chatText) && !opts.alreadyNudged) {
3121
- return { action: "nudge", prompt: formatDeferredDoneContinuePrompt() };
2947
+ }
2948
+ function memFor2(key) {
2949
+ const cached = memory2.get(key);
2950
+ if (cached) return cached;
2951
+ const mem = fromSnapshot2(loadFromDisk2(key) ?? emptyRunLog());
2952
+ memory2.set(key, mem);
2953
+ return mem;
2954
+ }
2955
+ function readRunLog(key) {
2956
+ const cached = memory2.get(key);
2957
+ if (cached) return materialize2(cached);
2958
+ const disk = loadFromDisk2(key);
2959
+ if (disk) {
2960
+ memory2.set(key, fromSnapshot2(disk));
2961
+ return disk;
3122
2962
  }
3123
- return { action: "none" };
2963
+ return emptyRunLog();
3124
2964
  }
3125
- function tailProgress(logFile, maxLines = 12) {
3126
- if (!existsSync8(logFile)) return "(no log yet)";
3127
- const lines = readFileSync7(logFile, "utf8").split("\n");
3128
- return lines.slice(-maxLines).join("\n");
2965
+ function beginRunLog(key, scriptName) {
2966
+ const mem = {
2967
+ chunks: [],
2968
+ length: 0,
2969
+ running: true,
2970
+ exitCode: null,
2971
+ scriptName
2972
+ };
2973
+ memory2.set(key, mem);
2974
+ persistNow2(key);
2975
+ return materialize2(mem);
3129
2976
  }
3130
- function lastProgressLine(progress) {
3131
- const last = progress.split("\n").map((l) => l.trim()).filter((l) => l && l !== "(no log yet)").at(-1);
3132
- return last ? last.slice(0, 72) : void 0;
2977
+ function appendRunLog(key, line) {
2978
+ const mem = memFor2(key);
2979
+ mem.chunks.push(line);
2980
+ mem.length += line.length + 1;
2981
+ mem.running = true;
2982
+ if (mem.length > MAX_RUN_LOG_CHARS * 2) materialize2(mem);
2983
+ schedulePersist2(key);
3133
2984
  }
3134
- function readLogLines(file) {
3135
- if (!existsSync8(file)) return [];
3136
- const lines = readFileSync7(file, "utf8").split("\n");
3137
- if (lines.length && lines[lines.length - 1] === "") lines.pop();
3138
- return lines;
2985
+ function finishRunLog(key, exitCode) {
2986
+ const mem = memFor2(key);
2987
+ mem.running = false;
2988
+ mem.exitCode = exitCode;
2989
+ persistNow2(key);
2990
+ return materialize2(mem);
3139
2991
  }
3140
- function takeDelta(logFile, cursorFile) {
3141
- const lines = readLogLines(logFile);
3142
- const cursor = readIntFile(cursorFile) ?? 0;
3143
- const start = Math.min(Math.max(0, cursor), lines.length);
3144
- return { delta: capJobLogDelta(lines.slice(start).join("\n")), nextCursor: lines.length };
2992
+ function flushRunLog(key) {
2993
+ persistNow2(key);
3145
2994
  }
3146
- function snapshotJob(dir) {
3147
- const pid = readIntFile(join11(dir, "pid"));
3148
- const running = jobTreeAlive(pid);
3149
- return {
3150
- pid,
3151
- running,
3152
- exitCode: readIntFile(join11(dir, "exit")),
3153
- log: join11(dir, "log"),
3154
- cursor: join11(dir, "present.cursor"),
3155
- progress: tailProgress(join11(dir, "log"))
3156
- };
2995
+
2996
+ // src/git/orphan-cleanup.ts
2997
+ import { existsSync as existsSync7, readdirSync as readdirSync3, statSync } from "fs";
2998
+ import { join as join9 } from "path";
2999
+ function isSideboardWorktreePath(path) {
3000
+ return path.includes("/.sideboard/worktrees/") || path.includes("/sideboard/workspaces/");
3157
3001
  }
3158
- function toResult(id, snap, extra) {
3159
- const failed = extra?.failed === true || !snap.running && snap.exitCode != null && snap.exitCode !== 0;
3160
- const ok = !snap.running && snap.exitCode === 0;
3161
- const stillRunning = snap.running && !ok;
3162
- const { delta, nextCursor } = takeDelta(snap.log, snap.cursor);
3163
- try {
3164
- mkdirSync5(dirname4(snap.cursor), { recursive: true });
3165
- writeFileSync6(snap.cursor, `${nextCursor}
3166
- `);
3167
- } catch {
3002
+ async function findOrphanWorktrees(repoPaths) {
3003
+ const threads = listThreads({ includeArchived: true });
3004
+ const known = new Set(threads.map((t) => t.worktreePath.replace(/\/$/, "")));
3005
+ const repos = new Set(
3006
+ repoPaths?.length ? repoPaths : threads.map((t) => t.repoPath).filter(Boolean)
3007
+ );
3008
+ const homeRoot = sideboardWorkspacesDir();
3009
+ if (existsSync7(homeRoot)) {
3010
+ try {
3011
+ for (const entry of readdirSync3(homeRoot, { withFileTypes: true })) {
3012
+ if (!entry.isDirectory()) continue;
3013
+ void entry;
3014
+ }
3015
+ } catch {
3016
+ }
3017
+ }
3018
+ const orphans = [];
3019
+ const seen = /* @__PURE__ */ new Set();
3020
+ for (const repoPath of repos) {
3021
+ if (!repoPath || !existsSync7(repoPath)) continue;
3022
+ try {
3023
+ const wts = await listWorktrees(repoPath);
3024
+ for (const wt of wts) {
3025
+ const path = wt.path.replace(/\/$/, "");
3026
+ if (!isSideboardWorktreePath(path)) continue;
3027
+ if (known.has(path)) continue;
3028
+ if (seen.has(path)) continue;
3029
+ seen.add(path);
3030
+ let mtimeMs = 0;
3031
+ try {
3032
+ mtimeMs = statSync(path).mtimeMs;
3033
+ } catch {
3034
+ mtimeMs = 0;
3035
+ }
3036
+ orphans.push({ path, repoPath, mtimeMs });
3037
+ }
3038
+ } catch {
3039
+ }
3040
+ try {
3041
+ const root = worktreesRoot(repoPath);
3042
+ if (existsSync7(root)) {
3043
+ for (const entry of readdirSync3(root, { withFileTypes: true })) {
3044
+ if (!entry.isDirectory()) continue;
3045
+ const path = join9(root, entry.name).replace(/\/$/, "");
3046
+ if (known.has(path) || seen.has(path)) continue;
3047
+ if (!existsSync7(join9(path, ".git"))) continue;
3048
+ seen.add(path);
3049
+ let mtimeMs = 0;
3050
+ try {
3051
+ mtimeMs = statSync(path).mtimeMs;
3052
+ } catch {
3053
+ mtimeMs = Date.now();
3054
+ }
3055
+ orphans.push({ path, repoPath, mtimeMs });
3056
+ }
3057
+ }
3058
+ } catch {
3059
+ }
3060
+ }
3061
+ return orphans.sort((a, b) => a.mtimeMs - b.mtimeMs);
3062
+ }
3063
+ async function cleanupOrphanWorktrees(opts) {
3064
+ const settings = loadAppSettings();
3065
+ const maxCount = opts?.maxCount ?? settings.advanced.worktreeMaxCount ?? 25;
3066
+ const orphans = await findOrphanWorktrees(opts?.repoPaths);
3067
+ const activeCount = listThreads().filter((t) => t.status !== "archived").length;
3068
+ const budget = Math.max(0, maxCount - activeCount);
3069
+ const sortedNewestFirst = [...orphans].sort((a, b) => b.mtimeMs - a.mtimeMs);
3070
+ const keep = new Set(sortedNewestFirst.slice(0, budget).map((o) => o.path));
3071
+ const removed = [];
3072
+ const kept = [];
3073
+ for (const orphan of orphans) {
3074
+ if (keep.has(orphan.path)) {
3075
+ kept.push(orphan.path);
3076
+ continue;
3077
+ }
3078
+ if (opts?.dryRun) {
3079
+ removed.push(orphan.path);
3080
+ continue;
3081
+ }
3082
+ try {
3083
+ await removeWorktree(orphan.repoPath, orphan.path);
3084
+ removed.push(orphan.path);
3085
+ } catch {
3086
+ kept.push(orphan.path);
3087
+ }
3088
+ }
3089
+ if (!opts?.dryRun) {
3090
+ updateAdvancedSettings({
3091
+ worktreeLastCleanupAt: (/* @__PURE__ */ new Date()).toISOString()
3092
+ });
3093
+ }
3094
+ return { removed, kept, orphans };
3095
+ }
3096
+ function shouldRunWorktreeCleanup(settings = loadAppSettings()) {
3097
+ const intervalHours = settings.advanced.worktreeCleanupIntervalHours ?? 6;
3098
+ const last = settings.advanced.worktreeLastCleanupAt;
3099
+ if (!last) return true;
3100
+ const elapsed = Date.now() - Date.parse(last);
3101
+ return elapsed >= intervalHours * 36e5;
3102
+ }
3103
+
3104
+ // src/store/history-cleanup.ts
3105
+ function stripArchivedTranscript(thread) {
3106
+ writeThread(
3107
+ {
3108
+ ...thread,
3109
+ attachments: [],
3110
+ pendingTurnAttachments: [],
3111
+ queueAttachments: [],
3112
+ compactedMessages: void 0,
3113
+ messages: [historyRetentionStubMessage(thread)]
3114
+ },
3115
+ { touch: false }
3116
+ );
3117
+ }
3118
+ function cleanupArchivedHistory(opts = {}) {
3119
+ const settings = loadAppSettings();
3120
+ if (!opts.force && opts.purgeOlderThanDays == null && !autoCleanupHistoryEnabled(settings)) {
3121
+ return { stripped: [], purged: [] };
3122
+ }
3123
+ const archived = listThreads({ includeArchived: true }).filter((t) => t.status === "archived");
3124
+ const nowMs = opts.nowMs ?? Date.now();
3125
+ const plan = opts.purgeOlderThanDays != null ? { stripIds: [], purgeIds: planHistoryAgePurge(archived, opts.purgeOlderThanDays, nowMs) } : planHistoryRetention(archived, {
3126
+ maxCount: opts.maxCount ?? historyMaxCount(settings),
3127
+ maxDays: opts.maxDays ?? historyMaxDays(settings),
3128
+ nowMs
3129
+ });
3130
+ const stripped = [];
3131
+ const purged = [];
3132
+ if (opts.dryRun) {
3133
+ return { stripped: plan.stripIds, purged: plan.purgeIds };
3134
+ }
3135
+ for (const id of plan.stripIds) {
3136
+ const thread = readThread(id);
3137
+ if (!thread || thread.status !== "archived") continue;
3138
+ try {
3139
+ stripArchivedTranscript(thread);
3140
+ stripped.push(id);
3141
+ } catch {
3142
+ }
3143
+ }
3144
+ for (const id of plan.purgeIds) {
3145
+ const thread = readThread(id);
3146
+ if (!thread || thread.status !== "archived") continue;
3147
+ try {
3148
+ deleteThreadRecord(id);
3149
+ purged.push(id);
3150
+ } catch {
3151
+ }
3152
+ }
3153
+ if (stripped.length > 0 || purged.length > 0) {
3154
+ updateAdvancedSettings({
3155
+ historyLastCleanupAt: new Date(nowMs).toISOString()
3156
+ });
3157
+ }
3158
+ return { stripped, purged };
3159
+ }
3160
+ function shouldRunHistoryCleanup(settings = loadAppSettings()) {
3161
+ if (!autoCleanupHistoryEnabled(settings)) return false;
3162
+ const intervalHours = settings.advanced.historyCleanupIntervalHours ?? HISTORY_CLEANUP_INTERVAL_HOURS_DEFAULT;
3163
+ const last = settings.advanced.historyLastCleanupAt;
3164
+ if (!last) return true;
3165
+ const elapsed = Date.now() - Date.parse(last);
3166
+ return Number.isFinite(elapsed) && elapsed >= intervalHours * 36e5;
3167
+ }
3168
+
3169
+ // src/git/apply-into-main.ts
3170
+ import { execa as execa3 } from "execa";
3171
+ async function applyThreadIntoMain(thread, opts) {
3172
+ const method = opts?.method ?? "merge";
3173
+ const target = opts?.targetBranch?.trim() || await resolveDefaultBranch(thread.repoPath);
3174
+ const status = await execa3("git", ["status", "--porcelain"], {
3175
+ cwd: thread.repoPath,
3176
+ reject: false
3177
+ });
3178
+ if (status.stdout.trim()) {
3179
+ throw new Error(
3180
+ `Main checkout is dirty \u2014 commit or stash before apply-into-main (${thread.repoPath})`
3181
+ );
3182
+ }
3183
+ const checkout = await execa3("git", ["checkout", target], {
3184
+ cwd: thread.repoPath,
3185
+ reject: false
3186
+ });
3187
+ if (checkout.exitCode !== 0) {
3188
+ throw new Error(
3189
+ `Failed to checkout ${target}: ${checkout.stderr.trim() || checkout.stdout.trim()}`
3190
+ );
3191
+ }
3192
+ if (method === "cherry-pick") {
3193
+ const log = await execa3(
3194
+ "git",
3195
+ ["log", "--reverse", "--format=%H", `${target}..${thread.branchName}`],
3196
+ { cwd: thread.repoPath, reject: false }
3197
+ );
3198
+ const shas = log.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3199
+ if (!shas.length) {
3200
+ return {
3201
+ applied: false,
3202
+ method,
3203
+ targetBranch: target,
3204
+ message: `No commits on ${thread.branchName} not already in ${target}`
3205
+ };
3206
+ }
3207
+ for (const sha of shas) {
3208
+ const cp = await execa3("git", ["cherry-pick", sha], {
3209
+ cwd: thread.repoPath,
3210
+ reject: false
3211
+ });
3212
+ if (cp.exitCode !== 0) {
3213
+ await execa3("git", ["cherry-pick", "--abort"], {
3214
+ cwd: thread.repoPath,
3215
+ reject: false
3216
+ });
3217
+ throw new Error(
3218
+ `Cherry-pick ${sha.slice(0, 8)} failed: ${cp.stderr.trim() || cp.stdout.trim()}`
3219
+ );
3220
+ }
3221
+ }
3222
+ return {
3223
+ applied: true,
3224
+ method,
3225
+ targetBranch: target,
3226
+ message: `Cherry-picked ${shas.length} commit(s) from ${thread.branchName} into ${target}`
3227
+ };
3228
+ }
3229
+ const merge = await execa3(
3230
+ "git",
3231
+ ["merge", "--no-ff", "-m", `Apply Sideboard thread ${thread.branchName}`, thread.branchName],
3232
+ { cwd: thread.repoPath, reject: false }
3233
+ );
3234
+ if (merge.exitCode !== 0) {
3235
+ await execa3("git", ["merge", "--abort"], {
3236
+ cwd: thread.repoPath,
3237
+ reject: false
3238
+ });
3239
+ throw new Error(
3240
+ `Merge failed: ${merge.stderr.trim() || merge.stdout.trim()}`
3241
+ );
3168
3242
  }
3169
- const status = ok ? "ok" : failed && !stillRunning ? "failed" : stillRunning ? "running" : "idle";
3170
- const progress = extra?.progress ?? snap.progress;
3171
3243
  return {
3172
- stillRunning,
3173
- ok,
3174
- failed: Boolean(failed && !stillRunning && !ok),
3175
- status,
3176
- id,
3177
- pid: snap.pid,
3178
- exitCode: snap.exitCode ?? void 0,
3179
- phase: lastProgressLine(progress),
3180
- delta,
3181
- progress,
3182
- hint: stillRunning ? MCP_WAIT_JOB_STILL_RUNNING_HINT : void 0
3244
+ applied: true,
3245
+ method: "merge",
3246
+ targetBranch: target,
3247
+ message: `Merged ${thread.branchName} into ${target} at ${thread.repoPath}`
3183
3248
  };
3184
3249
  }
3185
- async function sleepMs(ms) {
3186
- await new Promise((resolve2) => setTimeout(resolve2, ms));
3187
- }
3188
- async function waitForDetachedJob(cwd, id, opts) {
3189
- const jobId = sanitizeDetachedJobId(id);
3190
- const root = cwd.trim() || process.cwd();
3191
- const dir = resolveJobDir(root, jobId);
3192
- const timeoutMs = mcpWaitForJobTimeoutMs(opts?.timeoutMs);
3193
- const sleep = opts?.sleep ?? sleepMs;
3194
- if (!existsSync8(dir)) {
3195
- return {
3196
- stillRunning: false,
3197
- ok: false,
3198
- failed: true,
3199
- status: "failed",
3200
- id: jobId,
3201
- delta: "",
3202
- progress: "No detached job. Start one first.",
3203
- hint: "Start with detached-job.cjs start <id> -- <command>, then call wait_for_job again."
3204
- };
3250
+
3251
+ // src/git/clone-repo.ts
3252
+ import { existsSync as existsSync8 } from "fs";
3253
+ import { basename as basename2, join as join10 } from "path";
3254
+ import { execa as execa4 } from "execa";
3255
+ async function cloneRepoIntoSideboard(opts) {
3256
+ const url = opts.url.trim();
3257
+ if (!url) throw new Error("Clone URL is required");
3258
+ let name = opts.name?.trim();
3259
+ if (!name) {
3260
+ const leaf = basename2(url.replace(/\/$/, "").replace(/\.git$/, ""));
3261
+ name = leaf || "repo";
3205
3262
  }
3206
- const deadline = Date.now() + timeoutMs;
3207
- let snap = snapshotJob(dir);
3208
- if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
3209
- if (!snap.running && snap.pid == null && snap.progress === "(no log yet)") {
3210
- return toResult(jobId, snap, {
3211
- failed: true,
3212
- progress: "No detached job. Start one first."
3213
- });
3263
+ name = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
3264
+ const dest = join10(sideboardReposDir(), name);
3265
+ if (existsSync8(dest)) {
3266
+ const repoPath2 = await resolveRepoRoot(dest);
3267
+ const workspace2 = await ensureWorkspace(repoPath2);
3268
+ return { repoPath: repoPath2, workspace: workspace2 };
3214
3269
  }
3215
- while (Date.now() < deadline) {
3216
- snap = snapshotJob(dir);
3217
- if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
3218
- if (!snap.running) return toResult(jobId, snap, { failed: true });
3219
- await sleep(Math.min(2e3, Math.max(50, deadline - Date.now())));
3270
+ const result = await execa4("git", ["clone", url, dest], { reject: false });
3271
+ if (result.exitCode !== 0) {
3272
+ throw new Error(
3273
+ `git clone failed: ${result.stderr?.trim() || result.stdout?.trim() || `exit ${result.exitCode}`}`
3274
+ );
3220
3275
  }
3221
- snap = snapshotJob(dir);
3222
- return toResult(jobId, snap);
3276
+ const repoPath = await resolveRepoRoot(dest);
3277
+ const workspace = await ensureWorkspace(repoPath);
3278
+ return { repoPath, workspace };
3223
3279
  }
3224
- function killProcessTree(pid, signal) {
3225
- if (!Number.isInteger(pid) || pid <= 0) return;
3226
- if (process.platform !== "win32") {
3227
- try {
3228
- process.kill(-pid, signal);
3229
- return;
3230
- } catch {
3231
- }
3232
- }
3280
+
3281
+ // src/types/thread.ts
3282
+ function isAdoptableRunScriptRequest(req) {
3283
+ return Boolean(req && !req.fulfilledAt && !req.error);
3284
+ }
3285
+
3286
+ // src/store/desktop-host.ts
3287
+ import { readFileSync as readFileSync7, unlinkSync, writeFileSync as writeFileSync6 } from "fs";
3288
+ import { join as join11 } from "path";
3289
+ function desktopHostPidPath() {
3290
+ return join11(appDataDir(), "desktop-host.pid");
3291
+ }
3292
+ function readDesktopHostPid() {
3233
3293
  try {
3234
- process.kill(pid, signal);
3294
+ const pid = Number.parseInt(readFileSync7(desktopHostPidPath(), "utf8").trim(), 10);
3295
+ if (!Number.isFinite(pid) || pid <= 0) return null;
3296
+ return pid;
3235
3297
  } catch {
3298
+ return null;
3236
3299
  }
3237
3300
  }
3238
- function appendJobLog(logFile, line) {
3301
+ function pidAlive(pid) {
3239
3302
  try {
3240
- mkdirSync5(dirname4(logFile), { recursive: true });
3241
- const body = line.endsWith("\n") ? line : `${line}
3242
- `;
3243
- appendFileSync2(logFile, body);
3303
+ process.kill(pid, 0);
3304
+ return true;
3244
3305
  } catch {
3306
+ return false;
3245
3307
  }
3246
3308
  }
3247
- async function stopDetachedJob(cwd, id, opts) {
3248
- const jobId = sanitizeDetachedJobId(id);
3249
- const root = cwd.trim() || process.cwd();
3250
- const dir = resolveJobDir(root, jobId);
3251
- const sleep = opts?.sleep ?? sleepMs;
3252
- const why = opts?.reason?.trim() ?? "";
3253
- if (!existsSync8(dir)) {
3254
- return {
3255
- stillRunning: false,
3256
- ok: false,
3257
- failed: true,
3258
- status: "failed",
3259
- id: jobId,
3260
- delta: "",
3261
- progress: "No detached job. Start one first.",
3262
- hint: "Start with detached-job.cjs start <id> -- <command>, then wait_for_job or stop_job.",
3263
- stopped: false,
3264
- reason: "not-found"
3265
- };
3266
- }
3267
- let snap = snapshotJob(dir);
3268
- if (!snap.running) {
3269
- const result2 = toResult(jobId, snap);
3270
- return {
3271
- ...result2,
3272
- stopped: false,
3273
- reason: "not-running",
3274
- hint: "That job is already finished. Read the log; do not wait again unless you start a new command."
3275
- };
3276
- }
3277
- appendJobLog(snap.log, `$ stop${why ? `: ${why}` : ""}`);
3278
- const targetPid = snap.pid;
3279
- if (targetPid != null) killProcessTree(targetPid, "SIGTERM");
3280
- const graceMs = Number.isFinite(opts?.graceMs) ? Math.max(50, opts.graceMs) : 2e3;
3281
- const deadline = Date.now() + graceMs;
3282
- while (Date.now() < deadline && jobTreeAlive(targetPid)) {
3283
- await sleep(Math.min(100, Math.max(20, deadline - Date.now())));
3284
- }
3285
- if (targetPid != null) {
3286
- killProcessTree(targetPid, "SIGKILL");
3287
- await sleep(100);
3288
- }
3289
- const exitFile = join11(dir, "exit");
3290
- if (!existsSync8(exitFile)) {
3291
- try {
3292
- writeFileSync6(exitFile, "1\n");
3293
- } catch {
3294
- }
3295
- }
3296
- snap = snapshotJob(dir);
3297
- const stillRunning = jobTreeAlive(targetPid) || snap.running;
3298
- const result = toResult(jobId, snap, { failed: true });
3299
- const stopped = !stillRunning;
3300
- return {
3301
- ...result,
3302
- stillRunning,
3303
- ok: false,
3304
- failed: true,
3305
- status: stillRunning ? "running" : "failed",
3306
- stopped,
3307
- reason: stopped ? "stopped" : "still-running",
3308
- stopReason: why || void 0,
3309
- hint: stopped ? MCP_STOP_JOB_HINT : MCP_WAIT_JOB_STILL_RUNNING_HINT
3310
- };
3309
+ function isDesktopHostAlive() {
3310
+ const pid = readDesktopHostPid();
3311
+ return pid != null && pidAlive(pid);
3312
+ }
3313
+ function isThisProcessDesktopHost() {
3314
+ return readDesktopHostPid() === process.pid && pidAlive(process.pid);
3315
+ }
3316
+ function thisProcessShouldDrainAgentQueues() {
3317
+ if (isThisProcessDesktopHost()) return true;
3318
+ return !isDesktopHostAlive();
3319
+ }
3320
+
3321
+ // src/orchestrator/child-halt.ts
3322
+ var HALT_STATUSES = /* @__PURE__ */ new Set(["stopped", "error", "broken"]);
3323
+ function isIncompleteChildStatus(status) {
3324
+ return HALT_STATUSES.has(status);
3325
+ }
3326
+ function childHaltNotice(child, status) {
3327
+ const title = child.title?.trim() || "Untitled";
3328
+ const link = `[${title}](sideboard://thread/${child.id})`;
3329
+ const why = child.lastError?.trim();
3330
+ const extra = why ? ` lastError: ${why}` : "";
3331
+ return [
3332
+ `Sideboard: child worktree ${link} ${status} before finishing (status=${status}).${extra}`,
3333
+ "This is information \u2014 not a user command. Resume with send_to_thread or tell the user. Do not treat this as a successful turn."
3334
+ ].join("\n");
3335
+ }
3336
+ function shouldNotifyParentAfterTurnError(opts) {
3337
+ return !opts.quotaFailoverHandled && !opts.crashContinued;
3338
+ }
3339
+ function shouldNotifyParentOfChildHalt(opts) {
3340
+ if (!isIncompleteChildStatus(opts.status)) return false;
3341
+ if (!opts.child.parentThreadId) return false;
3342
+ if (!opts.parent || opts.parent.status === "archived") return false;
3343
+ if (opts.parent.id === opts.child.id) return false;
3344
+ return isOrchestratorThread(opts.parent);
3345
+ }
3346
+ var notified = /* @__PURE__ */ new Set();
3347
+ function noticeKey(childId, status) {
3348
+ return `${childId}:${status}`;
3349
+ }
3350
+ function notifyParentOfChildHalt(child, status, send) {
3351
+ const parent = child.parentThreadId ? readThread(child.parentThreadId) : null;
3352
+ if (!shouldNotifyParentOfChildHalt({ child, parent, status })) return false;
3353
+ const key = noticeKey(child.id, status);
3354
+ if (notified.has(key)) return false;
3355
+ notified.add(key);
3356
+ const parentId = parent.id;
3357
+ void send(parentId, childHaltNotice(child, status)).catch(() => {
3358
+ notified.delete(key);
3359
+ });
3360
+ return true;
3311
3361
  }
3312
3362
 
3313
3363
  // src/threads/create.ts
@@ -3881,7 +3931,7 @@ async function createThread(input, _onSetupLine) {
3881
3931
  });
3882
3932
  await requireAgent(resolved.agent);
3883
3933
  if (input.cowboy) {
3884
- const { cowboyModeEnabled } = await import("./app-settings-AGDLF75Z.js");
3934
+ const { cowboyModeEnabled } = await import("./app-settings-652J57CX.js");
3885
3935
  if (!cowboyModeEnabled()) {
3886
3936
  throw new Error(
3887
3937
  "Cowboy mode is off. Enable it in Settings \u2192 Advanced."
@@ -3939,7 +3989,7 @@ async function createThread(input, _onSetupLine) {
3939
3989
  let sourceIsFork = false;
3940
3990
  if (!input.cowboy && sourceType === "branch" && (!sourceRef || sourceRef === "HEAD" || sourceRef === "default")) {
3941
3991
  if (isAbleTimeConnected() && getIssueSource() === "abletime") {
3942
- const { ensureAbleTimeTask, issueAttachmentForAbleTimeTask } = await import("./abletime-UX4VUENQ.js");
3992
+ const { ensureAbleTimeTask, issueAttachmentForAbleTimeTask } = await import("./abletime-OI6FNSXX.js");
3943
3993
  const task = await ensureAbleTimeTask({
3944
3994
  title: title?.trim() || "Untitled work",
3945
3995
  description: input.prompt?.trim() || void 0
@@ -4502,7 +4552,7 @@ async function adoptThread(input) {
4502
4552
  messages: input.messages ?? []
4503
4553
  });
4504
4554
  writeThread(thread);
4505
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-4SFSCYKT.js");
4555
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-OIBSD5B6.js");
4506
4556
  await ensureWorkspace2(repoPath);
4507
4557
  const { ensureWorktreeSideboardIgnored: ensureWorktreeSideboardIgnored2 } = await import("./worktree-exclude-5N2KTYIT.js");
4508
4558
  await ensureWorktreeSideboardIgnored2(input.worktreePath);
@@ -7008,7 +7058,7 @@ function formatScheduledPrompt(name, prompt) {
7008
7058
  ${prompt}`;
7009
7059
  }
7010
7060
  async function defaultDeps() {
7011
- const { getOrchestrator: getOrchestrator2, startOrchestration: startOrchestration2 } = await import("./orchestrator-ZUOEBJFN.js");
7061
+ const { getOrchestrator: getOrchestrator2, startOrchestration: startOrchestration2 } = await import("./orchestrator-3IOISDFS.js");
7012
7062
  const orch = getOrchestrator2();
7013
7063
  return {
7014
7064
  findThread: (id) => findThreadByRef(id),
@@ -7333,7 +7383,7 @@ var Orchestrator = class {
7333
7383
  orphans: orphans.map((o) => ({ path: o.path, repoPath: o.repoPath }))
7334
7384
  });
7335
7385
  }
7336
- const { autoCleanupOrphansEnabled } = await import("./app-settings-AGDLF75Z.js");
7386
+ const { autoCleanupOrphansEnabled } = await import("./app-settings-652J57CX.js");
7337
7387
  if (autoCleanupOrphansEnabled() && shouldRunWorktreeCleanup() && orphans.length > 0) {
7338
7388
  await cleanupOrphanWorktrees({ repoPaths });
7339
7389
  }
@@ -7680,7 +7730,7 @@ var Orchestrator = class {
7680
7730
  }
7681
7731
  await setup;
7682
7732
  if (skipSetup) return;
7683
- const { autoRunAfterSetupEnabled } = await import("./app-settings-AGDLF75Z.js");
7733
+ const { autoRunAfterSetupEnabled } = await import("./app-settings-652J57CX.js");
7684
7734
  if (autoRunAfterSetupEnabled()) {
7685
7735
  try {
7686
7736
  await this.startDev(threadId);
@@ -7931,7 +7981,8 @@ var Orchestrator = class {
7931
7981
  if (compact.didCompact) {
7932
7982
  thread = updateThread(threadId, {
7933
7983
  messages: compact.thread.messages,
7934
- sessionId: compact.thread.sessionId
7984
+ sessionId: compact.thread.sessionId,
7985
+ compactedMessages: compact.thread.compactedMessages
7935
7986
  });
7936
7987
  this.emit({
7937
7988
  type: "context_compacted",
@@ -7945,9 +7996,6 @@ var Orchestrator = class {
7945
7996
  this.emit({ type: "error", threadId, message: `context compact failed: ${message}` });
7946
7997
  thread = this.requireThread(threadId);
7947
7998
  }
7948
- if (this.stoppedTurns.has(threadId)) {
7949
- return;
7950
- }
7951
7999
  const promptText = typeof prompt === "string" ? prompt : "";
7952
8000
  const autoContinue = isJobContinuePrompt(promptText) || promptText.startsWith("The previous agent process ended before it finished.");
7953
8001
  const parked = takePendingTurnAttachments(thread);
@@ -7964,6 +8012,14 @@ var Orchestrator = class {
7964
8012
  agent: thread.agent,
7965
8013
  orch: isOrchestratorThread(thread)
7966
8014
  });
8015
+ if (this.stoppedTurns.has(threadId)) {
8016
+ const stopped = writeLiveStatus(threadId, "stopped");
8017
+ if (stopped?.status === "stopped") {
8018
+ this.emit({ type: "status_changed", threadId, status: "stopped" });
8019
+ }
8020
+ this.emit({ type: "turn_finished", threadId, exitCode: 1 });
8021
+ return;
8022
+ }
7967
8023
  thread = this.requireThread(threadId);
7968
8024
  const gitPrompt = expandCanonicalGitRequest(promptText);
7969
8025
  const { agentPrompt: expandedPrompt } = expandComposerPrompt(
@@ -8022,7 +8078,7 @@ var Orchestrator = class {
8022
8078
  ].filter(Boolean).join("\n\n") || void 0;
8023
8079
  const prGateDirective = thread.agent !== "brightsy" && !isOrchestratorThread(thread) && mentionsPrGoal(promptText) ? formatPrGateDirective() : null;
8024
8080
  const agentPrompt = [
8025
- thread.planMode ? PLAN_MODE_INSTRUCTION : null,
8081
+ planModeTurnInstruction(thread.planMode),
8026
8082
  orchestrationReminder,
8027
8083
  prGateDirective,
8028
8084
  injectedNoticeContext,
@@ -8044,7 +8100,7 @@ var Orchestrator = class {
8044
8100
  }
8045
8101
  const isBrightsy = fresh.agent === "brightsy";
8046
8102
  const isOrchestration = isOrchestratorThread(fresh);
8047
- const { autoRenameBranchEnabled, getGithubGitAuthMode, gitBranchPrefixSetting } = await import("./app-settings-AGDLF75Z.js");
8103
+ const { autoRenameBranchEnabled, getGithubGitAuthMode, gitBranchPrefixSetting } = await import("./app-settings-652J57CX.js");
8048
8104
  const gitAuthMode = getGithubGitAuthMode();
8049
8105
  const worktreeDirective = isBrightsy || isOrchestration ? null : formatWorktreeDirective(fresh, {
8050
8106
  githubSlug: await withTimeout(
@@ -8525,6 +8581,9 @@ var Orchestrator = class {
8525
8581
  }
8526
8582
  const preferredPorts = active?.ports?.length ? active.ports : active?.port != null ? [active.port] : void 0;
8527
8583
  if (active && !existing) {
8584
+ if (typeof active.pid === "number") {
8585
+ killDetachedRunTree(active.pid, thread.worktreePath);
8586
+ }
8528
8587
  killListenersOnPorts(collectActiveRunPorts([active]));
8529
8588
  this.syncWorktreeRuns(
8530
8589
  thread.worktreePath,
@@ -8597,7 +8656,8 @@ var Orchestrator = class {
8597
8656
  scriptName: resolvedName,
8598
8657
  port: handle.port,
8599
8658
  ports: handle.ports,
8600
- startedAt
8659
+ startedAt,
8660
+ ...typeof handle.pid === "number" && handle.pid > 1 ? { pid: handle.pid } : {}
8601
8661
  };
8602
8662
  const nextRuns = [
8603
8663
  ...latestShared.activeRuns.filter((r) => r.scriptName !== resolvedName),
@@ -8743,6 +8803,9 @@ var Orchestrator = class {
8743
8803
  }
8744
8804
  const run2 = shared.activeRuns.find((r) => r.scriptName === scriptName);
8745
8805
  if (!hadHandle2 && run2) {
8806
+ if (typeof run2.pid === "number") {
8807
+ killDetachedRunTree(run2.pid, thread.worktreePath);
8808
+ }
8746
8809
  killListenersOnPorts(collectActiveRunPorts([run2]));
8747
8810
  }
8748
8811
  const remaining = shared.activeRuns.filter((r) => r.scriptName !== scriptName);
@@ -8764,6 +8827,11 @@ var Orchestrator = class {
8764
8827
  }
8765
8828
  }
8766
8829
  if (!hadHandle && shared.activeRuns.length > 0) {
8830
+ for (const run2 of shared.activeRuns) {
8831
+ if (typeof run2.pid === "number") {
8832
+ killDetachedRunTree(run2.pid, thread.worktreePath);
8833
+ }
8834
+ }
8767
8835
  killListenersOnPorts(collectActiveRunPorts(shared.activeRuns));
8768
8836
  }
8769
8837
  this.syncWorktreeRuns(thread.worktreePath, [], null);
@@ -8805,6 +8873,11 @@ var Orchestrator = class {
8805
8873
  (k) => isWorktreeRunProcessKey(k, thread.worktreePath) || siblings.some((t) => k.startsWith(`${t.id}:run:`) || k === `${t.id}:dev`)
8806
8874
  );
8807
8875
  if (!force && hasLiveHandle) continue;
8876
+ for (const run2 of shared.activeRuns) {
8877
+ if (typeof run2.pid === "number") {
8878
+ killDetachedRunTree(run2.pid, thread.worktreePath);
8879
+ }
8880
+ }
8808
8881
  killListenersOnPorts(collectActiveRunPorts(shared.activeRuns));
8809
8882
  if (shared.devPort != null) {
8810
8883
  killListenersOnPorts([shared.devPort]);
@@ -9318,7 +9391,7 @@ var Orchestrator = class {
9318
9391
  if (!titled.userSetTitle && meta.title && titled.title !== meta.title) {
9319
9392
  updateThread(thread.id, { title: meta.title });
9320
9393
  }
9321
- const { autoArchiveOnMergeEnabled } = await import("./app-settings-AGDLF75Z.js");
9394
+ const { autoArchiveOnMergeEnabled } = await import("./app-settings-652J57CX.js");
9322
9395
  const latest = this.requireThread(thread.id);
9323
9396
  if (!shouldAutoArchiveOnPrMerge({
9324
9397
  previousPrState: prevState || null,
@@ -9600,7 +9673,7 @@ var Orchestrator = class {
9600
9673
  this.enforceHistoryRetention();
9601
9674
  if (thread.repoPath && !isGlobalRepoPath(thread.repoPath)) {
9602
9675
  try {
9603
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-4SFSCYKT.js");
9676
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-OIBSD5B6.js");
9604
9677
  await ensureWorkspace2(thread.repoPath);
9605
9678
  } catch {
9606
9679
  }
@@ -9627,7 +9700,7 @@ var Orchestrator = class {
9627
9700
  await runArchiveScript(thread.repoPath, thread.worktreePath);
9628
9701
  } catch {
9629
9702
  }
9630
- const { deleteBranchOnPurgeEnabled } = await import("./app-settings-AGDLF75Z.js");
9703
+ const { deleteBranchOnPurgeEnabled } = await import("./app-settings-652J57CX.js");
9631
9704
  const deleteBranch = opts?.deleteBranch ?? deleteBranchOnPurgeEnabled();
9632
9705
  await removeWorktree(thread.repoPath, thread.worktreePath, {
9633
9706
  deleteBranch: deleteBranch ? thread.branchName : void 0
@@ -9654,7 +9727,7 @@ var Orchestrator = class {
9654
9727
  `Cowboy checkout missing: ${thread.worktreePath}. Re-add the project folder, then restore.`
9655
9728
  );
9656
9729
  }
9657
- const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-Y5FES5D6.js");
9730
+ const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-5VRZVGP3.js");
9658
9731
  const slug = thread.worktreePath.split("/").pop();
9659
9732
  const dest = thread.worktreePath;
9660
9733
  await withRepoGitLock(thread.repoPath, async () => {
@@ -9764,7 +9837,7 @@ async function startOrchestration(opts) {
9764
9837
  sourceRef: "default",
9765
9838
  ...createOpts
9766
9839
  }).catch(async () => {
9767
- const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-Y5FES5D6.js");
9840
+ const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-5VRZVGP3.js");
9768
9841
  const repo = await resolveRepoRoot2(repoPath);
9769
9842
  const def = await resolveDefaultBranch2(repo);
9770
9843
  return orch.createThread({
@@ -9774,7 +9847,7 @@ async function startOrchestration(opts) {
9774
9847
  repoPath: repo
9775
9848
  });
9776
9849
  });
9777
- const { updateThread: upd } = await import("./thread-store-YOPKY74K.js");
9850
+ const { updateThread: upd } = await import("./thread-store-JQB7KZJR.js");
9778
9851
  const updated = upd(thread.id, {
9779
9852
  sourceType: "orchestration",
9780
9853
  sourceRef: goal
@@ -9790,14 +9863,14 @@ export {
9790
9863
  needsCoordinatorAction,
9791
9864
  AGENT_GIT_ACTIONS,
9792
9865
  threadsSharingWorktree,
9793
- MAX_RUN_LOG_CHARS,
9794
- DEFAULT_RUN_LOG_TAIL_CHARS,
9795
- sliceRunLogOutput,
9796
9866
  mcpWaitForTurnTimeoutMs,
9797
9867
  mcpWaitTaskHint,
9798
9868
  mcpWaitForJobTimeoutMs,
9799
9869
  waitForDetachedJob,
9800
9870
  stopDetachedJob,
9871
+ MAX_RUN_LOG_CHARS,
9872
+ DEFAULT_RUN_LOG_TAIL_CHARS,
9873
+ sliceRunLogOutput,
9801
9874
  BOARD_COLUMN_DEFS,
9802
9875
  HOME_BOARD_CACHE_TTL_MS,
9803
9876
  dedupeBoardIssues,