@sideboard-ai/core 0.1.242 → 0.1.244

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-PBRIHUYM.js → agents-BQZ2CHXK.js} +9 -5
  8. package/dist/{agents-CDQCQVWU.js → agents-Q23R4WOR.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-3Q4PVZ3U.js → chunk-2QRU4IFI.js} +32 -5
  12. package/dist/{chunk-3PGFAVFL.js → chunk-43F44EKX.js} +2 -2
  13. package/dist/{chunk-LRMJ6V6U.js → chunk-4HMV53AB.js} +1506 -1465
  14. package/dist/{chunk-OKHOYI53.js → chunk-576S3HJ7.js} +5 -5
  15. package/dist/{chunk-TB6UMHIF.js → chunk-5ZFULOTC.js} +10 -2
  16. package/dist/{chunk-3AJUH66R.js → chunk-6I5YQTNK.js} +2 -1
  17. package/dist/{chunk-NJ5OXOTN.js → chunk-7HOKP4GR.js} +2 -1
  18. package/dist/{chunk-DXY4K5MP.js → chunk-BL2UGKWU.js} +3 -3
  19. package/dist/{chunk-CWHMLCLN.js → chunk-C64YQAA6.js} +3 -3
  20. package/dist/{chunk-QXYWLO6W.js → chunk-CYYQ6577.js} +28 -5
  21. package/dist/{chunk-O3UAQAYA.js → chunk-E6C5NR46.js} +10 -2
  22. package/dist/{chunk-CRAXJAYJ.js → chunk-G45QP2ZD.js} +3 -3
  23. package/dist/{chunk-BILEJG3V.js → chunk-GFRC6JQN.js} +5 -5
  24. package/dist/{chunk-YXOOSFAF.js → chunk-IMO7AZI4.js} +2 -2
  25. package/dist/{chunk-DJU54RYW.js → chunk-PGPSIEMW.js} +13 -0
  26. package/dist/{chunk-P3DCZRER.js → chunk-PPJ5JTWA.js} +1 -0
  27. package/dist/{chunk-5ELC3KK3.js → chunk-QNJXJIWJ.js} +5 -5
  28. package/dist/{chunk-JFOPOMK7.js → chunk-WDDUIGFK.js} +13 -0
  29. package/dist/{chunk-IVXPXYH2.js → chunk-WURF67ID.js} +5 -5
  30. package/dist/{chunk-R3YKDTVE.js → chunk-YJJ2A5CI.js} +1572 -1529
  31. package/dist/{chunk-QLWMHQPH.js → chunk-YLGBQVRK.js} +1 -0
  32. package/dist/{coordinator-prompt-5UINTEA6.js → coordinator-prompt-WQIICPT6.js} +4 -4
  33. package/dist/{coordinator-prompt-ATK73HYU.js → coordinator-prompt-ZBYRREHD.js} +4 -4
  34. package/dist/{global-workspace-ZJBBICFJ.js → global-workspace-2N6KISDO.js} +4 -4
  35. package/dist/{global-workspace-TD5SARNW.js → global-workspace-3SQU4GL4.js} +4 -4
  36. package/dist/index.cjs +590 -470
  37. package/dist/index.d.cts +50 -3
  38. package/dist/index.d.ts +50 -3
  39. package/dist/index.js +73 -39
  40. package/dist/mcp/run-stdio.cjs +1649 -1552
  41. package/dist/mcp/run-stdio.js +51 -37
  42. package/dist/{orchestrator-VYNPNHY4.js → orchestrator-ABF6QFE6.js} +7 -7
  43. package/dist/{orchestrator-66ZFQHW5.js → orchestrator-ZJ6SYGW7.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-JX5R4V24.js → workspaces-CFI7XVAE.js} +4 -4
  49. package/dist/{workspaces-4SFSCYKT.js → workspaces-QLKWW3O2.js} +4 -4
  50. package/dist/{worktree-AGVPKDBH.js → worktree-4UAOZUJ4.js} +5 -3
  51. package/dist/{worktree-Y5FES5D6.js → worktree-PK3QETXO.js} +5 -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-2QRU4IFI.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-576S3HJ7.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-5ZFULOTC.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-3SQU4GL4.js");
492
494
  if (isGlobalThread2(thread)) {
493
- const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-ATK73HYU.js");
495
+ const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-ZBYRREHD.js");
494
496
  ensureGlobalCoordinatorCwd2(
495
497
  isOrchestratorThread(thread) ? { orchestratorThreadId: thread.id } : void 0
496
498
  );
@@ -681,9 +683,9 @@ function gitActionExpansion(action, base) {
681
683
  case "commit-push":
682
684
  return "Commit any uncommitted work with a purpose-stating message, then push to origin (`git push -u origin HEAD`; this updates an existing PR when one is linked). Do not start a checks loop unless a goal was given.";
683
685
  case "create-draft":
684
- return "Commit, push, then create a draft PR with `gh pr create --draft -R <origin-owner/name>` (title/body from the change purpose). Update the existing PR instead if one is linked.";
686
+ return "Commit, push, then create a draft PR with `gh pr create --draft --assignee @me -R <origin-owner/name>` (title/body from the change purpose). Update the existing PR instead if one is linked. Use a different `--assignee` only if the user named someone else.";
685
687
  case "create-web":
686
- return "Commit, push, then `gh pr create --web -R <origin-owner/name>`.";
688
+ return "Commit, push, then `gh pr create --web --assignee @me -R <origin-owner/name>`. Use a different `--assignee` only if the user named someone else.";
687
689
  case "resolve-conflicts":
688
690
  return `Fetch the PR base${base ? ` (\`${base}\`)` : ""}, merge it into this branch, resolve conflicts carefully, then commit and push until the PR is mergeable.`;
689
691
  case "ready-for-review":
@@ -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,1610 +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("#"));
1734
- }
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 {
1764
- }
1765
- if (defaults.length) return defaults;
1766
- return [".env.local", ".env"];
1767
- }
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);
1778
- }
1779
- return copied;
1780
- }
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");
1785
- 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
- }
1803
- } catch {
1804
- }
1805
- cachedLoginEnv = { ...process.env };
1806
- return { ...cachedLoginEnv };
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);
1807
1769
  }
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
- }
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;
1834
1790
  }
1835
- applyWorktreePkgCacheEnv(env, opts.worktreePath);
1836
- return env;
1837
1791
  }
1838
- function pipeLines(stream, onLine) {
1839
- if (!stream || !onLine) return;
1840
- const rl = createInterface2({ input: stream });
1841
- rl.on("line", onLine);
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);
1842
1804
  }
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);
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)}`);
1849
1809
  }
1850
- return pids;
1810
+ return s;
1851
1811
  }
1852
- function listUnixListenerPids(port, selfPid) {
1812
+ function jobAlive(pid) {
1813
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1853
1814
  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);
1815
+ process.kill(pid, 0);
1816
+ return true;
1860
1817
  } catch {
1861
- return [];
1818
+ return false;
1862
1819
  }
1863
1820
  }
1864
- function listenerCwd(pid) {
1865
- if (!Number.isFinite(pid) || pid <= 0) return null;
1821
+ function processGroupAlive(pgid) {
1822
+ if (process.platform === "win32") return false;
1823
+ if (!Number.isInteger(pgid) || pgid <= 1) return false;
1866
1824
  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 {
1825
+ process.kill(-pgid, 0);
1826
+ return true;
1827
+ } catch (err) {
1828
+ return err && typeof err === "object" && "code" in err && err.code === "EPERM";
1876
1829
  }
1877
- return null;
1878
1830
  }
1879
- function realpathOrResolve(path) {
1880
- try {
1881
- return realpathSync(path);
1882
- } catch {
1883
- return resolve(path);
1884
- }
1831
+ function jobTreeAlive(pid) {
1832
+ if (pid == null) return false;
1833
+ return jobAlive(pid) || processGroupAlive(pid);
1885
1834
  }
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;
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;
1896
1839
  }
1897
- function signalPid(pid, signal) {
1898
- try {
1899
- process.kill(pid, signal);
1900
- } catch {
1901
- }
1840
+ function jobDir(root, id, legacy = false) {
1841
+ return join5(root, legacy ? LEGACY_DETACHED_JOBS_DIR : DETACHED_JOBS_DIR, id);
1902
1842
  }
1903
- function isThisWorktreeListenerCwd(cwd, worktreePath) {
1904
- const wt = realpathOrResolve(worktreePath);
1905
- const c = realpathOrResolve(cwd);
1906
- return c === wt || c.startsWith(wt + sep);
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;
1907
1849
  }
1908
- function killDetachedRunTree(pid, worktreePath) {
1909
- if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) return;
1910
- const cwd = listenerCwd(pid);
1911
- if (!cwd || !isThisWorktreeListenerCwd(cwd, worktreePath)) return;
1912
- try {
1913
- process.kill(-pid, "SIGTERM");
1914
- } catch {
1915
- signalPid(pid, "SIGTERM");
1916
- }
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);
1917
1854
  }
1918
- function killListenersOnPorts(ports) {
1919
- const selfPid = process.pid;
1920
- for (const port of ports) {
1921
- if (!Number.isFinite(port) || port <= 0) continue;
1922
- try {
1923
- if (process.platform === "win32") {
1924
- execFileSync(
1925
- "powershell",
1926
- [
1927
- "-NoProfile",
1928
- "-Command",
1929
- `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne ${selfPid} } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`
1930
- ],
1931
- { stdio: "ignore", timeout: 3e3 }
1932
- );
1933
- continue;
1934
- }
1935
- for (const pid of listUnixListenerPids(port, selfPid)) {
1936
- signalPid(pid, "SIGTERM");
1937
- }
1938
- } catch {
1939
- }
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);
1940
1867
  }
1868
+ return running.sort();
1941
1869
  }
1942
- function killStaleWorktreeListeners(ports, worktreePath, signal = "SIGTERM") {
1943
- if (!worktreePath.trim()) {
1944
- killListenersOnPorts(ports);
1945
- return;
1946
- }
1947
- if (process.platform === "win32") {
1948
- killListenersOnPorts(ports);
1949
- return;
1950
- }
1951
- const selfPid = process.pid;
1952
- for (const port of ports) {
1953
- if (!Number.isFinite(port) || port <= 0) continue;
1954
- for (const pid of listUnixListenerPids(port, selfPid)) {
1955
- const cwd = listenerCwd(pid);
1956
- if (cwd && isOtherWorktreeListenerCwd(cwd, worktreePath)) continue;
1957
- signalPid(pid, signal);
1958
- }
1959
- }
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);
1960
1876
  }
1961
- function killScriptTree(child, ports = []) {
1962
- const pid = child.pid;
1963
- if (pid) {
1964
- try {
1965
- if (process.platform === "win32") {
1966
- void execa2("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
1967
- } else {
1968
- process.kill(-pid, "SIGTERM");
1969
- setTimeout(() => {
1970
- try {
1971
- process.kill(-pid, "SIGKILL");
1972
- } catch {
1973
- }
1974
- }, 2500).unref?.();
1975
- }
1976
- } catch {
1977
- try {
1978
- child.kill("SIGTERM");
1979
- } catch {
1980
- }
1981
- }
1982
- }
1983
- killListenersOnPorts(ports);
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");
1984
1881
  }
1985
- async function spawnWorkspaceScript(command, opts) {
1986
- const loginEnv = await captureLoginEnv();
1987
- const env = buildWorkspaceScriptEnv(
1988
- {
1989
- worktreePath: opts.worktreePath,
1990
- repoPath: opts.repoPath,
1991
- workspaceName: opts.workspaceName,
1992
- defaultBranch: opts.defaultBranch,
1993
- ports: opts.ports
1994
- },
1995
- loginEnv
1996
- );
1997
- try {
1998
- mergeAgentGitAuthEnv(
1999
- env,
2000
- await resolveAgentGitAuthEnv(env, { cwd: opts.worktreePath })
2001
- );
2002
- } catch {
2003
- }
2004
- if (opts.heldPorts?.length) {
2005
- await Promise.all(opts.heldPorts.map((h) => h.release()));
2006
- }
2007
- const shell = process.platform === "darwin" ? "zsh" : "bash";
2008
- const child = execa2(shell, ["-lc", command], {
2009
- cwd: opts.worktreePath,
2010
- reject: false,
2011
- env,
2012
- // Own process group so Stop can tear down the whole tree (not just the shell).
2013
- // There is no settings.toml `stop=` / teardown hook for run scripts.
2014
- ...process.platform === "win32" ? {} : { detached: true }
2015
- });
2016
- pipeLines(child.stdout, opts.onLine);
2017
- pipeLines(child.stderr, opts.onLine);
2018
- const ports = opts.ports ?? [];
2019
- return {
2020
- pid: child.pid,
2021
- kill: () => killScriptTree(child, ports),
2022
- done: child.then((r) => r.exitCode ?? null),
2023
- child
2024
- };
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");
2025
1893
  }
2026
- async function attachAbort(handle, signal) {
2027
- if (signal) {
2028
- const onAbort = () => handle.kill();
2029
- if (signal.aborted) onAbort();
2030
- else signal.addEventListener("abort", onAbort, { once: true });
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)}`;
2031
1902
  }
2032
- return handle.done;
1903
+ return out;
2033
1904
  }
2034
- async function runSetupScript(repoPath, worktreePath, onLine, opts) {
2035
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2036
- if (!settings?.setup) return { ran: false, exitCode: null, source: null };
2037
- const source = workspaceSettingsSourceLabel(worktreePath, repoPath);
2038
- onLine?.(`[setup] ${source ?? "settings.toml"}`);
2039
- const handle = await spawnWorkspaceScript(settings.setup, {
2040
- worktreePath,
2041
- repoPath,
2042
- defaultBranch: opts?.defaultBranch,
2043
- onLine
2044
- });
2045
- const exitCode = await attachAbort(handle, opts?.signal);
2046
- return {
2047
- ran: true,
2048
- exitCode,
2049
- source,
2050
- kill: handle.kill
2051
- };
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(" ");
2052
1913
  }
2053
- async function runConventionSetup(repoPath, worktreePath, onLine, opts) {
2054
- const found = findConventionSetup(worktreePath, repoPath);
2055
- if (!found) return { ran: false, exitCode: null, source: null };
2056
- onLine?.(`[setup] ${found.source}`);
2057
- const handle = await spawnWorkspaceScript(found.command, {
2058
- worktreePath,
2059
- repoPath,
2060
- defaultBranch: opts?.defaultBranch,
2061
- onLine
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(" ");
1920
+ }
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);
2062
1927
  });
2063
- const exitCode = await attachAbort(handle, opts?.signal);
2064
- return {
2065
- ran: true,
2066
- exitCode,
2067
- source: found.source,
2068
- kill: handle.kill
2069
- };
2070
1928
  }
2071
- async function runWorkspaceSetup(repoPath, worktreePath, onLine, opts) {
2072
- await ensureWorktreeSideboardIgnored(worktreePath);
2073
- const review = ensureReviewGuidelinesFile(worktreePath, repoPath);
2074
- if (review.wrote) {
2075
- onLine?.(`[setup] wrote ${review.path} \u2014 local Review guidelines`);
2076
- }
2077
- let setup = await runSetupScript(repoPath, worktreePath, onLine, opts);
2078
- if (!setup.ran) {
2079
- 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
+ };
2080
1941
  }
2081
- if (!setup.ran) {
2082
- setup = await runConventionSetup(repoPath, worktreePath, onLine, opts);
1942
+ if (looksLikeDeferredDonePromise(opts.chatText) && !opts.alreadyNudged) {
1943
+ return { action: "nudge", prompt: formatDeferredDoneContinuePrompt() };
2083
1944
  }
2084
- return setup;
1945
+ return { action: "none" };
2085
1946
  }
2086
- async function runArchiveScript(repoPath, worktreePath, onLine) {
2087
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2088
- if (!settings?.archive) return { ran: false, exitCode: null };
2089
- const handle = await spawnWorkspaceScript(settings.archive, {
2090
- worktreePath,
2091
- repoPath,
2092
- onLine
2093
- });
2094
- const exitCode = await handle.done;
2095
- return { ran: true, exitCode };
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");
2096
1951
  }
2097
- function listRunScripts(worktreePath, repoPath) {
2098
- const settings = loadWorkspaceSettings(worktreePath, repoPath);
2099
- if (!settings?.runScripts.length) return [];
2100
- return settings.runScripts.filter((s) => {
2101
- if (!s.availableIn?.length) return true;
2102
- return s.availableIn.includes("local");
2103
- });
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;
2104
1955
  }
2105
- function getDefaultRunScript(worktreePath, repoPath) {
2106
- const scripts = listRunScripts(worktreePath, repoPath);
2107
- if (!scripts.length) return null;
2108
- return scripts.find((s) => s.default === true) ?? scripts.find((s) => s.name === "dev") ?? scripts.find((s) => s.name !== "all") ?? scripts[0] ?? null;
2109
- }
2110
- function getRunScript(worktreePath, repoPath, name) {
2111
- const scripts = listRunScripts(worktreePath, repoPath);
2112
- if (!scripts.length) return null;
2113
- if (name) {
2114
- return scripts.find((s) => s.name === name) ?? null;
2115
- }
2116
- return getDefaultRunScript(worktreePath, repoPath);
2117
- }
2118
- function getRunMode(worktreePath, repoPath) {
2119
- return loadWorkspaceSettings(worktreePath, repoPath)?.runMode ?? "concurrent";
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;
2120
1961
  }
2121
- async function reservePort() {
2122
- return new Promise((resolve2, reject) => {
2123
- const server = createServer();
2124
- let released = false;
2125
- const release = () => new Promise((res) => {
2126
- if (released) {
2127
- res();
2128
- return;
2129
- }
2130
- released = true;
2131
- server.close(() => res());
2132
- });
2133
- server.listen(0, "127.0.0.1", () => {
2134
- const addr = server.address();
2135
- if (!addr || typeof addr === "string") {
2136
- void release().then(() => reject(new Error("Failed to allocate port")));
2137
- return;
2138
- }
2139
- resolve2({ port: addr.port, release });
2140
- });
2141
- server.on("error", reject);
2142
- });
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 };
2143
1967
  }
2144
- async function tryReservePort(port) {
2145
- if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
2146
- return new Promise((resolve2) => {
2147
- const server = createServer();
2148
- let released = false;
2149
- const release = () => new Promise((res) => {
2150
- if (released) {
2151
- res();
2152
- return;
2153
- }
2154
- released = true;
2155
- server.close(() => res());
2156
- });
2157
- server.once("error", () => {
2158
- try {
2159
- server.close();
2160
- } catch {
2161
- }
2162
- resolve2(null);
2163
- });
2164
- server.listen(port, "127.0.0.1", () => {
2165
- resolve2({ port, release });
2166
- });
2167
- });
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
+ };
2168
1979
  }
2169
- function normalizePreferredPorts(ports, size) {
2170
- if (!ports?.length) return [];
2171
- const seen = /* @__PURE__ */ new Set();
2172
- const out = [];
2173
- for (const p of ports) {
2174
- if (!Number.isInteger(p) || p <= 0 || p > 65535 || seen.has(p)) continue;
2175
- seen.add(p);
2176
- out.push(p);
2177
- 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 {
2178
1990
  }
2179
- 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
+ };
2180
2006
  }
2181
- function delay(ms) {
2182
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2007
+ async function sleepMs(ms) {
2008
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
2183
2009
  }
2184
- async function fillPortRange(held, size) {
2185
- if (held.length >= size) return held;
2186
- const base = held[0]?.port;
2187
- const taken = new Set(held.map((h) => h.port));
2188
- try {
2189
- let offset = 0;
2190
- while (held.length < size) {
2191
- const candidate = base != null ? base + offset : null;
2192
- offset += 1;
2193
- if (candidate != null && taken.has(candidate)) continue;
2194
- const next = candidate != null ? await tryReservePort(candidate) : null;
2195
- if (next) {
2196
- held.push(next);
2197
- taken.add(next.port);
2198
- } else {
2199
- const ephemeral = await reservePort();
2200
- held.push(ephemeral);
2201
- taken.add(ephemeral.port);
2202
- }
2203
- }
2204
- return held;
2205
- } catch (err) {
2206
- await Promise.all(held.map((h) => h.release()));
2207
- 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
+ };
2208
2027
  }
2209
- }
2210
- async function tryReserveAvailablePorts(ports) {
2211
- const held = [];
2212
- for (const port of ports) {
2213
- const next = await tryReservePort(port);
2214
- 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
+ });
2215
2036
  }
2216
- return held;
2217
- }
2218
- async function reclaimPreferredPorts(ports, worktreePath) {
2219
- const primary = ports[0];
2220
- if (primary == null) return null;
2221
- const takePrimary = async () => {
2222
- const held2 = await tryReserveAvailablePorts(ports);
2223
- if (held2.some((h) => h.port === primary)) return held2;
2224
- await Promise.all(held2.map((h) => h.release()));
2225
- return null;
2226
- };
2227
- let held = await takePrimary();
2228
- if (held) return held;
2229
- if (worktreePath) {
2230
- killStaleWorktreeListeners(ports, worktreePath, "SIGTERM");
2231
- } else {
2232
- 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())));
2233
2042
  }
2234
- for (let i = 0; i < RECLAIM_TRIES; i++) {
2235
- await delay(RECLAIM_RETRY_DELAY_MS);
2236
- held = await takePrimary();
2237
- if (held) return held;
2238
- if (i === 2 && worktreePath) {
2239
- 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 {
2240
2053
  }
2241
2054
  }
2242
- return null;
2243
- }
2244
- async function reservePortRange(size = PORT_RANGE_SIZE, opts) {
2245
- const preferred = normalizePreferredPorts(opts?.preferred, size);
2246
- if (preferred.length) {
2247
- const reused = await reclaimPreferredPorts(preferred, opts?.worktreePath);
2248
- if (reused) return fillPortRange(reused, size);
2055
+ try {
2056
+ process.kill(pid, signal);
2057
+ } catch {
2249
2058
  }
2250
- const held = [await reservePort()];
2251
- return fillPortRange(held, size);
2252
2059
  }
2253
- async function startDevServer(repoPath, worktreePath, onLine, opts) {
2254
- const script = getRunScript(worktreePath, repoPath, opts?.scriptName);
2255
- if (!script) return null;
2256
- const preferred = loadWorktreeRunPorts(worktreePath, script.name) ?? opts?.preferredPorts;
2257
- const held = await reservePortRange(PORT_RANGE_SIZE, {
2258
- preferred: preferred ?? void 0,
2259
- worktreePath
2260
- });
2261
- const ports = held.map((h) => h.port);
2262
- saveWorktreeRunPorts(worktreePath, script.name, ports);
2060
+ function appendJobLog(logFile, line) {
2263
2061
  try {
2264
- const handle = await spawnWorkspaceScript(script.command, {
2265
- worktreePath,
2266
- repoPath,
2267
- ports,
2268
- heldPorts: held,
2269
- defaultBranch: opts?.defaultBranch,
2270
- onLine
2271
- });
2272
- return {
2273
- pid: handle.pid,
2274
- port: ports[0],
2275
- ports,
2276
- scriptName: script.name,
2277
- kill: handle.kill,
2278
- done: handle.done
2279
- };
2280
- } catch (err) {
2281
- await Promise.all(held.map((h) => h.release()));
2282
- throw err;
2062
+ mkdirSync4(dirname3(logFile), { recursive: true });
2063
+ const body = line.endsWith("\n") ? line : `${line}
2064
+ `;
2065
+ appendFileSync2(logFile, body);
2066
+ } catch {
2283
2067
  }
2284
2068
  }
2285
-
2286
- // src/store/setup-log.ts
2287
- import { createHash } from "crypto";
2288
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2289
- import { join as join6 } from "path";
2290
- var EMPTY = {
2291
- output: "",
2292
- running: false,
2293
- exitCode: null,
2294
- source: null
2295
- };
2296
- var MAX_SETUP_LOG_CHARS = 256e3;
2297
- function appendSetupOutput(prev, line, max = MAX_SETUP_LOG_CHARS) {
2298
- const next = prev ? `${prev}
2299
- ${line}` : line;
2300
- if (next.length <= max) return next;
2301
- const cut = next.slice(next.length - max);
2302
- const nl = cut.indexOf("\n");
2303
- return nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2304
- }
2305
- var memory = /* @__PURE__ */ new Map();
2306
- var persistTimers = /* @__PURE__ */ new Map();
2307
- function fromSnapshot(snap) {
2308
- return {
2309
- chunks: snap.output ? [snap.output] : [],
2310
- length: snap.output.length,
2311
- running: snap.running,
2312
- exitCode: snap.exitCode,
2313
- source: snap.source
2314
- };
2315
- }
2316
- function materialize(mem) {
2317
- let output = mem.chunks.join("\n");
2318
- if (output.length > MAX_SETUP_LOG_CHARS) {
2319
- output = appendSetupOutput("", output);
2320
- mem.chunks = [output];
2321
- mem.length = output.length;
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)) {
2076
+ return {
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"
2087
+ };
2088
+ }
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())));
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
+ }
2322
2117
  }
2118
+ snap = snapshotJob(dir);
2119
+ const stillRunning = jobTreeAlive(targetPid) || snap.running;
2120
+ const result = toResult(jobId, snap, { failed: true });
2121
+ const stopped = !stillRunning;
2323
2122
  return {
2324
- output,
2325
- running: mem.running,
2326
- exitCode: mem.exitCode,
2327
- 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
2328
2132
  };
2329
2133
  }
2330
- function setupLogPath(threadId) {
2331
- return join6(threadsDir(), `${threadId}.setup.log.json`);
2332
- }
2333
- function setupLogKeyForWorktree(worktreePath) {
2334
- const norm = normalizeWorktreePath(worktreePath);
2335
- 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);
2336
2142
  }
2337
- function emptySetupLog() {
2338
- 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("#"));
2339
2147
  }
2340
- var PERSIST_DEBOUNCE_MS = 500;
2341
- function persistNow(threadId) {
2342
- const timer = persistTimers.get(threadId);
2343
- if (timer) {
2344
- clearTimeout(timer);
2345
- 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)];
2346
2168
  }
2347
- const mem = memory.get(threadId);
2348
- if (!mem) return;
2169
+ const defaults = [];
2349
2170
  try {
2350
- writeFileSync3(
2351
- setupLogPath(threadId),
2352
- `${JSON.stringify(materialize(mem))}
2353
- `,
2354
- "utf8"
2355
- );
2171
+ for (const entry of readdirSync2(repoPath, { withFileTypes: true })) {
2172
+ if (entry.isFile() && entry.name.startsWith(".env")) {
2173
+ defaults.push(entry.name);
2174
+ }
2175
+ }
2356
2176
  } catch {
2357
2177
  }
2178
+ if (defaults.length) return defaults;
2179
+ return [".env.local", ".env"];
2358
2180
  }
2359
- function schedulePersist(threadId) {
2360
- if (persistTimers.has(threadId)) return;
2361
- persistTimers.set(
2362
- threadId,
2363
- setTimeout(() => {
2364
- persistTimers.delete(threadId);
2365
- persistNow(threadId);
2366
- }, PERSIST_DEBOUNCE_MS)
2367
- );
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;
2368
2193
  }
2369
- function loadFromDisk(threadId) {
2370
- const path = setupLogPath(threadId);
2371
- 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");
2372
2198
  try {
2373
- const raw = JSON.parse(readFileSync4(path, "utf8"));
2374
- return {
2375
- output: typeof raw.output === "string" ? raw.output : "",
2376
- running: Boolean(raw.running),
2377
- exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2378
- source: typeof raw.source === "string" ? raw.source : null
2379
- };
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
+ }
2380
2216
  } catch {
2381
- return null;
2382
2217
  }
2218
+ cachedLoginEnv = { ...process.env };
2219
+ return { ...cachedLoginEnv };
2383
2220
  }
2384
- function memFor(threadId) {
2385
- const cached = memory.get(threadId);
2386
- if (cached) return cached;
2387
- const mem = fromSnapshot(loadFromDisk(threadId) ?? emptySetupLog());
2388
- memory.set(threadId, mem);
2389
- return mem;
2390
- }
2391
- function readSetupLog(threadId) {
2392
- const cached = memory.get(threadId);
2393
- if (cached) return materialize(cached);
2394
- const disk = loadFromDisk(threadId);
2395
- if (disk) {
2396
- memory.set(threadId, fromSnapshot(disk));
2397
- 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
+ }
2398
2247
  }
2399
- return emptySetupLog();
2248
+ applyWorktreePkgCacheEnv(env, opts.worktreePath);
2249
+ return env;
2400
2250
  }
2401
- function beginSetupLog(threadId) {
2402
- const mem = {
2403
- chunks: [],
2404
- length: 0,
2405
- running: true,
2406
- exitCode: null,
2407
- source: null
2408
- };
2409
- memory.set(threadId, mem);
2410
- persistNow(threadId);
2411
- return materialize(mem);
2251
+ function pipeLines(stream, onLine) {
2252
+ if (!stream || !onLine) return;
2253
+ const rl = createInterface2({ input: stream });
2254
+ rl.on("line", onLine);
2412
2255
  }
2413
- function appendSetupLog(threadId, line) {
2414
- const mem = memFor(threadId);
2415
- mem.chunks.push(line);
2416
- mem.length += line.length + 1;
2417
- mem.running = true;
2418
- if (mem.length > MAX_SETUP_LOG_CHARS * 2) materialize(mem);
2419
- schedulePersist(threadId);
2420
- }
2421
- function finishSetupLog(threadId, exitCode, source) {
2422
- const mem = memFor(threadId);
2423
- mem.running = false;
2424
- mem.exitCode = exitCode;
2425
- mem.source = source ?? mem.source;
2426
- persistNow(threadId);
2427
- return materialize(mem);
2428
- }
2429
-
2430
- // src/store/run-log.ts
2431
- import { createHash as createHash2 } from "crypto";
2432
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2433
- import { join as join7 } from "path";
2434
- var EMPTY2 = {
2435
- output: "",
2436
- running: false,
2437
- exitCode: null,
2438
- scriptName: null
2439
- };
2440
- var MAX_RUN_LOG_CHARS = 256e3;
2441
- var DEFAULT_RUN_LOG_TAIL_CHARS = 16e3;
2442
- var memory2 = /* @__PURE__ */ new Map();
2443
- var persistTimers2 = /* @__PURE__ */ new Map();
2444
- function fromSnapshot2(snap) {
2445
- return {
2446
- chunks: snap.output ? [snap.output] : [],
2447
- length: snap.output.length,
2448
- running: snap.running,
2449
- exitCode: snap.exitCode,
2450
- scriptName: snap.scriptName
2451
- };
2452
- }
2453
- function materialize2(mem) {
2454
- let output = mem.chunks.join("\n");
2455
- if (output.length > MAX_RUN_LOG_CHARS) {
2456
- output = appendSetupOutput("", output, MAX_RUN_LOG_CHARS);
2457
- mem.chunks = [output];
2458
- mem.length = output.length;
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);
2459
2262
  }
2460
- return {
2461
- output,
2462
- running: mem.running,
2463
- exitCode: mem.exitCode,
2464
- scriptName: mem.scriptName
2465
- };
2466
- }
2467
- function safeScriptToken(scriptName) {
2468
- const token = scriptName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
2469
- return token || "dev";
2470
- }
2471
- function runLogPath(key) {
2472
- return join7(threadsDir(), `${key}.run.log.json`);
2473
- }
2474
- function runLogKeyForWorktree(worktreePath, scriptName) {
2475
- const norm = normalizeWorktreePath(worktreePath);
2476
- const hash = createHash2("sha1").update(norm).digest("hex").slice(0, 16);
2477
- return `wt-${hash}-run-${safeScriptToken(scriptName)}`;
2478
- }
2479
- function emptyRunLog() {
2480
- return { ...EMPTY2 };
2481
- }
2482
- function sliceRunLogOutput(output, tailChars = DEFAULT_RUN_LOG_TAIL_CHARS) {
2483
- const max = Math.max(1, tailChars);
2484
- const outputChars = output.length;
2485
- if (outputChars <= max) return { output, truncated: false, outputChars };
2486
- const cut = output.slice(outputChars - max);
2487
- const nl = cut.indexOf("\n");
2488
- const text = nl >= 0 && nl < cut.length - 1 ? cut.slice(nl + 1) : cut;
2489
- return { output: text, truncated: true, outputChars };
2263
+ return pids;
2490
2264
  }
2491
- var PERSIST_DEBOUNCE_MS2 = 500;
2492
- function persistNow2(key) {
2493
- const timer = persistTimers2.get(key);
2494
- if (timer) {
2495
- clearTimeout(timer);
2496
- persistTimers2.delete(key);
2497
- }
2498
- const mem = memory2.get(key);
2499
- if (!mem) return;
2265
+ function listUnixListenerPids(port, selfPid) {
2500
2266
  try {
2501
- writeFileSync4(runLogPath(key), `${JSON.stringify(materialize2(mem))}
2502
- `, "utf8");
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);
2503
2273
  } catch {
2274
+ return [];
2504
2275
  }
2505
2276
  }
2506
- function schedulePersist2(key) {
2507
- if (persistTimers2.has(key)) return;
2508
- persistTimers2.set(
2509
- key,
2510
- setTimeout(() => {
2511
- persistTimers2.delete(key);
2512
- persistNow2(key);
2513
- }, PERSIST_DEBOUNCE_MS2)
2514
- );
2515
- }
2516
- function loadFromDisk2(key) {
2517
- const path = runLogPath(key);
2518
- if (!existsSync5(path)) return null;
2277
+ function listenerCwd(pid) {
2278
+ if (!Number.isFinite(pid) || pid <= 0) return null;
2519
2279
  try {
2520
- const raw = JSON.parse(readFileSync5(path, "utf8"));
2521
- return {
2522
- output: typeof raw.output === "string" ? raw.output : "",
2523
- running: Boolean(raw.running),
2524
- exitCode: typeof raw.exitCode === "number" ? raw.exitCode : null,
2525
- scriptName: typeof raw.scriptName === "string" ? raw.scriptName : null
2526
- };
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
+ }
2527
2288
  } catch {
2528
- return null;
2529
2289
  }
2290
+ return null;
2530
2291
  }
2531
- function memFor2(key) {
2532
- const cached = memory2.get(key);
2533
- if (cached) return cached;
2534
- const mem = fromSnapshot2(loadFromDisk2(key) ?? emptyRunLog());
2535
- memory2.set(key, mem);
2536
- return mem;
2537
- }
2538
- function readRunLog(key) {
2539
- const cached = memory2.get(key);
2540
- if (cached) return materialize2(cached);
2541
- const disk = loadFromDisk2(key);
2542
- if (disk) {
2543
- memory2.set(key, fromSnapshot2(disk));
2544
- return disk;
2292
+ function realpathOrResolve(path) {
2293
+ try {
2294
+ return realpathSync(path);
2295
+ } catch {
2296
+ return resolve(path);
2545
2297
  }
2546
- return emptyRunLog();
2547
- }
2548
- function beginRunLog(key, scriptName) {
2549
- const mem = {
2550
- chunks: [],
2551
- length: 0,
2552
- running: true,
2553
- exitCode: null,
2554
- scriptName
2555
- };
2556
- memory2.set(key, mem);
2557
- persistNow2(key);
2558
- return materialize2(mem);
2559
2298
  }
2560
- function appendRunLog(key, line) {
2561
- const mem = memFor2(key);
2562
- mem.chunks.push(line);
2563
- mem.length += line.length + 1;
2564
- mem.running = true;
2565
- if (mem.length > MAX_RUN_LOG_CHARS * 2) materialize2(mem);
2566
- schedulePersist2(key);
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;
2567
2309
  }
2568
- function finishRunLog(key, exitCode) {
2569
- const mem = memFor2(key);
2570
- mem.running = false;
2571
- mem.exitCode = exitCode;
2572
- persistNow2(key);
2573
- return materialize2(mem);
2310
+ function signalPid(pid, signal) {
2311
+ try {
2312
+ process.kill(pid, signal);
2313
+ } catch {
2314
+ }
2574
2315
  }
2575
- function flushRunLog(key) {
2576
- persistNow2(key);
2316
+ function isThisWorktreeListenerCwd(cwd, worktreePath) {
2317
+ const wt = realpathOrResolve(worktreePath);
2318
+ const c = realpathOrResolve(cwd);
2319
+ return c === wt || c.startsWith(wt + sep);
2577
2320
  }
2578
-
2579
- // src/git/orphan-cleanup.ts
2580
- import { existsSync as existsSync6, readdirSync as readdirSync2, statSync } from "fs";
2581
- import { join as join8 } from "path";
2582
- function isSideboardWorktreePath(path) {
2583
- return path.includes("/.sideboard/worktrees/") || path.includes("/sideboard/workspaces/");
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;
2328
+ }
2329
+ try {
2330
+ process.kill(-pid, "SIGTERM");
2331
+ } catch {
2332
+ signalPid(pid, "SIGTERM");
2333
+ }
2584
2334
  }
2585
- async function findOrphanWorktrees(repoPaths) {
2586
- const threads = listThreads({ includeArchived: true });
2587
- const known = new Set(threads.map((t) => t.worktreePath.replace(/\/$/, "")));
2588
- const repos = new Set(
2589
- repoPaths?.length ? repoPaths : threads.map((t) => t.repoPath).filter(Boolean)
2590
- );
2591
- const homeRoot = sideboardWorkspacesDir();
2592
- if (existsSync6(homeRoot)) {
2335
+ function killListenersOnPorts(ports) {
2336
+ const selfPid = process.pid;
2337
+ for (const port of ports) {
2338
+ if (!Number.isFinite(port) || port <= 0) continue;
2593
2339
  try {
2594
- for (const entry of readdirSync2(homeRoot, { withFileTypes: true })) {
2595
- if (!entry.isDirectory()) continue;
2596
- void entry;
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;
2597
2351
  }
2598
- } catch {
2599
- }
2600
- }
2601
- const orphans = [];
2602
- const seen = /* @__PURE__ */ new Set();
2603
- for (const repoPath of repos) {
2604
- if (!repoPath || !existsSync6(repoPath)) continue;
2605
- try {
2606
- const wts = await listWorktrees(repoPath);
2607
- for (const wt of wts) {
2608
- const path = wt.path.replace(/\/$/, "");
2609
- if (!isSideboardWorktreePath(path)) continue;
2610
- if (known.has(path)) continue;
2611
- if (seen.has(path)) continue;
2612
- seen.add(path);
2613
- let mtimeMs = 0;
2614
- try {
2615
- mtimeMs = statSync(path).mtimeMs;
2616
- } catch {
2617
- mtimeMs = 0;
2618
- }
2619
- orphans.push({ path, repoPath, mtimeMs });
2620
- }
2621
- } catch {
2622
- }
2623
- try {
2624
- const root = worktreesRoot(repoPath);
2625
- if (existsSync6(root)) {
2626
- for (const entry of readdirSync2(root, { withFileTypes: true })) {
2627
- if (!entry.isDirectory()) continue;
2628
- const path = join8(root, entry.name).replace(/\/$/, "");
2629
- if (known.has(path) || seen.has(path)) continue;
2630
- if (!existsSync6(join8(path, ".git"))) continue;
2631
- seen.add(path);
2632
- let mtimeMs = 0;
2633
- try {
2634
- mtimeMs = statSync(path).mtimeMs;
2635
- } catch {
2636
- mtimeMs = Date.now();
2637
- }
2638
- orphans.push({ path, repoPath, mtimeMs });
2639
- }
2352
+ for (const pid of listUnixListenerPids(port, selfPid)) {
2353
+ signalPid(pid, "SIGTERM");
2640
2354
  }
2641
2355
  } catch {
2642
2356
  }
2643
2357
  }
2644
- return orphans.sort((a, b) => a.mtimeMs - b.mtimeMs);
2645
2358
  }
2646
- async function cleanupOrphanWorktrees(opts) {
2647
- const settings = loadAppSettings();
2648
- const maxCount = opts?.maxCount ?? settings.advanced.worktreeMaxCount ?? 25;
2649
- const orphans = await findOrphanWorktrees(opts?.repoPaths);
2650
- const activeCount = listThreads().filter((t) => t.status !== "archived").length;
2651
- const budget = Math.max(0, maxCount - activeCount);
2652
- const sortedNewestFirst = [...orphans].sort((a, b) => b.mtimeMs - a.mtimeMs);
2653
- const keep = new Set(sortedNewestFirst.slice(0, budget).map((o) => o.path));
2654
- const removed = [];
2655
- const kept = [];
2656
- for (const orphan of orphans) {
2657
- if (keep.has(orphan.path)) {
2658
- kept.push(orphan.path);
2659
- continue;
2660
- }
2661
- if (opts?.dryRun) {
2662
- removed.push(orphan.path);
2663
- 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);
2664
2375
  }
2376
+ }
2377
+ }
2378
+ function killScriptTree(child, ports = []) {
2379
+ const pid = child.pid;
2380
+ if (pid) {
2665
2381
  try {
2666
- await removeWorktree(orphan.repoPath, orphan.path);
2667
- 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
+ }
2668
2393
  } catch {
2669
- kept.push(orphan.path);
2394
+ try {
2395
+ child.kill("SIGTERM");
2396
+ } catch {
2397
+ }
2670
2398
  }
2671
2399
  }
2672
- if (!opts?.dryRun) {
2673
- updateAdvancedSettings({
2674
- worktreeLastCleanupAt: (/* @__PURE__ */ new Date()).toISOString()
2675
- });
2676
- }
2677
- return { removed, kept, orphans };
2678
- }
2679
- function shouldRunWorktreeCleanup(settings = loadAppSettings()) {
2680
- const intervalHours = settings.advanced.worktreeCleanupIntervalHours ?? 6;
2681
- const last = settings.advanced.worktreeLastCleanupAt;
2682
- if (!last) return true;
2683
- const elapsed = Date.now() - Date.parse(last);
2684
- return elapsed >= intervalHours * 36e5;
2400
+ killListenersOnPorts(ports);
2685
2401
  }
2686
-
2687
- // src/store/history-cleanup.ts
2688
- function stripArchivedTranscript(thread) {
2689
- writeThread(
2402
+ async function spawnWorkspaceScript(command, opts) {
2403
+ const loginEnv = await captureLoginEnv();
2404
+ const env = buildWorkspaceScriptEnv(
2690
2405
  {
2691
- ...thread,
2692
- attachments: [],
2693
- pendingTurnAttachments: [],
2694
- queueAttachments: [],
2695
- messages: [historyRetentionStubMessage(thread)]
2406
+ worktreePath: opts.worktreePath,
2407
+ repoPath: opts.repoPath,
2408
+ workspaceName: opts.workspaceName,
2409
+ defaultBranch: opts.defaultBranch,
2410
+ ports: opts.ports
2696
2411
  },
2697
- { touch: false }
2412
+ loginEnv
2698
2413
  );
2699
- }
2700
- function cleanupArchivedHistory(opts = {}) {
2701
- const settings = loadAppSettings();
2702
- if (!opts.force && opts.purgeOlderThanDays == null && !autoCleanupHistoryEnabled(settings)) {
2703
- return { stripped: [], purged: [] };
2414
+ try {
2415
+ mergeAgentGitAuthEnv(
2416
+ env,
2417
+ await resolveAgentGitAuthEnv(env, { cwd: opts.worktreePath })
2418
+ );
2419
+ } catch {
2704
2420
  }
2705
- const archived = listThreads({ includeArchived: true }).filter((t) => t.status === "archived");
2706
- const nowMs = opts.nowMs ?? Date.now();
2707
- const plan = opts.purgeOlderThanDays != null ? { stripIds: [], purgeIds: planHistoryAgePurge(archived, opts.purgeOlderThanDays, nowMs) } : planHistoryRetention(archived, {
2708
- maxCount: opts.maxCount ?? historyMaxCount(settings),
2709
- maxDays: opts.maxDays ?? historyMaxDays(settings),
2710
- 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 }
2711
2432
  });
2712
- const stripped = [];
2713
- const purged = [];
2714
- if (opts.dryRun) {
2715
- 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 });
2716
2448
  }
2717
- for (const id of plan.stripIds) {
2718
- const thread = readThread(id);
2719
- if (!thread || thread.status !== "archived") continue;
2720
- try {
2721
- stripArchivedTranscript(thread);
2722
- stripped.push(id);
2723
- } catch {
2724
- }
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`);
2725
2493
  }
2726
- for (const id of plan.purgeIds) {
2727
- const thread = readThread(id);
2728
- if (!thread || thread.status !== "archived") continue;
2729
- try {
2730
- deleteThreadRecord(id);
2731
- purged.push(id);
2732
- } catch {
2733
- }
2494
+ let setup = await runSetupScript(repoPath, worktreePath, onLine, opts);
2495
+ if (!setup.ran) {
2496
+ setup = await runCursorWorktreeSetup(repoPath, worktreePath, onLine);
2734
2497
  }
2735
- if (stripped.length > 0 || purged.length > 0) {
2736
- updateAdvancedSettings({
2737
- historyLastCleanupAt: new Date(nowMs).toISOString()
2738
- });
2498
+ if (!setup.ran) {
2499
+ setup = await runConventionSetup(repoPath, worktreePath, onLine, opts);
2739
2500
  }
2740
- return { stripped, purged };
2501
+ return setup;
2741
2502
  }
2742
- function shouldRunHistoryCleanup(settings = loadAppSettings()) {
2743
- if (!autoCleanupHistoryEnabled(settings)) return false;
2744
- const intervalHours = settings.advanced.historyCleanupIntervalHours ?? HISTORY_CLEANUP_INTERVAL_HOURS_DEFAULT;
2745
- const last = settings.advanced.historyLastCleanupAt;
2746
- if (!last) return true;
2747
- const elapsed = Date.now() - Date.parse(last);
2748
- 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 };
2749
2513
  }
2750
-
2751
- // src/git/apply-into-main.ts
2752
- import { execa as execa3 } from "execa";
2753
- async function applyThreadIntoMain(thread, opts) {
2754
- const method = opts?.method ?? "merge";
2755
- const target = opts?.targetBranch?.trim() || await resolveDefaultBranch(thread.repoPath);
2756
- const status = await execa3("git", ["status", "--porcelain"], {
2757
- cwd: thread.repoPath,
2758
- 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");
2759
2520
  });
2760
- if (status.stdout.trim()) {
2761
- throw new Error(
2762
- `Main checkout is dirty \u2014 commit or stash before apply-into-main (${thread.repoPath})`
2763
- );
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;
2764
2532
  }
2765
- const checkout = await execa3("git", ["checkout", target], {
2766
- cwd: thread.repoPath,
2767
- reject: false
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);
2768
2559
  });
2769
- if (checkout.exitCode !== 0) {
2770
- throw new Error(
2771
- `Failed to checkout ${target}: ${checkout.stderr.trim() || checkout.stdout.trim()}`
2772
- );
2773
- }
2774
- if (method === "cherry-pick") {
2775
- const log = await execa3(
2776
- "git",
2777
- ["log", "--reverse", "--format=%H", `${target}..${thread.branchName}`],
2778
- { cwd: thread.repoPath, reject: false }
2779
- );
2780
- const shas = log.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
2781
- if (!shas.length) {
2782
- return {
2783
- applied: false,
2784
- method,
2785
- targetBranch: target,
2786
- message: `No commits on ${thread.branchName} not already in ${target}`
2787
- };
2788
- }
2789
- for (const sha of shas) {
2790
- const cp = await execa3("git", ["cherry-pick", sha], {
2791
- cwd: thread.repoPath,
2792
- reject: false
2793
- });
2794
- if (cp.exitCode !== 0) {
2795
- await execa3("git", ["cherry-pick", "--abort"], {
2796
- cwd: thread.repoPath,
2797
- reject: false
2798
- });
2799
- throw new Error(
2800
- `Cherry-pick ${sha.slice(0, 8)} failed: ${cp.stderr.trim() || cp.stdout.trim()}`
2801
- );
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;
2802
2570
  }
2803
- }
2804
- return {
2805
- applied: true,
2806
- method,
2807
- targetBranch: target,
2808
- message: `Cherry-picked ${shas.length} commit(s) from ${thread.branchName} into ${target}`
2809
- };
2810
- }
2811
- const merge = await execa3(
2812
- "git",
2813
- ["merge", "--no-ff", "-m", `Apply Sideboard thread ${thread.branchName}`, thread.branchName],
2814
- { cwd: thread.repoPath, reject: false }
2815
- );
2816
- if (merge.exitCode !== 0) {
2817
- await execa3("git", ["merge", "--abort"], {
2818
- cwd: thread.repoPath,
2819
- reject: false
2571
+ released = true;
2572
+ server.close(() => res());
2820
2573
  });
2821
- throw new Error(
2822
- `Merge failed: ${merge.stderr.trim() || merge.stdout.trim()}`
2823
- );
2824
- }
2825
- return {
2826
- applied: true,
2827
- method: "merge",
2828
- targetBranch: target,
2829
- message: `Merged ${thread.branchName} into ${target} at ${thread.repoPath}`
2830
- };
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
+ });
2831
2585
  }
2832
-
2833
- // src/git/clone-repo.ts
2834
- import { existsSync as existsSync7 } from "fs";
2835
- import { basename as basename2, join as join9 } from "path";
2836
- import { execa as execa4 } from "execa";
2837
- async function cloneRepoIntoSideboard(opts) {
2838
- const url = opts.url.trim();
2839
- if (!url) throw new Error("Clone URL is required");
2840
- let name = opts.name?.trim();
2841
- if (!name) {
2842
- const leaf = basename2(url.replace(/\/$/, "").replace(/\.git$/, ""));
2843
- name = leaf || "repo";
2844
- }
2845
- name = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
2846
- const dest = join9(sideboardReposDir(), name);
2847
- if (existsSync7(dest)) {
2848
- const repoPath2 = await resolveRepoRoot(dest);
2849
- const workspace2 = await ensureWorkspace(repoPath2);
2850
- return { repoPath: repoPath2, workspace: workspace2 };
2851
- }
2852
- const result = await execa4("git", ["clone", url, dest], { reject: false });
2853
- if (result.exitCode !== 0) {
2854
- throw new Error(
2855
- `git clone failed: ${result.stderr?.trim() || result.stdout?.trim() || `exit ${result.exitCode}`}`
2856
- );
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;
2857
2595
  }
2858
- const repoPath = await resolveRepoRoot(dest);
2859
- const workspace = await ensureWorkspace(repoPath);
2860
- return { repoPath, workspace };
2861
- }
2862
-
2863
- // src/types/thread.ts
2864
- function isAdoptableRunScriptRequest(req) {
2865
- return Boolean(req && !req.fulfilledAt && !req.error);
2596
+ return out;
2866
2597
  }
2867
-
2868
- // src/store/desktop-host.ts
2869
- import { readFileSync as readFileSync6, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
2870
- import { join as join10 } from "path";
2871
- function desktopHostPidPath() {
2872
- return join10(appDataDir(), "desktop-host.pid");
2598
+ function delay(ms) {
2599
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2873
2600
  }
2874
- function readDesktopHostPid() {
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));
2875
2605
  try {
2876
- const pid = Number.parseInt(readFileSync6(desktopHostPidPath(), "utf8").trim(), 10);
2877
- if (!Number.isFinite(pid) || pid <= 0) return null;
2878
- return pid;
2879
- } catch {
2880
- return null;
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);
2619
+ }
2620
+ }
2621
+ return held;
2622
+ } catch (err) {
2623
+ await Promise.all(held.map((h) => h.release()));
2624
+ throw err;
2881
2625
  }
2882
2626
  }
2883
- function pidAlive(pid) {
2884
- try {
2885
- process.kill(pid, 0);
2886
- return true;
2887
- } catch {
2888
- return false;
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);
2889
2632
  }
2633
+ return held;
2890
2634
  }
2891
- function isDesktopHostAlive() {
2892
- const pid = readDesktopHostPid();
2893
- return pid != null && pidAlive(pid);
2894
- }
2895
- function isThisProcessDesktopHost() {
2896
- return readDesktopHostPid() === process.pid && pidAlive(process.pid);
2897
- }
2898
- function thisProcessShouldDrainAgentQueues() {
2899
- if (isThisProcessDesktopHost()) return true;
2900
- return !isDesktopHostAlive();
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);
2650
+ }
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
+ }
2658
+ }
2659
+ return null;
2901
2660
  }
2902
-
2903
- // src/orchestrator/child-halt.ts
2904
- var HALT_STATUSES = /* @__PURE__ */ new Set(["stopped", "error", "broken"]);
2905
- function isIncompleteChildStatus(status) {
2906
- return HALT_STATUSES.has(status);
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);
2666
+ }
2667
+ const held = [await reservePort()];
2668
+ return fillPortRange(held, size);
2907
2669
  }
2908
- function childHaltNotice(child, status) {
2909
- const title = child.title?.trim() || "Untitled";
2910
- const link = `[${title}](sideboard://thread/${child.id})`;
2911
- const why = child.lastError?.trim();
2912
- const extra = why ? ` lastError: ${why}` : "";
2913
- return [
2914
- `Sideboard: child worktree ${link} ${status} before finishing (status=${status}).${extra}`,
2915
- "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."
2916
- ].join("\n");
2917
- }
2918
- function shouldNotifyParentAfterTurnError(opts) {
2919
- return !opts.quotaFailoverHandled && !opts.crashContinued;
2920
- }
2921
- function shouldNotifyParentOfChildHalt(opts) {
2922
- if (!isIncompleteChildStatus(opts.status)) return false;
2923
- if (!opts.child.parentThreadId) return false;
2924
- if (!opts.parent || opts.parent.status === "archived") return false;
2925
- if (opts.parent.id === opts.child.id) return false;
2926
- return isOrchestratorThread(opts.parent);
2927
- }
2928
- var notified = /* @__PURE__ */ new Set();
2929
- function noticeKey(childId, status) {
2930
- return `${childId}:${status}`;
2931
- }
2932
- function notifyParentOfChildHalt(child, status, send) {
2933
- const parent = child.parentThreadId ? readThread(child.parentThreadId) : null;
2934
- if (!shouldNotifyParentOfChildHalt({ child, parent, status })) return false;
2935
- const key = noticeKey(child.id, status);
2936
- if (notified.has(key)) return false;
2937
- notified.add(key);
2938
- const parentId = parent.id;
2939
- void send(parentId, childHaltNotice(child, status)).catch(() => {
2940
- notified.delete(key);
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
2941
2677
  });
2942
- return true;
2678
+ const ports = held.map((h) => h.port);
2679
+ saveWorktreeRunPorts(worktreePath, script.name, ports);
2680
+ try {
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;
2700
+ }
2943
2701
  }
2944
2702
 
2945
- // src/mcp/wait-for-job.ts
2946
- import {
2947
- appendFileSync as appendFileSync2,
2948
- existsSync as existsSync8,
2949
- mkdirSync as mkdirSync5,
2950
- readdirSync as readdirSync3,
2951
- readFileSync as readFileSync7,
2952
- writeFileSync as writeFileSync6
2953
- } from "fs";
2954
- import { dirname as dirname4, join as join11 } from "path";
2955
-
2956
- // src/mcp/wait-for-turn.ts
2957
- var MCP_WAIT_FOR_TURN_MAX_MS = 45e3;
2958
- function mcpWaitForTurnTimeoutMs(requested) {
2959
- const n = requested ?? MCP_WAIT_FOR_TURN_MAX_MS;
2960
- if (!Number.isFinite(n)) return MCP_WAIT_FOR_TURN_MAX_MS;
2961
- return Math.min(Math.max(1e3, Math.floor(n)), MCP_WAIT_FOR_TURN_MAX_MS);
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;
2962
2721
  }
2963
- 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.";
2964
- 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.";
2965
- 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.";
2966
- var MCP_WAIT_BROKEN_HINT = "Child worktree is broken (missing on disk). Tell the user \u2014 do not treat this as success.";
2967
- 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.";
2968
- 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.";
2969
- function mcpWaitTaskHint(taskState, status) {
2970
- switch (taskState) {
2971
- case "submitted":
2972
- return MCP_WAIT_QUEUED_HINT;
2973
- case "working":
2974
- return MCP_WAIT_STILL_RUNNING_HINT;
2975
- case "input-required":
2976
- return MCP_WAIT_INPUT_REQUIRED_HINT;
2977
- case "canceled":
2978
- return MCP_WAIT_STOPPED_HINT;
2979
- case "failed":
2980
- return status === "broken" ? MCP_WAIT_BROKEN_HINT : MCP_WAIT_ERROR_HINT;
2981
- default:
2982
- return void 0;
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
+ };
2732
+ }
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;
2983
2739
  }
2740
+ return {
2741
+ output,
2742
+ running: mem.running,
2743
+ exitCode: mem.exitCode,
2744
+ source: mem.source
2745
+ };
2984
2746
  }
2985
-
2986
- // src/mcp/wait-for-job.ts
2987
- var MAX_JOB_CONTINUES = 8;
2988
- var MAX_JOB_DELTA_LINES = 80;
2989
- var MAX_JOB_DELTA_CHARS = 4e3;
2990
- var DEFERRED_DONE_MAX_CHARS = 480;
2991
- var GH_WATCH_REFRESH = "Refreshing run status every 3 seconds.";
2992
- 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.";
2993
- 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.";
2994
- var JOB_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
2995
- function mcpWaitForJobTimeoutMs(requested) {
2996
- return mcpWaitForTurnTimeoutMs(requested);
2747
+ function setupLogPath(threadId) {
2748
+ return join7(threadsDir(), `${threadId}.setup.log.json`);
2997
2749
  }
2998
- function sanitizeDetachedJobId(id) {
2999
- const s = id.trim();
3000
- if (!JOB_ID_RE.test(s)) {
3001
- throw new Error(`detached-job id must be 1\u201364 chars [A-Za-z0-9._-], got ${JSON.stringify(id)}`);
3002
- }
3003
- return s;
2750
+ function setupLogKeyForWorktree(worktreePath) {
2751
+ const norm = normalizeWorktreePath(worktreePath);
2752
+ return `wt-${createHash("sha1").update(norm).digest("hex").slice(0, 16)}`;
3004
2753
  }
3005
- function jobAlive(pid) {
3006
- if (!Number.isInteger(pid) || pid <= 0) return false;
2754
+ function emptySetupLog() {
2755
+ return { ...EMPTY };
2756
+ }
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);
2763
+ }
2764
+ const mem = memory.get(threadId);
2765
+ if (!mem) return;
3007
2766
  try {
3008
- process.kill(pid, 0);
3009
- return true;
2767
+ writeFileSync4(
2768
+ setupLogPath(threadId),
2769
+ `${JSON.stringify(materialize(mem))}
2770
+ `,
2771
+ "utf8"
2772
+ );
3010
2773
  } catch {
3011
- return false;
3012
2774
  }
3013
2775
  }
3014
- function processGroupAlive(pgid) {
3015
- if (process.platform === "win32") return false;
3016
- 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;
3017
2789
  try {
3018
- process.kill(-pgid, 0);
3019
- return true;
3020
- } catch (err) {
3021
- 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;
3022
2799
  }
3023
2800
  }
3024
- function jobTreeAlive(pid) {
3025
- if (pid == null) return false;
3026
- return jobAlive(pid) || processGroupAlive(pid);
3027
- }
3028
- function readIntFile(file) {
3029
- if (!existsSync8(file)) return null;
3030
- const n = Number.parseInt(readFileSync7(file, "utf8").trim(), 10);
3031
- 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;
3032
2807
  }
3033
- function jobDir(root, id, legacy = false) {
3034
- 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();
3035
2817
  }
3036
- function resolveJobDir(root, id) {
3037
- const modern = jobDir(root, id, false);
3038
- if (existsSync8(modern)) return modern;
3039
- const legacy = jobDir(root, id, true);
3040
- if (existsSync8(legacy)) return legacy;
3041
- 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);
3042
2829
  }
3043
- function listJobIdsIn(root, rel) {
3044
- const dir = join11(root, rel);
3045
- if (!existsSync8(dir)) return [];
3046
- 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);
3047
2837
  }
3048
- function listRunningDetachedJobs(worktreePath) {
3049
- const root = worktreePath.trim();
3050
- if (!root) return [];
3051
- const ids = /* @__PURE__ */ new Set([
3052
- ...listJobIdsIn(root, DETACHED_JOBS_DIR),
3053
- ...listJobIdsIn(root, LEGACY_DETACHED_JOBS_DIR)
3054
- ]);
3055
- const running = [];
3056
- for (const id of ids) {
3057
- const dir = resolveJobDir(root, id);
3058
- const pid = readIntFile(join11(dir, "pid"));
3059
- if (jobTreeAlive(pid)) running.push(id);
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);
2845
+ }
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
+ };
2869
+ }
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;
3060
2876
  }
3061
- return running.sort();
2877
+ return {
2878
+ output,
2879
+ running: mem.running,
2880
+ exitCode: mem.exitCode,
2881
+ scriptName: mem.scriptName
2882
+ };
3062
2883
  }
3063
- function looksLikeDeferredDonePromise(text) {
3064
- const t = (text ?? "").trim();
3065
- if (!t || t.length > DEFERRED_DONE_MAX_CHARS) return false;
3066
- const unquoted = t.replace(/`[^`]*`/g, " ").replace(/["“”][^"“”]*["“”]/g, " ").replace(/\s+/g, " ").trim();
3067
- if (!unquoted) return false;
3068
- 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);
2884
+ function safeScriptToken(scriptName) {
2885
+ const token = scriptName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
2886
+ return token || "dev";
3069
2887
  }
3070
- function isJobContinuePrompt(text) {
3071
- if (typeof text !== "string") return false;
3072
- const t = text.trim();
3073
- return t.startsWith("Detached job still running:") || t.startsWith("You ended the turn after promising to report later");
2888
+ function runLogPath(key) {
2889
+ return join8(threadsDir(), `${key}.run.log.json`);
3074
2890
  }
3075
- function collapseGhRunWatchLog(text) {
3076
- if (!text.includes(GH_WATCH_REFRESH)) return text;
3077
- const frames = text.split(/(?=Refreshing run status every 3 seconds\.)/);
3078
- const watchFrames = frames.filter((f) => f.includes(GH_WATCH_REFRESH));
3079
- if (watchFrames.length <= 1) return text;
3080
- const prefix = frames[0]?.includes(GH_WATCH_REFRESH) ? "" : frames[0] ?? "";
3081
- const last = watchFrames[watchFrames.length - 1] ?? "";
3082
- const omitted = watchFrames.length - 1;
3083
- const note = `(${omitted} identical gh run watch refresh${omitted === 1 ? "" : "es"} omitted)
3084
- `;
3085
- return [prefix.trimEnd(), note, last.trimStart()].filter(Boolean).join("\n");
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)}`;
3086
2895
  }
3087
- function capJobLogDelta(text) {
3088
- const collapsed = collapseGhRunWatchLog(text);
3089
- const lines = collapsed.split("\n");
3090
- let out = lines.length > MAX_JOB_DELTA_LINES ? `\u2026(${lines.length - MAX_JOB_DELTA_LINES} earlier lines omitted)
3091
- ${lines.slice(-MAX_JOB_DELTA_LINES).join("\n")}` : collapsed;
3092
- if (out.length > MAX_JOB_DELTA_CHARS) {
3093
- out = `\u2026(truncated ${out.length} chars)
3094
- ${out.slice(-MAX_JOB_DELTA_CHARS)}`;
3095
- }
3096
- return out;
2896
+ function emptyRunLog() {
2897
+ return { ...EMPTY2 };
3097
2898
  }
3098
- function formatJobStillRunningContinuePrompt(jobIds) {
3099
- const ids = jobIds.join(", ");
3100
- return [
3101
- `Detached job still running: ${ids}.`,
3102
- "Do not end this turn. Loop wait_for_job (same id) until stillRunning is false. The log pane updates from each wait result.",
3103
- "If the job is hanging or doing the wrong thing, call stop_job (same id) instead of looping forever.",
3104
- "Then report the result. Do not tell the user you will let them know later."
3105
- ].join(" ");
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 formatDeferredDoneContinuePrompt() {
3108
- return [
3109
- "You ended the turn after promising to report later, but no detached job is running.",
3110
- "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).",
3111
- "Do not say you will let the user know later."
3112
- ].join(" ");
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
+ }
3113
2922
  }
3114
- function turnWatchedDetachedJob(parts) {
3115
- return parts.some((p) => {
3116
- if (p.type !== "tool") return false;
3117
- if (/wait_for_job$/i.test(p.name ?? "") || /stop_job$/i.test(p.name ?? "")) return true;
3118
- const blob = [p.name, p.detail, p.description, p.input ? JSON.stringify(p.input) : ""].filter(Boolean).join(" ");
3119
- return /detached-job\.(?:js|cjs)\b/i.test(blob);
3120
- });
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
+ );
3121
2932
  }
3122
- function planJobContinue(opts) {
3123
- if (opts.isOrchestrator) return { action: "none" };
3124
- if (opts.agent === "brightsy") return { action: "none" };
3125
- if (opts.queueLength > 0) return { action: "none" };
3126
- if (opts.continueCount >= MAX_JOB_CONTINUES) return { action: "none" };
3127
- const farewell = looksLikeDeferredDonePromise(opts.chatText);
3128
- if (opts.runningJobIds.length > 0 && (farewell || opts.watchedJob)) {
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"));
3129
2938
  return {
3130
- action: "wait",
3131
- jobIds: opts.runningJobIds,
3132
- 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
3133
2943
  };
2944
+ } catch {
2945
+ return null;
3134
2946
  }
3135
- if (looksLikeDeferredDonePromise(opts.chatText) && !opts.alreadyNudged) {
3136
- 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;
3137
2962
  }
3138
- return { action: "none" };
2963
+ return emptyRunLog();
3139
2964
  }
3140
- function tailProgress(logFile, maxLines = 12) {
3141
- if (!existsSync8(logFile)) return "(no log yet)";
3142
- const lines = readFileSync7(logFile, "utf8").split("\n");
3143
- 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);
3144
2976
  }
3145
- function lastProgressLine(progress) {
3146
- const last = progress.split("\n").map((l) => l.trim()).filter((l) => l && l !== "(no log yet)").at(-1);
3147
- 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);
3148
2984
  }
3149
- function readLogLines(file) {
3150
- if (!existsSync8(file)) return [];
3151
- const lines = readFileSync7(file, "utf8").split("\n");
3152
- if (lines.length && lines[lines.length - 1] === "") lines.pop();
3153
- 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);
3154
2991
  }
3155
- function takeDelta(logFile, cursorFile) {
3156
- const lines = readLogLines(logFile);
3157
- const cursor = readIntFile(cursorFile) ?? 0;
3158
- const start = Math.min(Math.max(0, cursor), lines.length);
3159
- return { delta: capJobLogDelta(lines.slice(start).join("\n")), nextCursor: lines.length };
2992
+ function flushRunLog(key) {
2993
+ persistNow2(key);
3160
2994
  }
3161
- function snapshotJob(dir) {
3162
- const pid = readIntFile(join11(dir, "pid"));
3163
- const running = jobTreeAlive(pid);
3164
- return {
3165
- pid,
3166
- running,
3167
- exitCode: readIntFile(join11(dir, "exit")),
3168
- log: join11(dir, "log"),
3169
- cursor: join11(dir, "present.cursor"),
3170
- progress: tailProgress(join11(dir, "log"))
3171
- };
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/");
3172
3001
  }
3173
- function toResult(id, snap, extra) {
3174
- const failed = extra?.failed === true || !snap.running && snap.exitCode != null && snap.exitCode !== 0;
3175
- const ok = !snap.running && snap.exitCode === 0;
3176
- const stillRunning = snap.running && !ok;
3177
- const { delta, nextCursor } = takeDelta(snap.log, snap.cursor);
3178
- try {
3179
- mkdirSync5(dirname4(snap.cursor), { recursive: true });
3180
- writeFileSync6(snap.cursor, `${nextCursor}
3181
- `);
3182
- } 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
+ );
3183
3242
  }
3184
- const status = ok ? "ok" : failed && !stillRunning ? "failed" : stillRunning ? "running" : "idle";
3185
- const progress = extra?.progress ?? snap.progress;
3186
3243
  return {
3187
- stillRunning,
3188
- ok,
3189
- failed: Boolean(failed && !stillRunning && !ok),
3190
- status,
3191
- id,
3192
- pid: snap.pid,
3193
- exitCode: snap.exitCode ?? void 0,
3194
- phase: lastProgressLine(progress),
3195
- delta,
3196
- progress,
3197
- 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}`
3198
3248
  };
3199
3249
  }
3200
- async function sleepMs(ms) {
3201
- await new Promise((resolve2) => setTimeout(resolve2, ms));
3202
- }
3203
- async function waitForDetachedJob(cwd, id, opts) {
3204
- const jobId = sanitizeDetachedJobId(id);
3205
- const root = cwd.trim() || process.cwd();
3206
- const dir = resolveJobDir(root, jobId);
3207
- const timeoutMs = mcpWaitForJobTimeoutMs(opts?.timeoutMs);
3208
- const sleep = opts?.sleep ?? sleepMs;
3209
- if (!existsSync8(dir)) {
3210
- return {
3211
- stillRunning: false,
3212
- ok: false,
3213
- failed: true,
3214
- status: "failed",
3215
- id: jobId,
3216
- delta: "",
3217
- progress: "No detached job. Start one first.",
3218
- hint: "Start with detached-job.cjs start <id> -- <command>, then call wait_for_job again."
3219
- };
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";
3220
3262
  }
3221
- const deadline = Date.now() + timeoutMs;
3222
- let snap = snapshotJob(dir);
3223
- if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
3224
- if (!snap.running && snap.pid == null && snap.progress === "(no log yet)") {
3225
- return toResult(jobId, snap, {
3226
- failed: true,
3227
- progress: "No detached job. Start one first."
3228
- });
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 };
3229
3269
  }
3230
- while (Date.now() < deadline) {
3231
- snap = snapshotJob(dir);
3232
- if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
3233
- if (!snap.running) return toResult(jobId, snap, { failed: true });
3234
- 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
+ );
3235
3275
  }
3236
- snap = snapshotJob(dir);
3237
- return toResult(jobId, snap);
3276
+ const repoPath = await resolveRepoRoot(dest);
3277
+ const workspace = await ensureWorkspace(repoPath);
3278
+ return { repoPath, workspace };
3238
3279
  }
3239
- function killProcessTree(pid, signal) {
3240
- if (!Number.isInteger(pid) || pid <= 0) return;
3241
- if (process.platform !== "win32") {
3242
- try {
3243
- process.kill(-pid, signal);
3244
- return;
3245
- } catch {
3246
- }
3247
- }
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() {
3248
3293
  try {
3249
- 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;
3250
3297
  } catch {
3298
+ return null;
3251
3299
  }
3252
3300
  }
3253
- function appendJobLog(logFile, line) {
3301
+ function pidAlive(pid) {
3254
3302
  try {
3255
- mkdirSync5(dirname4(logFile), { recursive: true });
3256
- const body = line.endsWith("\n") ? line : `${line}
3257
- `;
3258
- appendFileSync2(logFile, body);
3303
+ process.kill(pid, 0);
3304
+ return true;
3259
3305
  } catch {
3306
+ return false;
3260
3307
  }
3261
3308
  }
3262
- async function stopDetachedJob(cwd, id, opts) {
3263
- const jobId = sanitizeDetachedJobId(id);
3264
- const root = cwd.trim() || process.cwd();
3265
- const dir = resolveJobDir(root, jobId);
3266
- const sleep = opts?.sleep ?? sleepMs;
3267
- const why = opts?.reason?.trim() ?? "";
3268
- if (!existsSync8(dir)) {
3269
- return {
3270
- stillRunning: false,
3271
- ok: false,
3272
- failed: true,
3273
- status: "failed",
3274
- id: jobId,
3275
- delta: "",
3276
- progress: "No detached job. Start one first.",
3277
- hint: "Start with detached-job.cjs start <id> -- <command>, then wait_for_job or stop_job.",
3278
- stopped: false,
3279
- reason: "not-found"
3280
- };
3281
- }
3282
- let snap = snapshotJob(dir);
3283
- if (!snap.running) {
3284
- const result2 = toResult(jobId, snap);
3285
- return {
3286
- ...result2,
3287
- stopped: false,
3288
- reason: "not-running",
3289
- hint: "That job is already finished. Read the log; do not wait again unless you start a new command."
3290
- };
3291
- }
3292
- appendJobLog(snap.log, `$ stop${why ? `: ${why}` : ""}`);
3293
- const targetPid = snap.pid;
3294
- if (targetPid != null) killProcessTree(targetPid, "SIGTERM");
3295
- const graceMs = Number.isFinite(opts?.graceMs) ? Math.max(50, opts.graceMs) : 2e3;
3296
- const deadline = Date.now() + graceMs;
3297
- while (Date.now() < deadline && jobTreeAlive(targetPid)) {
3298
- await sleep(Math.min(100, Math.max(20, deadline - Date.now())));
3299
- }
3300
- if (targetPid != null) {
3301
- killProcessTree(targetPid, "SIGKILL");
3302
- await sleep(100);
3303
- }
3304
- const exitFile = join11(dir, "exit");
3305
- if (!existsSync8(exitFile)) {
3306
- try {
3307
- writeFileSync6(exitFile, "1\n");
3308
- } catch {
3309
- }
3310
- }
3311
- snap = snapshotJob(dir);
3312
- const stillRunning = jobTreeAlive(targetPid) || snap.running;
3313
- const result = toResult(jobId, snap, { failed: true });
3314
- const stopped = !stillRunning;
3315
- return {
3316
- ...result,
3317
- stillRunning,
3318
- ok: false,
3319
- failed: true,
3320
- status: stillRunning ? "running" : "failed",
3321
- stopped,
3322
- reason: stopped ? "stopped" : "still-running",
3323
- stopReason: why || void 0,
3324
- hint: stopped ? MCP_STOP_JOB_HINT : MCP_WAIT_JOB_STILL_RUNNING_HINT
3325
- };
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;
3326
3361
  }
3327
3362
 
3328
3363
  // src/threads/create.ts
@@ -3896,7 +3931,7 @@ async function createThread(input, _onSetupLine) {
3896
3931
  });
3897
3932
  await requireAgent(resolved.agent);
3898
3933
  if (input.cowboy) {
3899
- const { cowboyModeEnabled } = await import("./app-settings-AGDLF75Z.js");
3934
+ const { cowboyModeEnabled } = await import("./app-settings-652J57CX.js");
3900
3935
  if (!cowboyModeEnabled()) {
3901
3936
  throw new Error(
3902
3937
  "Cowboy mode is off. Enable it in Settings \u2192 Advanced."
@@ -3954,7 +3989,7 @@ async function createThread(input, _onSetupLine) {
3954
3989
  let sourceIsFork = false;
3955
3990
  if (!input.cowboy && sourceType === "branch" && (!sourceRef || sourceRef === "HEAD" || sourceRef === "default")) {
3956
3991
  if (isAbleTimeConnected() && getIssueSource() === "abletime") {
3957
- const { ensureAbleTimeTask, issueAttachmentForAbleTimeTask } = await import("./abletime-UX4VUENQ.js");
3992
+ const { ensureAbleTimeTask, issueAttachmentForAbleTimeTask } = await import("./abletime-OI6FNSXX.js");
3958
3993
  const task = await ensureAbleTimeTask({
3959
3994
  title: title?.trim() || "Untitled work",
3960
3995
  description: input.prompt?.trim() || void 0
@@ -4517,7 +4552,7 @@ async function adoptThread(input) {
4517
4552
  messages: input.messages ?? []
4518
4553
  });
4519
4554
  writeThread(thread);
4520
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-4SFSCYKT.js");
4555
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-QLKWW3O2.js");
4521
4556
  await ensureWorkspace2(repoPath);
4522
4557
  const { ensureWorktreeSideboardIgnored: ensureWorktreeSideboardIgnored2 } = await import("./worktree-exclude-5N2KTYIT.js");
4523
4558
  await ensureWorktreeSideboardIgnored2(input.worktreePath);
@@ -6494,7 +6529,7 @@ function formatWorktreeDirective(thread, opts) {
6494
6529
  const slug = opts?.githubSlug ?? "<origin-owner/name>";
6495
6530
  const resolve2 = opts?.githubSlug ? "" : " Resolve `<origin-owner/name>` with `git remote get-url origin` in this worktree.";
6496
6531
  lines.push(
6497
- `- Prefer a draft PR first: \`gh pr create --draft -R ${slug}\` (update via \`gh pr edit -R ${slug}\`). Always pass \`-R\` \u2014 bare \`gh pr create\` may target upstream on dual-remote checkouts.${resolve2} Mark ready for review only when asked. If GitHub rejects the body as too long, retry with a short \`--body-file\` \u2014 do not paste a changelog or diff into \`--body\`.`
6532
+ `- Prefer a draft PR first: \`gh pr create --draft --assignee @me -R ${slug}\` (update via \`gh pr edit -R ${slug}\`). Always pass \`-R\` \u2014 bare \`gh pr create\` may target upstream on dual-remote checkouts.${resolve2} Assign to the GitHub-connected user (\`--assignee @me\`) unless the user named someone else. Mark ready for review only when asked. If GitHub rejects the body as too long, retry with a short \`--body-file\` \u2014 do not paste a changelog or diff into \`--body\`.`
6498
6533
  );
6499
6534
  }
6500
6535
  lines.push(
@@ -7023,7 +7058,7 @@ function formatScheduledPrompt(name, prompt) {
7023
7058
  ${prompt}`;
7024
7059
  }
7025
7060
  async function defaultDeps() {
7026
- const { getOrchestrator: getOrchestrator2, startOrchestration: startOrchestration2 } = await import("./orchestrator-66ZFQHW5.js");
7061
+ const { getOrchestrator: getOrchestrator2, startOrchestration: startOrchestration2 } = await import("./orchestrator-ZJ6SYGW7.js");
7027
7062
  const orch = getOrchestrator2();
7028
7063
  return {
7029
7064
  findThread: (id) => findThreadByRef(id),
@@ -7348,7 +7383,7 @@ var Orchestrator = class {
7348
7383
  orphans: orphans.map((o) => ({ path: o.path, repoPath: o.repoPath }))
7349
7384
  });
7350
7385
  }
7351
- const { autoCleanupOrphansEnabled } = await import("./app-settings-AGDLF75Z.js");
7386
+ const { autoCleanupOrphansEnabled } = await import("./app-settings-652J57CX.js");
7352
7387
  if (autoCleanupOrphansEnabled() && shouldRunWorktreeCleanup() && orphans.length > 0) {
7353
7388
  await cleanupOrphanWorktrees({ repoPaths });
7354
7389
  }
@@ -7695,7 +7730,7 @@ var Orchestrator = class {
7695
7730
  }
7696
7731
  await setup;
7697
7732
  if (skipSetup) return;
7698
- const { autoRunAfterSetupEnabled } = await import("./app-settings-AGDLF75Z.js");
7733
+ const { autoRunAfterSetupEnabled } = await import("./app-settings-652J57CX.js");
7699
7734
  if (autoRunAfterSetupEnabled()) {
7700
7735
  try {
7701
7736
  await this.startDev(threadId);
@@ -7946,7 +7981,8 @@ var Orchestrator = class {
7946
7981
  if (compact.didCompact) {
7947
7982
  thread = updateThread(threadId, {
7948
7983
  messages: compact.thread.messages,
7949
- sessionId: compact.thread.sessionId
7984
+ sessionId: compact.thread.sessionId,
7985
+ compactedMessages: compact.thread.compactedMessages
7950
7986
  });
7951
7987
  this.emit({
7952
7988
  type: "context_compacted",
@@ -7960,9 +7996,6 @@ var Orchestrator = class {
7960
7996
  this.emit({ type: "error", threadId, message: `context compact failed: ${message}` });
7961
7997
  thread = this.requireThread(threadId);
7962
7998
  }
7963
- if (this.stoppedTurns.has(threadId)) {
7964
- return;
7965
- }
7966
7999
  const promptText = typeof prompt === "string" ? prompt : "";
7967
8000
  const autoContinue = isJobContinuePrompt(promptText) || promptText.startsWith("The previous agent process ended before it finished.");
7968
8001
  const parked = takePendingTurnAttachments(thread);
@@ -7979,6 +8012,14 @@ var Orchestrator = class {
7979
8012
  agent: thread.agent,
7980
8013
  orch: isOrchestratorThread(thread)
7981
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
+ }
7982
8023
  thread = this.requireThread(threadId);
7983
8024
  const gitPrompt = expandCanonicalGitRequest(promptText);
7984
8025
  const { agentPrompt: expandedPrompt } = expandComposerPrompt(
@@ -8037,7 +8078,7 @@ var Orchestrator = class {
8037
8078
  ].filter(Boolean).join("\n\n") || void 0;
8038
8079
  const prGateDirective = thread.agent !== "brightsy" && !isOrchestratorThread(thread) && mentionsPrGoal(promptText) ? formatPrGateDirective() : null;
8039
8080
  const agentPrompt = [
8040
- thread.planMode ? PLAN_MODE_INSTRUCTION : null,
8081
+ planModeTurnInstruction(thread.planMode),
8041
8082
  orchestrationReminder,
8042
8083
  prGateDirective,
8043
8084
  injectedNoticeContext,
@@ -8059,7 +8100,7 @@ var Orchestrator = class {
8059
8100
  }
8060
8101
  const isBrightsy = fresh.agent === "brightsy";
8061
8102
  const isOrchestration = isOrchestratorThread(fresh);
8062
- const { autoRenameBranchEnabled, getGithubGitAuthMode, gitBranchPrefixSetting } = await import("./app-settings-AGDLF75Z.js");
8103
+ const { autoRenameBranchEnabled, getGithubGitAuthMode, gitBranchPrefixSetting } = await import("./app-settings-652J57CX.js");
8063
8104
  const gitAuthMode = getGithubGitAuthMode();
8064
8105
  const worktreeDirective = isBrightsy || isOrchestration ? null : formatWorktreeDirective(fresh, {
8065
8106
  githubSlug: await withTimeout(
@@ -9350,7 +9391,7 @@ var Orchestrator = class {
9350
9391
  if (!titled.userSetTitle && meta.title && titled.title !== meta.title) {
9351
9392
  updateThread(thread.id, { title: meta.title });
9352
9393
  }
9353
- const { autoArchiveOnMergeEnabled } = await import("./app-settings-AGDLF75Z.js");
9394
+ const { autoArchiveOnMergeEnabled } = await import("./app-settings-652J57CX.js");
9354
9395
  const latest = this.requireThread(thread.id);
9355
9396
  if (!shouldAutoArchiveOnPrMerge({
9356
9397
  previousPrState: prevState || null,
@@ -9632,7 +9673,7 @@ var Orchestrator = class {
9632
9673
  this.enforceHistoryRetention();
9633
9674
  if (thread.repoPath && !isGlobalRepoPath(thread.repoPath)) {
9634
9675
  try {
9635
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-4SFSCYKT.js");
9676
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-QLKWW3O2.js");
9636
9677
  await ensureWorkspace2(thread.repoPath);
9637
9678
  } catch {
9638
9679
  }
@@ -9659,7 +9700,7 @@ var Orchestrator = class {
9659
9700
  await runArchiveScript(thread.repoPath, thread.worktreePath);
9660
9701
  } catch {
9661
9702
  }
9662
- const { deleteBranchOnPurgeEnabled } = await import("./app-settings-AGDLF75Z.js");
9703
+ const { deleteBranchOnPurgeEnabled } = await import("./app-settings-652J57CX.js");
9663
9704
  const deleteBranch = opts?.deleteBranch ?? deleteBranchOnPurgeEnabled();
9664
9705
  await removeWorktree(thread.repoPath, thread.worktreePath, {
9665
9706
  deleteBranch: deleteBranch ? thread.branchName : void 0
@@ -9686,7 +9727,7 @@ var Orchestrator = class {
9686
9727
  `Cowboy checkout missing: ${thread.worktreePath}. Re-add the project folder, then restore.`
9687
9728
  );
9688
9729
  }
9689
- const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-Y5FES5D6.js");
9730
+ const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-PK3QETXO.js");
9690
9731
  const slug = thread.worktreePath.split("/").pop();
9691
9732
  const dest = thread.worktreePath;
9692
9733
  await withRepoGitLock(thread.repoPath, async () => {
@@ -9796,7 +9837,7 @@ async function startOrchestration(opts) {
9796
9837
  sourceRef: "default",
9797
9838
  ...createOpts
9798
9839
  }).catch(async () => {
9799
- const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-Y5FES5D6.js");
9840
+ const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-PK3QETXO.js");
9800
9841
  const repo = await resolveRepoRoot2(repoPath);
9801
9842
  const def = await resolveDefaultBranch2(repo);
9802
9843
  return orch.createThread({
@@ -9806,7 +9847,7 @@ async function startOrchestration(opts) {
9806
9847
  repoPath: repo
9807
9848
  });
9808
9849
  });
9809
- const { updateThread: upd } = await import("./thread-store-YOPKY74K.js");
9850
+ const { updateThread: upd } = await import("./thread-store-JQB7KZJR.js");
9810
9851
  const updated = upd(thread.id, {
9811
9852
  sourceType: "orchestration",
9812
9853
  sourceRef: goal
@@ -9822,14 +9863,14 @@ export {
9822
9863
  needsCoordinatorAction,
9823
9864
  AGENT_GIT_ACTIONS,
9824
9865
  threadsSharingWorktree,
9825
- MAX_RUN_LOG_CHARS,
9826
- DEFAULT_RUN_LOG_TAIL_CHARS,
9827
- sliceRunLogOutput,
9828
9866
  mcpWaitForTurnTimeoutMs,
9829
9867
  mcpWaitTaskHint,
9830
9868
  mcpWaitForJobTimeoutMs,
9831
9869
  waitForDetachedJob,
9832
9870
  stopDetachedJob,
9871
+ MAX_RUN_LOG_CHARS,
9872
+ DEFAULT_RUN_LOG_TAIL_CHARS,
9873
+ sliceRunLogOutput,
9833
9874
  BOARD_COLUMN_DEFS,
9834
9875
  HOME_BOARD_CACHE_TTL_MS,
9835
9876
  dedupeBoardIssues,