@sideboard-ai/core 0.1.264 → 0.1.267
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.
- package/dist/{abletime-6AOJI2YZ.js → abletime-4ZSDI4YP.js} +9 -6
- package/dist/{abletime-U75ZGDIX.js → abletime-JNQAHLHH.js} +7 -4
- package/dist/{abletime-oauth-TA2YDCYX.js → abletime-oauth-4RXZH2KU.js} +3 -3
- package/dist/{abletime-oauth-LOAHGG4X.js → abletime-oauth-DCEIINYT.js} +5 -5
- package/dist/{abletime-rest-CTA5WV26.js → abletime-rest-KVBJFVFD.js} +3 -3
- package/dist/{abletime-rest-YZPFM6IY.js → abletime-rest-VN6PZ6P2.js} +5 -5
- package/dist/agents/cursor-runner.js +7 -5
- package/dist/{agents-3TUMFSMH.js → agents-G3JTULM2.js} +7 -5
- package/dist/{agents-6FWDRVA2.js → agents-XRXJCAWJ.js} +11 -10
- package/dist/{app-settings-RKFFUIFQ.js → app-settings-L5HGYXWJ.js} +1 -3
- package/dist/{app-settings-MVZPM6WW.js → app-settings-RAC4PSSP.js} +3 -5
- package/dist/{caffeinate-hold-UAGLSGMM.js → caffeinate-hold-7JM5H5P3.js} +2 -2
- package/dist/{chunk-F5MFNUAA.js → chunk-27Z4MEQ5.js} +1 -1
- package/dist/{chunk-EK3KVT7I.js → chunk-2XIRV7FS.js} +53 -1254
- package/dist/{chunk-3H7O27X2.js → chunk-3VBS6MES.js} +39 -1017
- package/dist/chunk-4ECDIKAS.js +2273 -0
- package/dist/{chunk-H547WSSZ.js → chunk-4RZRHTSZ.js} +1 -1
- package/dist/{chunk-QN5DL2QO.js → chunk-5DZUWGKV.js} +1 -1
- package/dist/chunk-5N4Q46SG.js +392 -0
- package/dist/{chunk-YKYEHDMX.js → chunk-AGXZAMOF.js} +5 -5
- package/dist/{chunk-RKPBIB2U.js → chunk-DB7MVDYS.js} +33 -7
- package/dist/{chunk-BOMSJUXW.js → chunk-DBFAFI5A.js} +5 -5
- package/dist/chunk-FAGK33E2.js +994 -0
- package/dist/{chunk-A5SAYFJ4.js → chunk-FIBZEFKA.js} +4 -390
- package/dist/{chunk-5PK53SAP.js → chunk-GONPMH36.js} +5 -5
- package/dist/{chunk-L5WGAIL2.js → chunk-GZXKUUJO.js} +35 -9
- package/dist/{chunk-Z2LGMV6C.js → chunk-H5XL4KUS.js} +21 -0
- package/dist/{chunk-T3AYRQJZ.js → chunk-HSRN2CSL.js} +473 -2487
- package/dist/{chunk-TJ673EZT.js → chunk-JANCPF7Z.js} +2 -2
- package/dist/chunk-KZ2CDZBZ.js +828 -0
- package/dist/{chunk-6YY7XPPL.js → chunk-L6CYOON4.js} +5 -5
- package/dist/{chunk-6YOXQVJ7.js → chunk-LAIBYKZS.js} +3 -3
- package/dist/{chunk-4ALDX5IO.js → chunk-NWE3NOWQ.js} +3 -3
- package/dist/{chunk-36FOBJBF.js → chunk-OIENQEGG.js} +63 -38
- package/dist/{chunk-2QUQH2SZ.js → chunk-PAH7KDL2.js} +68 -24
- package/dist/{chunk-XAISOBBG.js → chunk-TQRSVE7D.js} +1 -1
- package/dist/chunk-UKSJO36E.js +1224 -0
- package/dist/{chunk-CEMNIGQH.js → chunk-UN5Q7POQ.js} +1 -1
- package/dist/{chunk-5ZPT5YAV.js → chunk-USBZ7PH4.js} +1 -1
- package/dist/chunk-UXI52MLJ.js +826 -0
- package/dist/chunk-X7RP3TQB.js +2385 -0
- package/dist/{chunk-QX6QUZVD.js → chunk-ZSGSVFWD.js} +424 -2611
- package/dist/conductor-MLM5NGTP.js +104 -0
- package/dist/conductor-NRKTEN5X.js +105 -0
- package/dist/{connected-teams-7CENZDWU.js → connected-teams-JIUOSZ6C.js} +2 -2
- package/dist/{coordinator-prompt-J7DKHOCH.js → coordinator-prompt-4GHKCVRH.js} +7 -7
- package/dist/{coordinator-prompt-HZCQZ6C5.js → coordinator-prompt-BKRQDZFT.js} +3 -3
- package/dist/{cursor-recover-FALSNEZN.js → cursor-recover-MB3T2K5T.js} +2 -2
- package/dist/{global-workspace-TI26K25J.js → global-workspace-EEWJOMED.js} +9 -3
- package/dist/{global-workspace-6LDUZ2NF.js → global-workspace-EWFL4HPW.js} +13 -7
- package/dist/index.cjs +3918 -2592
- package/dist/index.d.cts +363 -68
- package/dist/index.d.ts +363 -68
- package/dist/index.js +4181 -3462
- package/dist/{managed-accounts-4BLTJKAN.js → managed-accounts-46A4KRUE.js} +2 -2
- package/dist/mcp/run-stdio.cjs +1894 -2012
- package/dist/mcp/run-stdio.js +719 -1445
- package/dist/{orchestrator-5YU46YNS.js → orchestrator-6ZQ2CYS4.js} +9 -6
- package/dist/{orchestrator-4JSG7BWC.js → orchestrator-MA3MHYIM.js} +15 -13
- package/dist/{paths-IUR3ABNG.js → paths-HYT5WOOX.js} +1 -1
- package/dist/{thread-store-FJ5CDX2M.js → thread-store-B3ZM4QEZ.js} +3 -3
- package/dist/{workspaces-QJ3XIPUJ.js → workspaces-F33Z4CEY.js} +3 -3
- package/dist/{workspaces-WOQHJFRQ.js → workspaces-SLP24Z3S.js} +7 -7
- package/dist/{worktree-6U2FA5LO.js → worktree-ACW23KJ5.js} +6 -6
- package/dist/{worktree-KQA4ON2G.js → worktree-C7RPVHZ7.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-CEOTK5GZ.js +0 -479
- package/dist/chunk-K7B25HO4.js +0 -477
|
@@ -0,0 +1,2273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clipToolResultForStore,
|
|
5
|
+
compactSummaryPart,
|
|
6
|
+
isInternalAgentStatusText,
|
|
7
|
+
lastAssistantMessageText
|
|
8
|
+
} from "./chunk-UKSJO36E.js";
|
|
9
|
+
import {
|
|
10
|
+
isCloudCoordinatorThread,
|
|
11
|
+
isGlobalThread,
|
|
12
|
+
isOrchestratorThread,
|
|
13
|
+
isSlackCoordinatorThread
|
|
14
|
+
} from "./chunk-GZXKUUJO.js";
|
|
15
|
+
import {
|
|
16
|
+
allocateTeamName,
|
|
17
|
+
mergeAgentGitAuthEnv,
|
|
18
|
+
normalizeWorktreePath,
|
|
19
|
+
resolveAgentGitAuthEnv,
|
|
20
|
+
takenSlugsFromThread,
|
|
21
|
+
worktreeNameFromPath
|
|
22
|
+
} from "./chunk-NWE3NOWQ.js";
|
|
23
|
+
import {
|
|
24
|
+
ATTACHMENTS_DIR,
|
|
25
|
+
DETACHED_JOBS_DIR,
|
|
26
|
+
LEGACY_ATTACHMENTS_DIR,
|
|
27
|
+
LEGACY_DETACHED_JOBS_DIR
|
|
28
|
+
} from "./chunk-N2WW62LF.js";
|
|
29
|
+
import {
|
|
30
|
+
ensureWorktreeSideboardIgnored
|
|
31
|
+
} from "./chunk-DD67JWTN.js";
|
|
32
|
+
import {
|
|
33
|
+
ensureAgentPath,
|
|
34
|
+
run
|
|
35
|
+
} from "./chunk-2UG25RMK.js";
|
|
36
|
+
import {
|
|
37
|
+
assertOrchestratorCapableAgent,
|
|
38
|
+
contextTokens
|
|
39
|
+
} from "./chunk-OIENQEGG.js";
|
|
40
|
+
import {
|
|
41
|
+
stripNestedElectronEnv
|
|
42
|
+
} from "./chunk-WWUABK6B.js";
|
|
43
|
+
import {
|
|
44
|
+
createEmptyThread,
|
|
45
|
+
findThreadByRef,
|
|
46
|
+
listThreads,
|
|
47
|
+
updateThread,
|
|
48
|
+
writeThread
|
|
49
|
+
} from "./chunk-JANCPF7Z.js";
|
|
50
|
+
import {
|
|
51
|
+
findConventionSetup,
|
|
52
|
+
loadRepoSettings,
|
|
53
|
+
loadWorkspaceSettings,
|
|
54
|
+
runCursorWorktreeSetup,
|
|
55
|
+
workspaceSettingsSourceLabel
|
|
56
|
+
} from "./chunk-H5XL4KUS.js";
|
|
57
|
+
|
|
58
|
+
// src/hook/conductor.ts
|
|
59
|
+
import {
|
|
60
|
+
copyFileSync,
|
|
61
|
+
existsSync as existsSync4,
|
|
62
|
+
mkdirSync as mkdirSync5,
|
|
63
|
+
readdirSync as readdirSync2,
|
|
64
|
+
readFileSync as readFileSync4,
|
|
65
|
+
realpathSync
|
|
66
|
+
} from "fs";
|
|
67
|
+
import { execFileSync } from "child_process";
|
|
68
|
+
import { basename, dirname as dirname4, join as join5, resolve, sep } from "path";
|
|
69
|
+
import { execa } from "execa";
|
|
70
|
+
import { createInterface } from "readline";
|
|
71
|
+
|
|
72
|
+
// src/hook/worktree-pkg-cache.ts
|
|
73
|
+
import { existsSync, mkdirSync } from "fs";
|
|
74
|
+
import { join } from "path";
|
|
75
|
+
var WORKTREE_PKG_CACHE_REL = ".context/.sideboard/pkg-cache";
|
|
76
|
+
function worktreePkgCacheRoot(worktreePath) {
|
|
77
|
+
return join(worktreePath, WORKTREE_PKG_CACHE_REL);
|
|
78
|
+
}
|
|
79
|
+
function worktreePkgCacheEnv(worktreePath) {
|
|
80
|
+
const root = worktreePkgCacheRoot(worktreePath);
|
|
81
|
+
const store = join(root, "pnpm-store");
|
|
82
|
+
const pnpmCache = join(root, "pnpm");
|
|
83
|
+
const npmCache = join(root, "npm");
|
|
84
|
+
const nodeGyp = join(root, "node-gyp");
|
|
85
|
+
const electron = join(root, "electron");
|
|
86
|
+
return {
|
|
87
|
+
npm_config_store_dir: store,
|
|
88
|
+
PNPM_STORE_DIR: store,
|
|
89
|
+
npm_config_cache_dir: pnpmCache,
|
|
90
|
+
npm_config_cache: npmCache,
|
|
91
|
+
npm_config_devdir: nodeGyp,
|
|
92
|
+
// @electron/get — do not set ELECTRON_CACHE (stripNestedElectronEnv).
|
|
93
|
+
electron_config_cache: electron
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function isSet(value) {
|
|
97
|
+
return value != null && value.trim() !== "";
|
|
98
|
+
}
|
|
99
|
+
function fillGroup(env, keys, values) {
|
|
100
|
+
if (keys.some((key) => isSet(env[key]))) return;
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
const value = values[key];
|
|
103
|
+
if (value) env[key] = value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function ensureWorktreePkgCacheDirs(worktreePath) {
|
|
107
|
+
if (!existsSync(worktreePath)) return;
|
|
108
|
+
mkdirSync(worktreePkgCacheRoot(worktreePath), { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
function applyWorktreePkgCacheEnv(env, worktreePath) {
|
|
111
|
+
const cwd = worktreePath.trim();
|
|
112
|
+
if (!cwd) return;
|
|
113
|
+
const next = worktreePkgCacheEnv(cwd);
|
|
114
|
+
fillGroup(env, ["npm_config_store_dir", "PNPM_STORE_DIR"], next);
|
|
115
|
+
fillGroup(env, ["npm_config_cache_dir"], next);
|
|
116
|
+
fillGroup(env, ["npm_config_cache"], next);
|
|
117
|
+
fillGroup(env, ["npm_config_devdir"], next);
|
|
118
|
+
fillGroup(env, ["electron_config_cache"], next);
|
|
119
|
+
ensureWorktreePkgCacheDirs(cwd);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/review/request-review.ts
|
|
123
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
124
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
125
|
+
import { dirname, join as join2 } from "path";
|
|
126
|
+
|
|
127
|
+
// src/threads/chat-tabs.ts
|
|
128
|
+
import { randomUUID } from "crypto";
|
|
129
|
+
|
|
130
|
+
// src/composer/context-estimate.ts
|
|
131
|
+
var CHARS_PER_CONTEXT_TOKEN = 4;
|
|
132
|
+
function estimateMessageChars(message) {
|
|
133
|
+
let n = message.text.length + 16;
|
|
134
|
+
for (const part of message.parts ?? []) {
|
|
135
|
+
n += estimatePartChars(part);
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
function estimatePartChars(part) {
|
|
140
|
+
switch (part.type) {
|
|
141
|
+
case "text":
|
|
142
|
+
case "thinking":
|
|
143
|
+
return part.text.length;
|
|
144
|
+
case "tool": {
|
|
145
|
+
const input = part.input ? JSON.stringify(part.input) : "";
|
|
146
|
+
return part.name.length + (part.description?.length ?? 0) + (part.detail?.length ?? 0) + (part.result?.length ?? 0) + input.length + 32;
|
|
147
|
+
}
|
|
148
|
+
default:
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function estimateThreadChars(messages) {
|
|
153
|
+
return messages.reduce((sum, m) => sum + estimateMessageChars(m), 0);
|
|
154
|
+
}
|
|
155
|
+
function estimateOccupancyTokens(messages) {
|
|
156
|
+
return Math.ceil(estimateThreadChars(messages) / CHARS_PER_CONTEXT_TOKEN);
|
|
157
|
+
}
|
|
158
|
+
function applyForwardOccupancy(messages) {
|
|
159
|
+
const tokens = estimateOccupancyTokens(messages);
|
|
160
|
+
if (tokens <= 0) return messages;
|
|
161
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
162
|
+
const m = messages[i];
|
|
163
|
+
if (m?.role !== "agent" || !m.usage) continue;
|
|
164
|
+
const current = contextTokens(m.usage);
|
|
165
|
+
if (tokens >= current) return messages;
|
|
166
|
+
const next = messages.slice();
|
|
167
|
+
next[i] = { ...m, usage: { ...m.usage, lastRequestTokens: tokens } };
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
return messages;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/composer/summarize.ts
|
|
174
|
+
var MAX_TRANSCRIPT_CHARS = 12e4;
|
|
175
|
+
var MAX_SUMMARY_CHARS = 6e3;
|
|
176
|
+
var CLAUDE_SUMMARY_TIMEOUT_MS = 2e4;
|
|
177
|
+
async function summarizeConversation(transcript, opts) {
|
|
178
|
+
const clipped = transcript.length > MAX_TRANSCRIPT_CHARS ? `${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}
|
|
179
|
+
|
|
180
|
+
[\u2026truncated\u2026]` : transcript;
|
|
181
|
+
const claudeSummary = await tryClaudeSummary(clipped, opts);
|
|
182
|
+
if (claudeSummary) {
|
|
183
|
+
return { summary: clipSummary(claudeSummary), method: "claude" };
|
|
184
|
+
}
|
|
185
|
+
return { summary: clipSummary(extractiveSummary(clipped)), method: "extractive" };
|
|
186
|
+
}
|
|
187
|
+
async function tryClaudeSummary(transcript, opts) {
|
|
188
|
+
ensureAgentPath();
|
|
189
|
+
const prompt = [
|
|
190
|
+
"Summarize this coding-agent conversation for continuity in a fresh session.",
|
|
191
|
+
"Use concise markdown bullets covering:",
|
|
192
|
+
"- Goal / request",
|
|
193
|
+
"- Key decisions and constraints",
|
|
194
|
+
"- Files / areas touched",
|
|
195
|
+
"- Current state and open tasks",
|
|
196
|
+
"- Anything the next turn must remember",
|
|
197
|
+
"Do not invent details. No preamble.",
|
|
198
|
+
"",
|
|
199
|
+
"Transcript:",
|
|
200
|
+
transcript
|
|
201
|
+
].join("\n");
|
|
202
|
+
try {
|
|
203
|
+
const { stdout, exitCode } = await run(
|
|
204
|
+
"claude",
|
|
205
|
+
[
|
|
206
|
+
"-p",
|
|
207
|
+
prompt,
|
|
208
|
+
"--output-format",
|
|
209
|
+
"text",
|
|
210
|
+
"--permission-mode",
|
|
211
|
+
"bypassPermissions",
|
|
212
|
+
"--model",
|
|
213
|
+
"haiku"
|
|
214
|
+
],
|
|
215
|
+
{
|
|
216
|
+
cwd: opts?.cwd,
|
|
217
|
+
reject: false,
|
|
218
|
+
timeoutMs: opts?.timeoutMs ?? CLAUDE_SUMMARY_TIMEOUT_MS
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
if (exitCode !== 0) return null;
|
|
222
|
+
const text = stdout.trim();
|
|
223
|
+
if (text.length < 40) return null;
|
|
224
|
+
return text;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function extractiveSummary(transcript) {
|
|
230
|
+
const lines = transcript.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
231
|
+
const users = [];
|
|
232
|
+
const tools = [];
|
|
233
|
+
const agents = [];
|
|
234
|
+
let mode = "none";
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
if (line.startsWith("### User")) {
|
|
237
|
+
mode = "user";
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (line.startsWith("### Agent") || line.startsWith("## Prior")) {
|
|
241
|
+
mode = "agent";
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (line.startsWith("- ") && line.includes(":")) {
|
|
245
|
+
tools.push(line.replace(/^- /, ""));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (mode === "user" && users.length < 8) {
|
|
249
|
+
users.push(line.slice(0, 240));
|
|
250
|
+
} else if (mode === "agent" && agents.length < 6) {
|
|
251
|
+
agents.push(line.slice(0, 240));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const uniqueTools = [...new Set(tools)].slice(0, 20);
|
|
255
|
+
const parts = [
|
|
256
|
+
"## Compacted context (extractive)",
|
|
257
|
+
"",
|
|
258
|
+
"### User goals",
|
|
259
|
+
...users.length ? users.map((u) => `- ${u}`) : ["- (none captured)"],
|
|
260
|
+
"",
|
|
261
|
+
"### Recent agent notes",
|
|
262
|
+
...agents.length ? agents.map((a) => `- ${a}`) : ["- (none captured)"]
|
|
263
|
+
];
|
|
264
|
+
if (uniqueTools.length) {
|
|
265
|
+
parts.push("", "### Tools / files", ...uniqueTools.map((t) => `- ${t}`));
|
|
266
|
+
}
|
|
267
|
+
parts.push(
|
|
268
|
+
"",
|
|
269
|
+
"_Automatic extractive summary \u2014 Claude summarizer was unavailable._"
|
|
270
|
+
);
|
|
271
|
+
return parts.join("\n");
|
|
272
|
+
}
|
|
273
|
+
function clipSummary(text) {
|
|
274
|
+
if (text.length <= MAX_SUMMARY_CHARS) return text;
|
|
275
|
+
return `${text.slice(0, MAX_SUMMARY_CHARS)}
|
|
276
|
+
|
|
277
|
+
[\u2026summary truncated\u2026]`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/composer/context-compact.ts
|
|
281
|
+
var CONTEXT_COMPACT_CHARS = 4e5;
|
|
282
|
+
var CONTEXT_KEEP_RECENT_CHARS = 24e3;
|
|
283
|
+
var CONTEXT_KEEP_RECENT_MESSAGES = 12;
|
|
284
|
+
var CONTEXT_MIN_MESSAGES = 10;
|
|
285
|
+
var SESSION_RESET_OCCUPANCY_TOKENS = 75e4;
|
|
286
|
+
function shouldCompactContext(messages, thresholds = {}) {
|
|
287
|
+
const maxChars = thresholds.maxChars ?? CONTEXT_COMPACT_CHARS;
|
|
288
|
+
const minMessages = thresholds.minMessages ?? CONTEXT_MIN_MESSAGES;
|
|
289
|
+
if (messages.length < minMessages) return false;
|
|
290
|
+
return estimateThreadChars(messages) >= maxChars;
|
|
291
|
+
}
|
|
292
|
+
function splitForCompaction(messages, thresholds = {}) {
|
|
293
|
+
const keepChars = thresholds.keepRecentChars ?? CONTEXT_KEEP_RECENT_CHARS;
|
|
294
|
+
const keepCount = thresholds.keepRecentMessages ?? CONTEXT_KEEP_RECENT_MESSAGES;
|
|
295
|
+
if (messages.length === 0) return { older: [], recent: [] };
|
|
296
|
+
let recentChars = 0;
|
|
297
|
+
let cut = messages.length;
|
|
298
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
299
|
+
const next = recentChars + estimateMessageChars(messages[i]);
|
|
300
|
+
const kept = messages.length - i;
|
|
301
|
+
if (kept >= keepCount && next > keepChars) break;
|
|
302
|
+
recentChars = next;
|
|
303
|
+
cut = i;
|
|
304
|
+
}
|
|
305
|
+
const minRecent = Math.min(keepCount, messages.length);
|
|
306
|
+
cut = Math.min(cut, messages.length - minRecent);
|
|
307
|
+
if (cut <= 0) return { older: [], recent: messages };
|
|
308
|
+
return {
|
|
309
|
+
older: messages.slice(0, cut),
|
|
310
|
+
recent: messages.slice(cut)
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function formatToolPart(t, detail) {
|
|
314
|
+
if (detail === "summary") {
|
|
315
|
+
const label = t.description || t.detail || t.name;
|
|
316
|
+
const path = t.filePath ? ` (${t.filePath})` : "";
|
|
317
|
+
return `- ${t.name}: ${label}${path}`;
|
|
318
|
+
}
|
|
319
|
+
const lines = [`#### Tool: ${t.name}`, `Status: ${t.status}`];
|
|
320
|
+
if (t.description) lines.push(`Description: ${t.description}`);
|
|
321
|
+
if (t.filePath) lines.push(`Path: ${t.filePath}`);
|
|
322
|
+
if (t.input && Object.keys(t.input).length > 0) {
|
|
323
|
+
lines.push("Input:");
|
|
324
|
+
lines.push("```json");
|
|
325
|
+
lines.push(clipToolResultForStore(JSON.stringify(t.input, null, 2)) ?? "");
|
|
326
|
+
lines.push("```");
|
|
327
|
+
} else if (t.detail) {
|
|
328
|
+
lines.push(`Detail: ${t.detail}`);
|
|
329
|
+
}
|
|
330
|
+
if (t.result != null && t.result !== "") {
|
|
331
|
+
lines.push("Result:");
|
|
332
|
+
lines.push("```");
|
|
333
|
+
lines.push(t.result);
|
|
334
|
+
lines.push("```");
|
|
335
|
+
}
|
|
336
|
+
return lines.join("\n");
|
|
337
|
+
}
|
|
338
|
+
function formatMessagesAsTranscript(messages, opts) {
|
|
339
|
+
const tools = opts?.tools ?? "full";
|
|
340
|
+
const thinking = opts?.thinking ?? true;
|
|
341
|
+
return messages.map((m) => formatMessageBlock(m, { tools, thinking })).join("\n\n");
|
|
342
|
+
}
|
|
343
|
+
function formatMessageBlock(m, opts) {
|
|
344
|
+
if (m.role === "summary") return `## Prior summary
|
|
345
|
+
${m.text}`;
|
|
346
|
+
if (m.role === "user") return `### User
|
|
347
|
+
${m.text}`;
|
|
348
|
+
const bits = [];
|
|
349
|
+
if (m.text?.trim()) {
|
|
350
|
+
bits.push(`### Agent
|
|
351
|
+
${m.text}`);
|
|
352
|
+
} else {
|
|
353
|
+
bits.push("### Agent");
|
|
354
|
+
}
|
|
355
|
+
if (opts.thinking) {
|
|
356
|
+
for (const th of m.parts ?? []) {
|
|
357
|
+
if (th.type !== "thinking" || !th.text.trim()) continue;
|
|
358
|
+
bits.push("Thinking:");
|
|
359
|
+
bits.push(th.text);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const toolParts = (m.parts ?? []).filter(
|
|
363
|
+
(p) => p.type === "tool"
|
|
364
|
+
);
|
|
365
|
+
if (opts.tools !== "none" && toolParts.length > 0) {
|
|
366
|
+
if (opts.tools === "summary") bits.push("Tools:");
|
|
367
|
+
for (const t of toolParts) {
|
|
368
|
+
bits.push(formatToolPart(t, opts.tools));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return bits.join("\n");
|
|
372
|
+
}
|
|
373
|
+
var SEED_MAX_CHARS = 6e4;
|
|
374
|
+
var SEED_FULL_TOOL_MESSAGES = 6;
|
|
375
|
+
function budgetSeedBlocks(messages, blocks, maxChars) {
|
|
376
|
+
const keep = new Array(blocks.length).fill(false);
|
|
377
|
+
let used = 0;
|
|
378
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
379
|
+
if (messages[i].role === "summary") {
|
|
380
|
+
keep[i] = true;
|
|
381
|
+
used += blocks[i].length + 2;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
385
|
+
if (keep[i]) continue;
|
|
386
|
+
const next = used + blocks[i].length + 2;
|
|
387
|
+
if (next > maxChars && used > 0) continue;
|
|
388
|
+
keep[i] = true;
|
|
389
|
+
used = next;
|
|
390
|
+
}
|
|
391
|
+
const dropped = keep.filter((k) => !k).length;
|
|
392
|
+
const marker = `_(${dropped} older message${dropped === 1 ? "" : "s"} omitted for length)_`;
|
|
393
|
+
const kept = [];
|
|
394
|
+
let markerPlaced = false;
|
|
395
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
396
|
+
if (!keep[i]) continue;
|
|
397
|
+
if (dropped > 0 && !markerPlaced && messages[i].role !== "summary") {
|
|
398
|
+
kept.push(marker);
|
|
399
|
+
markerPlaced = true;
|
|
400
|
+
}
|
|
401
|
+
kept.push(blocks[i]);
|
|
402
|
+
}
|
|
403
|
+
if (dropped > 0 && !markerPlaced) kept.push(marker);
|
|
404
|
+
return kept.join("\n\n");
|
|
405
|
+
}
|
|
406
|
+
function buildSessionSeed(messages, opts) {
|
|
407
|
+
if (messages.length === 0) return null;
|
|
408
|
+
const tools = opts?.tools ?? "full";
|
|
409
|
+
const fullCount = opts?.fullToolMessages ?? SEED_FULL_TOOL_MESSAGES;
|
|
410
|
+
const maxChars = opts?.maxChars ?? SEED_MAX_CHARS;
|
|
411
|
+
const olderTools = tools === "none" ? "none" : "summary";
|
|
412
|
+
const fullFrom = Math.max(0, messages.length - fullCount);
|
|
413
|
+
const blocks = messages.map(
|
|
414
|
+
(m, i) => formatMessageBlock(m, {
|
|
415
|
+
tools: i >= fullFrom ? tools : olderTools,
|
|
416
|
+
thinking: false
|
|
417
|
+
})
|
|
418
|
+
);
|
|
419
|
+
const body = budgetSeedBlocks(messages, blocks, maxChars);
|
|
420
|
+
if (!body.trim()) return null;
|
|
421
|
+
return [
|
|
422
|
+
"Sideboard conversation context (restored after compaction or a new session):",
|
|
423
|
+
"",
|
|
424
|
+
body,
|
|
425
|
+
"",
|
|
426
|
+
"Continue from this context. Do not repeat the summary unless asked."
|
|
427
|
+
].join("\n");
|
|
428
|
+
}
|
|
429
|
+
var BRIGHTSY_SUMMARIZE_CONTEXT_TOOL = "summarize_context";
|
|
430
|
+
function extractBrightsyContextSummary(result) {
|
|
431
|
+
if (!result?.trim()) return null;
|
|
432
|
+
const trimmed = result.trim();
|
|
433
|
+
const lower = trimmed.toLowerCase();
|
|
434
|
+
if (lower.startsWith("context summarization failed") || lower.startsWith("nothing to summarize") || lower.startsWith("no messages found") || lower.startsWith("messages are required") || lower.startsWith("agent id is required")) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const parsed = JSON.parse(trimmed);
|
|
439
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
440
|
+
if (parsed.error != null && typeof parsed.context_summary !== "string") {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
if (typeof parsed.context_summary === "string" && parsed.context_summary.trim()) {
|
|
444
|
+
return parsed.context_summary.trim();
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
if (trimmed.includes('"error"') && !trimmed.includes("context_summary")) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
return trimmed;
|
|
454
|
+
}
|
|
455
|
+
function findLastBrightsyContextSummary(messages) {
|
|
456
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
457
|
+
const message = messages[i];
|
|
458
|
+
if (message?.role !== "agent") continue;
|
|
459
|
+
for (const part of message.parts ?? []) {
|
|
460
|
+
if (part.type !== "tool" || part.name !== BRIGHTSY_SUMMARIZE_CONTEXT_TOOL) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (part.status === "error") continue;
|
|
464
|
+
const text = extractBrightsyContextSummary(part.result);
|
|
465
|
+
if (text) return { index: i, text };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
function buildBrightsySessionSeed(messages, opts) {
|
|
471
|
+
const match = findLastBrightsyContextSummary(messages);
|
|
472
|
+
const tail = match ? messages.slice(match.index + 1) : messages;
|
|
473
|
+
const budget = Math.max(1e3, (opts?.maxChars ?? SEED_MAX_CHARS) - (match?.text.length ?? 0));
|
|
474
|
+
const tailBlocks = tail.map((m) => formatMessageBlock(m, { tools: "none", thinking: false }));
|
|
475
|
+
const body = budgetSeedBlocks(tail, tailBlocks, budget);
|
|
476
|
+
if (!match && !body.trim()) return null;
|
|
477
|
+
const blocks = [
|
|
478
|
+
"Sideboard conversation context (restored after compaction or a new session):",
|
|
479
|
+
""
|
|
480
|
+
];
|
|
481
|
+
if (match) {
|
|
482
|
+
blocks.push(`## Prior summary
|
|
483
|
+
${match.text}`, "");
|
|
484
|
+
}
|
|
485
|
+
if (body.trim()) {
|
|
486
|
+
blocks.push(body, "");
|
|
487
|
+
}
|
|
488
|
+
blocks.push("Continue from this context. Do not repeat the summary unless asked.");
|
|
489
|
+
return blocks.join("\n");
|
|
490
|
+
}
|
|
491
|
+
function compactArchiveProse(message) {
|
|
492
|
+
if (message.origin === "continue") return null;
|
|
493
|
+
if (message.role === "summary") return null;
|
|
494
|
+
let text = (message.text ?? "").trim();
|
|
495
|
+
if (!text && message.role === "agent") {
|
|
496
|
+
text = lastAssistantMessageText(message.parts).trim();
|
|
497
|
+
}
|
|
498
|
+
if (!text || isInternalAgentStatusText(text)) return null;
|
|
499
|
+
return { role: message.role, text, ts: message.ts };
|
|
500
|
+
}
|
|
501
|
+
function messagesForCompactArchive(older) {
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const message of older) {
|
|
504
|
+
const prose = compactArchiveProse(message);
|
|
505
|
+
if (prose) out.push(prose);
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
function appendCompactedMessages(existing, older) {
|
|
510
|
+
const next = messagesForCompactArchive(older);
|
|
511
|
+
if (!existing?.length) return next;
|
|
512
|
+
if (!next.length) return existing;
|
|
513
|
+
return [...existing, ...next];
|
|
514
|
+
}
|
|
515
|
+
function applyCompaction(messages, summaryText, thresholds = {}) {
|
|
516
|
+
const { older, recent } = splitForCompaction(messages, thresholds);
|
|
517
|
+
if (older.length === 0) return messages;
|
|
518
|
+
const text = summaryText.trim();
|
|
519
|
+
const summary = {
|
|
520
|
+
role: "summary",
|
|
521
|
+
text,
|
|
522
|
+
parts: [compactSummaryPart(text)],
|
|
523
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
524
|
+
};
|
|
525
|
+
return [summary, ...recent];
|
|
526
|
+
}
|
|
527
|
+
function lastRequestOccupancy(thread) {
|
|
528
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
529
|
+
const usage = thread.messages[i]?.usage;
|
|
530
|
+
if (usage) return contextTokens(usage);
|
|
531
|
+
}
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
function shouldResetSessionForOccupancy(thread, occupancyTokens = SESSION_RESET_OCCUPANCY_TOKENS) {
|
|
535
|
+
return lastRequestOccupancy(thread) >= occupancyTokens;
|
|
536
|
+
}
|
|
537
|
+
async function maybeCompactContext(thread, thresholds = {}, summarize = summarizeConversation) {
|
|
538
|
+
if (!shouldCompactContext(thread.messages, thresholds)) {
|
|
539
|
+
return { didCompact: false, thread };
|
|
540
|
+
}
|
|
541
|
+
const { older } = splitForCompaction(thread.messages, thresholds);
|
|
542
|
+
if (older.length === 0) {
|
|
543
|
+
return { didCompact: false, thread };
|
|
544
|
+
}
|
|
545
|
+
const transcript = formatMessagesAsTranscript(older, { tools: "summary" });
|
|
546
|
+
const { summary, method } = await summarize(transcript, {
|
|
547
|
+
cwd: thread.worktreePath
|
|
548
|
+
});
|
|
549
|
+
let messages = applyCompaction(thread.messages, summary, thresholds);
|
|
550
|
+
const compactedMessages = appendCompactedMessages(thread.compactedMessages, older);
|
|
551
|
+
const resetSession = shouldResetSessionForOccupancy({ messages: thread.messages });
|
|
552
|
+
if (resetSession) {
|
|
553
|
+
messages = applyForwardOccupancy(messages);
|
|
554
|
+
}
|
|
555
|
+
const next = {
|
|
556
|
+
...thread,
|
|
557
|
+
messages,
|
|
558
|
+
...compactedMessages.length ? { compactedMessages } : { compactedMessages: void 0 },
|
|
559
|
+
sessionId: resetSession ? null : thread.sessionId,
|
|
560
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
didCompact: true,
|
|
564
|
+
thread: next,
|
|
565
|
+
summary,
|
|
566
|
+
method,
|
|
567
|
+
olderCount: older.length
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/threads/worktree-runtime.ts
|
|
572
|
+
function worktreeProcessPrefix(worktreePath) {
|
|
573
|
+
return `wt:${normalizeWorktreePath(worktreePath)}`;
|
|
574
|
+
}
|
|
575
|
+
function worktreeSetupProcessKey(worktreePath) {
|
|
576
|
+
return `${worktreeProcessPrefix(worktreePath)}:setup`;
|
|
577
|
+
}
|
|
578
|
+
function worktreeRunProcessKey(worktreePath, scriptName) {
|
|
579
|
+
return `${worktreeProcessPrefix(worktreePath)}:run:${scriptName}`;
|
|
580
|
+
}
|
|
581
|
+
function worktreeDevProcessKey(worktreePath) {
|
|
582
|
+
return `${worktreeProcessPrefix(worktreePath)}:dev`;
|
|
583
|
+
}
|
|
584
|
+
function isWorktreeRunProcessKey(key, worktreePath) {
|
|
585
|
+
const prefix = worktreeProcessPrefix(worktreePath);
|
|
586
|
+
return key.startsWith(`${prefix}:run:`) || key === `${prefix}:dev`;
|
|
587
|
+
}
|
|
588
|
+
function pickRichestSetupLog(snaps) {
|
|
589
|
+
let best;
|
|
590
|
+
for (const snap of snaps) {
|
|
591
|
+
if (!snap.output && !snap.running && snap.exitCode == null) continue;
|
|
592
|
+
if (!best) {
|
|
593
|
+
best = snap;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (snap.running && !best.running) {
|
|
597
|
+
best = snap;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (snap.running === best.running && snap.output.length > best.output.length) {
|
|
601
|
+
best = snap;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return best;
|
|
605
|
+
}
|
|
606
|
+
function resolveWorktreeSetupLog(shared, legacyChatLogs) {
|
|
607
|
+
if (shared.output || shared.running || shared.exitCode != null) return shared;
|
|
608
|
+
return pickRichestSetupLog(legacyChatLogs);
|
|
609
|
+
}
|
|
610
|
+
function mergeWorktreeActiveRuns(threads) {
|
|
611
|
+
const byName = /* @__PURE__ */ new Map();
|
|
612
|
+
let devPort = null;
|
|
613
|
+
for (const thread of threads) {
|
|
614
|
+
if (thread.devPort != null && devPort == null) devPort = thread.devPort;
|
|
615
|
+
for (const run2 of thread.activeRuns ?? []) {
|
|
616
|
+
if (!byName.has(run2.scriptName)) byName.set(run2.scriptName, run2);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { activeRuns: [...byName.values()], devPort };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/threads/chat-tabs.ts
|
|
623
|
+
function sameWorktreePath(a, b) {
|
|
624
|
+
return normalizeWorktreePath(a) === normalizeWorktreePath(b);
|
|
625
|
+
}
|
|
626
|
+
function takenTeamSlugsForChatTab(worktreePath) {
|
|
627
|
+
const key = normalizeWorktreePath(worktreePath);
|
|
628
|
+
const taken = /* @__PURE__ */ new Set();
|
|
629
|
+
const dir = worktreeNameFromPath(key);
|
|
630
|
+
if (dir) taken.add(dir.toLowerCase());
|
|
631
|
+
for (const sibling of threadsSharingWorktree(key)) {
|
|
632
|
+
for (const slug of takenSlugsFromThread(sibling)) {
|
|
633
|
+
taken.add(slug);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return [...taken];
|
|
637
|
+
}
|
|
638
|
+
function requireThread(idOrRef) {
|
|
639
|
+
const thread = findThreadByRef(idOrRef) ?? null;
|
|
640
|
+
if (!thread) throw new Error(`Thread not found: ${idOrRef}`);
|
|
641
|
+
return thread;
|
|
642
|
+
}
|
|
643
|
+
function worktreeBindingFrom(from) {
|
|
644
|
+
return {
|
|
645
|
+
worktreePath: normalizeWorktreePath(from.worktreePath),
|
|
646
|
+
repoPath: from.repoPath,
|
|
647
|
+
branchName: from.branchName,
|
|
648
|
+
sourceRef: from.sourceRef,
|
|
649
|
+
// Legacy pinned-repo orchestration: new tabs become normal worktree agents.
|
|
650
|
+
// Global: keep orchestration so chat tabs stay fleet orchestrators (MCP + Bash).
|
|
651
|
+
sourceType: isGlobalThread(from) ? "orchestration" : from.sourceType === "orchestration" ? "branch" : from.sourceType,
|
|
652
|
+
sourceIsFork: from.sourceIsFork,
|
|
653
|
+
parentThreadId: from.parentThreadId,
|
|
654
|
+
prUrl: from.prUrl,
|
|
655
|
+
prTitle: from.prTitle,
|
|
656
|
+
prState: from.prState,
|
|
657
|
+
prIsDraft: from.prIsDraft,
|
|
658
|
+
prAuthorLogin: from.prAuthorLogin,
|
|
659
|
+
prReviewerLogins: from.prReviewerLogins,
|
|
660
|
+
prReviewDecision: from.prReviewDecision,
|
|
661
|
+
prMergeable: from.prMergeable,
|
|
662
|
+
prMergeStateStatus: from.prMergeStateStatus,
|
|
663
|
+
prIsInMergeQueue: from.prIsInMergeQueue,
|
|
664
|
+
prLabels: from.prLabels,
|
|
665
|
+
prChecksFailed: from.prChecksFailed,
|
|
666
|
+
skipAutoArchiveOnMerge: from.skipAutoArchiveOnMerge,
|
|
667
|
+
stackId: from.stackId,
|
|
668
|
+
stackLayer: from.stackLayer,
|
|
669
|
+
cowboy: from.cowboy
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function threadsSharingWorktree(worktreePath) {
|
|
673
|
+
if (!worktreePath) return [];
|
|
674
|
+
const key = normalizeWorktreePath(worktreePath);
|
|
675
|
+
return listThreads({ includeArchived: true }).filter(
|
|
676
|
+
(t) => Boolean(t.worktreePath) && sameWorktreePath(t.worktreePath, key) && t.status !== "archived"
|
|
677
|
+
).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
678
|
+
}
|
|
679
|
+
function formatTranscriptMarkdown(title, messages) {
|
|
680
|
+
const body = formatMessagesAsTranscript(messages, { tools: "full" });
|
|
681
|
+
return [`# Transcript: ${title}`, "", `Forked ${(/* @__PURE__ */ new Date()).toISOString()}`, "", body].join("\n");
|
|
682
|
+
}
|
|
683
|
+
function forkMessageSlice(from, throughIndex) {
|
|
684
|
+
const through = throughIndex === void 0 ? from.messages.length - 1 : Math.min(throughIndex, from.messages.length - 1);
|
|
685
|
+
return through >= 0 ? from.messages.slice(0, through + 1) : [];
|
|
686
|
+
}
|
|
687
|
+
function buildForkTranscriptAttachment(baseTitle, messages) {
|
|
688
|
+
const title = baseTitle || "Chat";
|
|
689
|
+
return {
|
|
690
|
+
id: randomUUID(),
|
|
691
|
+
name: `Transcript of ${title}.md`,
|
|
692
|
+
kind: "transcript",
|
|
693
|
+
content: formatTranscriptMarkdown(title, messages)
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function createChatTab(input) {
|
|
697
|
+
const from = requireThread(input.fromThreadId);
|
|
698
|
+
const binding = worktreeBindingFrom(from);
|
|
699
|
+
const explicitTitle = input.title?.trim();
|
|
700
|
+
const title = explicitTitle || allocateTeamName(takenTeamSlugsForChatTab(binding.worktreePath)).name;
|
|
701
|
+
const nextAgent = input.agent ?? from.agent;
|
|
702
|
+
if (isOrchestratorThread(from) || binding.sourceType === "orchestration") {
|
|
703
|
+
assertOrchestratorCapableAgent(nextAgent);
|
|
704
|
+
}
|
|
705
|
+
const singletonInbox = isSlackCoordinatorThread(from) || isCloudCoordinatorThread(from);
|
|
706
|
+
const thread = createEmptyThread({
|
|
707
|
+
title,
|
|
708
|
+
// Chat-tab nicknames (soccer team or explicit) must stick. Post-turn
|
|
709
|
+
// syncThreadBranchFromGit would otherwise rewrite every sibling to the
|
|
710
|
+
// shared worktree folder name (e.g. fork "Arsenal" → "Monaco").
|
|
711
|
+
userSetTitle: true,
|
|
712
|
+
...binding,
|
|
713
|
+
// Slack / Brightsy cloud identity stays on the original chat. A + tab
|
|
714
|
+
// that copies `slack:team:user` would steal inbound DMs after you close
|
|
715
|
+
// the connected chat.
|
|
716
|
+
sourceRef: singletonInbox ? title : binding.sourceRef,
|
|
717
|
+
agent: nextAgent,
|
|
718
|
+
model: input.model !== void 0 ? input.model : input.agent && input.agent !== from.agent ? null : from.model,
|
|
719
|
+
effort: input.effort !== void 0 ? input.effort : from.effort,
|
|
720
|
+
fast: input.fast !== void 0 ? Boolean(input.fast) : from.fast,
|
|
721
|
+
planMode: from.planMode,
|
|
722
|
+
autonomy: input.autonomy ?? from.autonomy,
|
|
723
|
+
attachments: input.attachments ?? [],
|
|
724
|
+
status: "idle",
|
|
725
|
+
...input.accountId !== void 0 ? { accountId: input.accountId } : {},
|
|
726
|
+
...mergeWorktreeActiveRuns(threadsSharingWorktree(binding.worktreePath))
|
|
727
|
+
});
|
|
728
|
+
writeThread(thread);
|
|
729
|
+
return thread;
|
|
730
|
+
}
|
|
731
|
+
function forkChatTab(input) {
|
|
732
|
+
const from = requireThread(input.threadId);
|
|
733
|
+
const slice = forkMessageSlice(from, input.throughIndex);
|
|
734
|
+
const attachment = buildForkTranscriptAttachment(from.title || "Chat", slice);
|
|
735
|
+
const tab = createChatTab({
|
|
736
|
+
fromThreadId: input.threadId,
|
|
737
|
+
agent: input.agent ?? from.agent,
|
|
738
|
+
model: input.model,
|
|
739
|
+
title: input.title?.trim() || void 0,
|
|
740
|
+
attachments: [attachment]
|
|
741
|
+
});
|
|
742
|
+
if (isOrchestratorThread(from) && tab.parentThreadId !== from.id) {
|
|
743
|
+
const next = { ...tab, parentThreadId: from.id };
|
|
744
|
+
writeThread(next);
|
|
745
|
+
return next;
|
|
746
|
+
}
|
|
747
|
+
return tab;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/review/review-write-gate.ts
|
|
751
|
+
var REVIEW_TAB_TITLE = "Review";
|
|
752
|
+
function hasLiveReviewSibling(thread, siblings = []) {
|
|
753
|
+
const selfId = thread.id;
|
|
754
|
+
return siblings.some(
|
|
755
|
+
(t) => t.id !== selfId && t.status !== "archived" && t.title.trim() === REVIEW_TAB_TITLE
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
function isReviewWriteGatedThread(thread, siblings) {
|
|
759
|
+
if (thread.sourceType === "pr" || thread.title.trim() === REVIEW_TAB_TITLE) {
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
return hasLiveReviewSibling(thread, siblings);
|
|
763
|
+
}
|
|
764
|
+
function formatReviewWriteGateDirective() {
|
|
765
|
+
return [
|
|
766
|
+
"Review writes are public. The PR or ticket author sees GitHub/Linear/AbleTime comments, ticket updates, and submitted reviews immediately \u2014 a draft will confuse them.",
|
|
767
|
+
"Write the recommendation in chat so the user can work through the feedback first. Do not call github_comment, linear_comment, abletime_comment, github_update_issue, linear_update_issue, abletime_update_task, or run `gh pr review` / `gh pr comment`, until they type a next step that asks you to post.",
|
|
768
|
+
"After the review is in chat, stop. Do not call ask_user. The user needs time to read and will type the next steps. If they already asked you to post a specific comment or update in this turn, that is confirmation.",
|
|
769
|
+
"Do not request reviewers, change labels, or assign yourself unless they asked \u2014 those also notify the author."
|
|
770
|
+
].join("\n");
|
|
771
|
+
}
|
|
772
|
+
function formatReviewWriteGateReminder() {
|
|
773
|
+
return "Review writes: keep the review in chat. Do not ask_user after it \u2014 they read and type next steps. Do not github_comment / linear_comment / *_update_* / `gh pr review` on the PR or ticket until they ask. Authors see those immediately.";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/review/review-request-template.ts
|
|
777
|
+
var REVIEW_REQUEST_TEMPLATE = `# Review guidelines:
|
|
778
|
+
|
|
779
|
+
You are reviewing a proposed code change so a human can decide whether it is **ready to merge / land**. Findings matter, but the primary deliverable is a clear readiness recommendation \u2014 not a laundry list of style notes.
|
|
780
|
+
|
|
781
|
+
## Required outcome
|
|
782
|
+
|
|
783
|
+
Start your reply with a **Recommendation** section using exactly one of:
|
|
784
|
+
|
|
785
|
+
- **Approve** \u2014 ready to merge as-is (or with only trivial nits the author can ignore).
|
|
786
|
+
- **Approve with nits** \u2014 ready to merge; list only optional polish that should not block.
|
|
787
|
+
- **Request changes** \u2014 not ready; blocking issues must be fixed first.
|
|
788
|
+
- **Needs more information** \u2014 cannot judge readiness yet (missing context, incomplete diff, unclear intent).
|
|
789
|
+
|
|
790
|
+
In 1\u20133 sentences, say **why** \u2014 grounded in correctness, risk, test coverage, and scope \u2014 not vibes. If you request changes, name the blockers explicitly.
|
|
791
|
+
|
|
792
|
+
People running this review are asking \u201Ccan we ship this?\u201D Treat that as the question you answer first.
|
|
793
|
+
|
|
794
|
+
## Confirm before posting
|
|
795
|
+
|
|
796
|
+
The report stays in this chat until the user posts it. Do not call \`github_comment\`, \`linear_comment\`, \`abletime_comment\`, \`github_update_issue\`, \`linear_update_issue\`, \`abletime_update_task\`, or run \`gh pr review\` / \`gh pr comment\`, until they type a next step that asks you to post.
|
|
797
|
+
|
|
798
|
+
After the Recommendation is in chat, stop. Do not call ask_user. The user needs time to read the review and will type the next steps. If they already asked you to post a specific comment or update in this turn, that is confirmation.
|
|
799
|
+
|
|
800
|
+
The PR or ticket author sees those writes immediately \u2014 a draft review will confuse them. Do not request reviewers, change labels, or assign yourself unless they asked.
|
|
801
|
+
|
|
802
|
+
## Findings
|
|
803
|
+
|
|
804
|
+
Below are guidelines for determining whether an issue is worth flagging to the original author.
|
|
805
|
+
|
|
806
|
+
These are not the final word. More specific guidelines elsewhere (developer message, user message, a file, etc.) override these.
|
|
807
|
+
|
|
808
|
+
Flag something as a bug / blocking finding only when:
|
|
809
|
+
|
|
810
|
+
1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code.
|
|
811
|
+
2. The bug is discrete and actionable (not a vague codebase-wide complaint or a bundle of unrelated issues).
|
|
812
|
+
3. Fixing it does not demand rigor absent from the rest of the codebase.
|
|
813
|
+
4. The issue was introduced by this change (do not flag pre-existing bugs unless they are newly exposed by this PR).
|
|
814
|
+
5. The author would likely fix it if made aware.
|
|
815
|
+
6. It does not rely on unstated assumptions about the codebase or author intent.
|
|
816
|
+
7. Speculative breakage is not enough \u2014 identify the other code that is provably affected.
|
|
817
|
+
8. It is clearly not just an intentional change by the author.
|
|
818
|
+
|
|
819
|
+
When flagging an issue, include a short accompanying comment:
|
|
820
|
+
|
|
821
|
+
1. Clear about why it is a problem.
|
|
822
|
+
2. Severity must match reality \u2014 do not inflate.
|
|
823
|
+
3. Brief: at most one paragraph; avoid unnecessary line breaks in prose.
|
|
824
|
+
4. No code chunks longer than 3 lines; wrap code in inline ticks or a fenced block.
|
|
825
|
+
5. Call out scenarios / environments / inputs needed to hit the bug when severity depends on them.
|
|
826
|
+
6. Matter-of-fact tone \u2014 helpful assistant, not accusatory or effusive.
|
|
827
|
+
7. Skimmable on first read.
|
|
828
|
+
8. No empty flattery (\u201CGreat job\u2026\u201D, \u201CThanks for\u2026\u201D).
|
|
829
|
+
|
|
830
|
+
HOW MANY FINDINGS TO RETURN:
|
|
831
|
+
|
|
832
|
+
List every finding the author would fix if they knew about it. If nothing qualifies, say so and still give the Recommendation. Do not stop at the first finding.
|
|
833
|
+
|
|
834
|
+
GUIDELINES:
|
|
835
|
+
|
|
836
|
+
- Ignore trivial style unless it obscures meaning or violates documented standards.
|
|
837
|
+
- One comment per distinct issue (or a short multi-line range if needed).
|
|
838
|
+
- Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block).
|
|
839
|
+
- In every \`\`\`suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces).
|
|
840
|
+
- Do NOT introduce or remove outer indentation levels unless that is the actual fix.
|
|
841
|
+
- Separate **blocking** findings from **nits**. Only blocking findings should drive Request changes.
|
|
842
|
+
|
|
843
|
+
The report appears in chat (and can become Sideboard diff comments). Avoid unnecessary location chatter in the body; keep line ranges as short as possible (prefer \u22645\u201310 lines).
|
|
844
|
+
|
|
845
|
+
## Getting the diff
|
|
846
|
+
|
|
847
|
+
Use Sideboard's diff for this thread's worktree. Prefer the \`get_diff\` MCP tool (pass this thread's ref) for a compact summary, then read specific files with Read/Glob as needed. In the Sideboard desktop app, the Changes panel shows the same worktree diff.
|
|
848
|
+
|
|
849
|
+
If the user asks you to address or read line comments they added in the Changes / file diff UI, those arrive as \`diff-comment\` attachments on the next turn \u2014 follow them precisely.
|
|
850
|
+
|
|
851
|
+
## Fallback: if you don't have access to the Sideboard diff tool
|
|
852
|
+
|
|
853
|
+
If you don't have access to \`get_diff\`, use the following git commands to get the diff:
|
|
854
|
+
|
|
855
|
+
\`\`\`bash
|
|
856
|
+
# Get the merge base between this branch and the target
|
|
857
|
+
MERGE_BASE=$(git merge-base origin/main HEAD)
|
|
858
|
+
|
|
859
|
+
# Get the committed diff against the merge base
|
|
860
|
+
git diff $MERGE_BASE HEAD
|
|
861
|
+
|
|
862
|
+
# Get any uncommitted changes (staged and unstaged)
|
|
863
|
+
git diff HEAD
|
|
864
|
+
\`\`\`
|
|
865
|
+
|
|
866
|
+
Review the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress.
|
|
867
|
+
|
|
868
|
+
No need to mention in your report whether or not you used one of the fallback strategies; it's usually irrelevant.
|
|
869
|
+
|
|
870
|
+
## Output format
|
|
871
|
+
|
|
872
|
+
**1. Recommendation first** (required), then **2. Findings** (may be empty).
|
|
873
|
+
|
|
874
|
+
Only report ONE finding per unique issue.
|
|
875
|
+
|
|
876
|
+
<example>
|
|
877
|
+
## Recommendation
|
|
878
|
+
|
|
879
|
+
**Request changes** \u2014 The empty-input crash on load will break first-run users; fix that before merge. The unused helper is a nit and can wait.
|
|
880
|
+
|
|
881
|
+
## Findings
|
|
882
|
+
|
|
883
|
+
### **#1 Empty input causes crash** (blocking)
|
|
884
|
+
|
|
885
|
+
If the input field is empty when the page loads, the app will crash.
|
|
886
|
+
|
|
887
|
+
File: src/client/frontends/desktop/ui/Input.tsx
|
|
888
|
+
|
|
889
|
+
### **#2 Dead code** (nit)
|
|
890
|
+
|
|
891
|
+
The getUserData function is now unused. It should be deleted.
|
|
892
|
+
|
|
893
|
+
File: src/client/frontends/desktop/core/UserData.ts
|
|
894
|
+
</example>
|
|
895
|
+
|
|
896
|
+
<example>
|
|
897
|
+
## Recommendation
|
|
898
|
+
|
|
899
|
+
**Approve** \u2014 Diff is scoped, behavior looks correct, and there are no blocking issues. Safe to merge.
|
|
900
|
+
</example>
|
|
901
|
+
|
|
902
|
+
## Growing the rules
|
|
903
|
+
|
|
904
|
+
If a blocking issue is a missing or ambiguous repo rule that will recur, add one sentence to \`.claude/skills/review/SKILL.md\` when that skill already exists. Otherwise write it to \`.context/review.md\` (do not create a review skill). Do not only patch this diff when the same miss will happen again. Do not write new skills under \`.sideboard/skills/\`.
|
|
905
|
+
`;
|
|
906
|
+
var REVIEW_SKILL_PATH = ".claude/skills/review/SKILL.md";
|
|
907
|
+
var REVIEW_SKILL_NAME = "review";
|
|
908
|
+
|
|
909
|
+
// src/review/request-review.ts
|
|
910
|
+
var REPO_REVIEW_PATH = ".sideboard/review.md";
|
|
911
|
+
var REPO_REVIEW_NAME = "review.md";
|
|
912
|
+
var CONTEXT_REVIEW_PATH = ".context/review.md";
|
|
913
|
+
var REVIEW_REQUEST_PATH = `${ATTACHMENTS_DIR}/Review request.md`;
|
|
914
|
+
var LEGACY_REVIEW_REQUEST_PATH = `${LEGACY_ATTACHMENTS_DIR}/Review request.md`;
|
|
915
|
+
var REVIEW_REQUEST_NAME = "Review request.md";
|
|
916
|
+
var REVIEW_REQUEST_PREFILL = "Review changes in this workspace.";
|
|
917
|
+
var LEGACY_REVIEW_TEMPLATE_MARKERS = [
|
|
918
|
+
"You are acting as a reviewer for a proposed code change made by another engineer.",
|
|
919
|
+
"HOW MANY FINDINGS TO RETURN:"
|
|
920
|
+
];
|
|
921
|
+
function shouldRefreshReviewRequestTemplate(content) {
|
|
922
|
+
const trimmed = content.trim();
|
|
923
|
+
if (!trimmed) return true;
|
|
924
|
+
if (/##\s*Recommendation|ready to merge|Approve with nits|Request changes/i.test(trimmed)) {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
return LEGACY_REVIEW_TEMPLATE_MARKERS.every((m) => trimmed.includes(m));
|
|
928
|
+
}
|
|
929
|
+
function readTextIfPresent(abs) {
|
|
930
|
+
if (!existsSync2(abs)) return null;
|
|
931
|
+
try {
|
|
932
|
+
const content = readFileSync(abs, "utf8");
|
|
933
|
+
return content.trim() ? content : null;
|
|
934
|
+
} catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function readLocalGuidelines(worktreePath) {
|
|
939
|
+
const localAbs = join2(worktreePath, REVIEW_REQUEST_PATH);
|
|
940
|
+
const localContent = readTextIfPresent(localAbs);
|
|
941
|
+
if (localContent && !shouldRefreshReviewRequestTemplate(localContent)) {
|
|
942
|
+
return { path: REVIEW_REQUEST_PATH, content: localContent };
|
|
943
|
+
}
|
|
944
|
+
const legacyAbs = join2(worktreePath, LEGACY_REVIEW_REQUEST_PATH);
|
|
945
|
+
const legacyContent = readTextIfPresent(legacyAbs);
|
|
946
|
+
if (legacyContent && !shouldRefreshReviewRequestTemplate(legacyContent)) {
|
|
947
|
+
return { path: LEGACY_REVIEW_REQUEST_PATH, content: legacyContent };
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
function skillGuidelines(content, source) {
|
|
952
|
+
return {
|
|
953
|
+
path: REVIEW_SKILL_PATH,
|
|
954
|
+
name: REVIEW_SKILL_NAME,
|
|
955
|
+
content,
|
|
956
|
+
source
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
function contextGuidelines(content, source) {
|
|
960
|
+
return {
|
|
961
|
+
path: CONTEXT_REVIEW_PATH,
|
|
962
|
+
name: REPO_REVIEW_NAME,
|
|
963
|
+
content,
|
|
964
|
+
source
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function writeContextGuidelines(worktreePath, content) {
|
|
968
|
+
const abs = join2(worktreePath, CONTEXT_REVIEW_PATH);
|
|
969
|
+
mkdirSync2(dirname(abs), { recursive: true });
|
|
970
|
+
const body = content.endsWith("\n") ? content : `${content}
|
|
971
|
+
`;
|
|
972
|
+
writeFileSync(abs, body, "utf8");
|
|
973
|
+
return body;
|
|
974
|
+
}
|
|
975
|
+
function readSideboardReview(worktreePath, repoPath) {
|
|
976
|
+
const fromWorktree = readTextIfPresent(join2(worktreePath, REPO_REVIEW_PATH));
|
|
977
|
+
if (fromWorktree) return fromWorktree;
|
|
978
|
+
const repo = repoPath?.replace(/\/+$/, "");
|
|
979
|
+
const wt = worktreePath.replace(/\/+$/, "");
|
|
980
|
+
if (!repo || repo === wt) return null;
|
|
981
|
+
return readTextIfPresent(join2(repo, REPO_REVIEW_PATH));
|
|
982
|
+
}
|
|
983
|
+
function ensureReviewGuidelinesFile(worktreePath, repoPath) {
|
|
984
|
+
const skillContent = readTextIfPresent(join2(worktreePath, REVIEW_SKILL_PATH));
|
|
985
|
+
if (skillContent) {
|
|
986
|
+
return { ...skillGuidelines(skillContent, "skill"), wrote: false };
|
|
987
|
+
}
|
|
988
|
+
const contextContent = readTextIfPresent(join2(worktreePath, CONTEXT_REVIEW_PATH));
|
|
989
|
+
if (contextContent && !shouldRefreshReviewRequestTemplate(contextContent)) {
|
|
990
|
+
return { ...contextGuidelines(contextContent, "local"), wrote: false };
|
|
991
|
+
}
|
|
992
|
+
const fromSideboard = readSideboardReview(worktreePath, repoPath);
|
|
993
|
+
if (fromSideboard && !shouldRefreshReviewRequestTemplate(fromSideboard)) {
|
|
994
|
+
const body2 = writeContextGuidelines(worktreePath, fromSideboard);
|
|
995
|
+
return { ...contextGuidelines(body2, "repo"), wrote: true };
|
|
996
|
+
}
|
|
997
|
+
const local = readLocalGuidelines(worktreePath);
|
|
998
|
+
const body = writeContextGuidelines(worktreePath, local?.content ?? REVIEW_REQUEST_TEMPLATE);
|
|
999
|
+
return {
|
|
1000
|
+
...contextGuidelines(body, local ? "local" : "stock"),
|
|
1001
|
+
wrote: true
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function resolveReviewGuidelines(worktreePath, repoPath) {
|
|
1005
|
+
return ensureReviewGuidelinesFile(worktreePath, repoPath);
|
|
1006
|
+
}
|
|
1007
|
+
function buildReviewRequestAttachment(content, opts) {
|
|
1008
|
+
const path = opts?.path ?? REVIEW_SKILL_PATH;
|
|
1009
|
+
const name = opts?.name ?? (path === REVIEW_SKILL_PATH ? REVIEW_SKILL_NAME : path === REPO_REVIEW_PATH || path === CONTEXT_REVIEW_PATH ? REPO_REVIEW_NAME : REVIEW_REQUEST_NAME);
|
|
1010
|
+
return {
|
|
1011
|
+
id: randomUUID2(),
|
|
1012
|
+
name,
|
|
1013
|
+
kind: "file",
|
|
1014
|
+
path,
|
|
1015
|
+
content
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function canReuseIdleWorktreeAgentForReview(from, siblings) {
|
|
1019
|
+
const live = siblings.filter((t) => t.status !== "archived");
|
|
1020
|
+
if (live.length !== 1) return false;
|
|
1021
|
+
const only = live[0];
|
|
1022
|
+
if (only.id !== from.id) return false;
|
|
1023
|
+
if (from.status !== "idle") return false;
|
|
1024
|
+
if ((from.queue?.length ?? 0) > 0) return false;
|
|
1025
|
+
return (from.messages?.length ?? 0) === 0 && (only.messages?.length ?? 0) === 0;
|
|
1026
|
+
}
|
|
1027
|
+
function reviewGuidelinesAttachment(from) {
|
|
1028
|
+
const guidelines = resolveReviewGuidelines(from.worktreePath, from.repoPath);
|
|
1029
|
+
return buildReviewRequestAttachment(guidelines.content, {
|
|
1030
|
+
path: guidelines.path,
|
|
1031
|
+
name: guidelines.name
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
async function requestReview(threadRef, send) {
|
|
1035
|
+
const from = findThreadByRef(threadRef);
|
|
1036
|
+
if (!from) throw new Error(`Thread not found: ${threadRef}`);
|
|
1037
|
+
if (isOrchestratorThread(from)) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
"request_review targets a worktree agent thread (not the orchestrator). Pass a child/worktree thread ref."
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (from.status === "archived") {
|
|
1043
|
+
throw new Error(`Thread is archived: ${from.id}`);
|
|
1044
|
+
}
|
|
1045
|
+
const attachment = reviewGuidelinesAttachment(from);
|
|
1046
|
+
const siblings = threadsSharingWorktree(from.worktreePath);
|
|
1047
|
+
if (canReuseIdleWorktreeAgentForReview(from, siblings)) {
|
|
1048
|
+
const reused = updateThread(from.id, {
|
|
1049
|
+
title: REVIEW_TAB_TITLE,
|
|
1050
|
+
userSetTitle: true,
|
|
1051
|
+
attachments: [...from.attachments ?? [], attachment]
|
|
1052
|
+
});
|
|
1053
|
+
const started2 = await send(reused.id, REVIEW_REQUEST_PREFILL);
|
|
1054
|
+
return { tab: started2, from: reused };
|
|
1055
|
+
}
|
|
1056
|
+
const tab = createChatTab({
|
|
1057
|
+
fromThreadId: from.id,
|
|
1058
|
+
title: REVIEW_TAB_TITLE,
|
|
1059
|
+
attachments: [attachment]
|
|
1060
|
+
});
|
|
1061
|
+
const started = await send(tab.id, REVIEW_REQUEST_PREFILL);
|
|
1062
|
+
return { tab: started, from };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/hook/worktree-run-ports.ts
|
|
1066
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1067
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1068
|
+
var RUN_PORTS_REL = ".context/.sideboard/run-ports.json";
|
|
1069
|
+
function worktreeRunPortsPath(worktreePath) {
|
|
1070
|
+
return join3(worktreePath, RUN_PORTS_REL);
|
|
1071
|
+
}
|
|
1072
|
+
function isPort(value) {
|
|
1073
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
1074
|
+
}
|
|
1075
|
+
function parseFile(raw) {
|
|
1076
|
+
try {
|
|
1077
|
+
const parsed = JSON.parse(raw);
|
|
1078
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
1079
|
+
const scripts = parsed.scripts;
|
|
1080
|
+
if (!scripts || typeof scripts !== "object") return {};
|
|
1081
|
+
const next = {};
|
|
1082
|
+
for (const [name, ports] of Object.entries(scripts)) {
|
|
1083
|
+
if (!name || !Array.isArray(ports)) continue;
|
|
1084
|
+
const cleaned = ports.filter(isPort);
|
|
1085
|
+
if (cleaned.length) next[name] = cleaned;
|
|
1086
|
+
}
|
|
1087
|
+
return { scripts: next };
|
|
1088
|
+
} catch {
|
|
1089
|
+
return {};
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function readScripts(worktreePath) {
|
|
1093
|
+
if (!worktreePath.trim()) return {};
|
|
1094
|
+
try {
|
|
1095
|
+
const raw = readFileSync2(worktreeRunPortsPath(worktreePath), "utf8");
|
|
1096
|
+
return parseFile(raw).scripts ?? {};
|
|
1097
|
+
} catch {
|
|
1098
|
+
return {};
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function loadWorktreeRunPorts(worktreePath, scriptName) {
|
|
1102
|
+
if (!scriptName.trim()) return null;
|
|
1103
|
+
const ports = readScripts(worktreePath)[scriptName];
|
|
1104
|
+
return ports?.length ? ports : null;
|
|
1105
|
+
}
|
|
1106
|
+
function loadOtherScriptRunPorts(worktreePath, scriptName) {
|
|
1107
|
+
const scripts = readScripts(worktreePath);
|
|
1108
|
+
const ports = [];
|
|
1109
|
+
for (const [name, list] of Object.entries(scripts)) {
|
|
1110
|
+
if (name === scriptName) continue;
|
|
1111
|
+
ports.push(...list);
|
|
1112
|
+
}
|
|
1113
|
+
return ports;
|
|
1114
|
+
}
|
|
1115
|
+
function saveWorktreeRunPorts(worktreePath, scriptName, ports) {
|
|
1116
|
+
if (!worktreePath.trim() || !scriptName.trim()) return;
|
|
1117
|
+
const cleaned = ports.filter(isPort);
|
|
1118
|
+
if (!cleaned.length) return;
|
|
1119
|
+
const path = worktreeRunPortsPath(worktreePath);
|
|
1120
|
+
let current = {};
|
|
1121
|
+
try {
|
|
1122
|
+
current = parseFile(readFileSync2(path, "utf8"));
|
|
1123
|
+
} catch {
|
|
1124
|
+
current = {};
|
|
1125
|
+
}
|
|
1126
|
+
const scripts = { ...current.scripts ?? {} };
|
|
1127
|
+
scripts[scriptName] = cleaned;
|
|
1128
|
+
mkdirSync3(dirname2(path), { recursive: true });
|
|
1129
|
+
writeFileSync2(path, `${JSON.stringify({ scripts }, null, 2)}
|
|
1130
|
+
`, "utf8");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/mcp/wait-for-job.ts
|
|
1134
|
+
import {
|
|
1135
|
+
appendFileSync,
|
|
1136
|
+
existsSync as existsSync3,
|
|
1137
|
+
mkdirSync as mkdirSync4,
|
|
1138
|
+
readdirSync,
|
|
1139
|
+
readFileSync as readFileSync3,
|
|
1140
|
+
writeFileSync as writeFileSync3
|
|
1141
|
+
} from "fs";
|
|
1142
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
1143
|
+
|
|
1144
|
+
// src/orchestrator/task-state.ts
|
|
1145
|
+
function isIncompleteTaskState(state) {
|
|
1146
|
+
return state === "failed" || state === "canceled";
|
|
1147
|
+
}
|
|
1148
|
+
function needsCoordinatorAction(state) {
|
|
1149
|
+
return isIncompleteTaskState(state) || state === "input-required";
|
|
1150
|
+
}
|
|
1151
|
+
var ASK_USER_TOOL = /(^|[_.:/])ask_user$/i;
|
|
1152
|
+
function endedOnAskUser(parts) {
|
|
1153
|
+
if (!parts?.length) return false;
|
|
1154
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
1155
|
+
const p = parts[i];
|
|
1156
|
+
if (p.type === "tool" && !p.parentId) {
|
|
1157
|
+
return ASK_USER_TOOL.test(p.name);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
function deriveTaskState(input) {
|
|
1163
|
+
if (input.stillRunning) {
|
|
1164
|
+
return input.status === "queued" ? "submitted" : "working";
|
|
1165
|
+
}
|
|
1166
|
+
switch (input.status) {
|
|
1167
|
+
case "error":
|
|
1168
|
+
case "broken":
|
|
1169
|
+
return "failed";
|
|
1170
|
+
case "stopped":
|
|
1171
|
+
return "canceled";
|
|
1172
|
+
default:
|
|
1173
|
+
return endedOnAskUser(input.lastAgentParts) ? "input-required" : "completed";
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/mcp/wait-for-turn.ts
|
|
1178
|
+
var MCP_WAIT_FOR_TURN_MAX_MS = 45e3;
|
|
1179
|
+
function mcpWaitForTurnTimeoutMs(requested) {
|
|
1180
|
+
const n = requested ?? MCP_WAIT_FOR_TURN_MAX_MS;
|
|
1181
|
+
if (!Number.isFinite(n)) return MCP_WAIT_FOR_TURN_MAX_MS;
|
|
1182
|
+
return Math.min(Math.max(1e3, Math.floor(n)), MCP_WAIT_FOR_TURN_MAX_MS);
|
|
1183
|
+
}
|
|
1184
|
+
var MCP_WAIT_STILL_RUNNING_HINT = "Child is still working. Call wait_for_turn again. Do not send_to_chat a check-in (that steers / interrupts) or assume a hang while progress is updating.";
|
|
1185
|
+
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_chat a check-in (that steers / interrupts), force_stop, or assume it failed to start.";
|
|
1186
|
+
var MCP_WAIT_STOPPED_HINT = "Child was stopped before the turn finished. Do not treat this as success. send_to_chat to resume, or tell the user.";
|
|
1187
|
+
var MCP_WAIT_BROKEN_HINT = "Child worktree is broken (missing on disk). Tell the user \u2014 do not treat this as success.";
|
|
1188
|
+
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.";
|
|
1189
|
+
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_chat a check-in.";
|
|
1190
|
+
function mcpWaitTaskHint(taskState, status) {
|
|
1191
|
+
switch (taskState) {
|
|
1192
|
+
case "submitted":
|
|
1193
|
+
return MCP_WAIT_QUEUED_HINT;
|
|
1194
|
+
case "working":
|
|
1195
|
+
return MCP_WAIT_STILL_RUNNING_HINT;
|
|
1196
|
+
case "input-required":
|
|
1197
|
+
return MCP_WAIT_INPUT_REQUIRED_HINT;
|
|
1198
|
+
case "canceled":
|
|
1199
|
+
return MCP_WAIT_STOPPED_HINT;
|
|
1200
|
+
case "failed":
|
|
1201
|
+
return status === "broken" ? MCP_WAIT_BROKEN_HINT : MCP_WAIT_ERROR_HINT;
|
|
1202
|
+
default:
|
|
1203
|
+
return void 0;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function nonemptyIso(value) {
|
|
1207
|
+
const trimmed = value?.trim();
|
|
1208
|
+
return trimmed ? trimmed : null;
|
|
1209
|
+
}
|
|
1210
|
+
function lastActivityAtForWait(stillRunning, liveUpdatedAt, fallbacks) {
|
|
1211
|
+
if (stillRunning) return nonemptyIso(liveUpdatedAt);
|
|
1212
|
+
for (const value of fallbacks) {
|
|
1213
|
+
const iso = nonemptyIso(value);
|
|
1214
|
+
if (iso) return iso;
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
function waitForTurnToolResult(input) {
|
|
1219
|
+
return {
|
|
1220
|
+
...input,
|
|
1221
|
+
usage: input.usage ?? null,
|
|
1222
|
+
hint: mcpWaitTaskHint(input.taskState, input.status),
|
|
1223
|
+
incomplete: needsCoordinatorAction(input.taskState)
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/mcp/wait-for-job.ts
|
|
1228
|
+
var MAX_JOB_CONTINUES = 8;
|
|
1229
|
+
var MAX_JOB_DELTA_LINES = 80;
|
|
1230
|
+
var MAX_JOB_DELTA_CHARS = 4e3;
|
|
1231
|
+
var DEFERRED_DONE_MAX_CHARS = 480;
|
|
1232
|
+
var GH_WATCH_REFRESH = "Refreshing run status every 3 seconds.";
|
|
1233
|
+
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.";
|
|
1234
|
+
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.";
|
|
1235
|
+
var JOB_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
1236
|
+
function mcpWaitForJobTimeoutMs(requested) {
|
|
1237
|
+
return mcpWaitForTurnTimeoutMs(requested);
|
|
1238
|
+
}
|
|
1239
|
+
function sanitizeDetachedJobId(id) {
|
|
1240
|
+
const s = id.trim();
|
|
1241
|
+
if (!JOB_ID_RE.test(s)) {
|
|
1242
|
+
throw new Error(`detached-job id must be 1\u201364 chars [A-Za-z0-9._-], got ${JSON.stringify(id)}`);
|
|
1243
|
+
}
|
|
1244
|
+
return s;
|
|
1245
|
+
}
|
|
1246
|
+
function jobAlive(pid) {
|
|
1247
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1248
|
+
try {
|
|
1249
|
+
process.kill(pid, 0);
|
|
1250
|
+
return true;
|
|
1251
|
+
} catch {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function processGroupAlive(pgid) {
|
|
1256
|
+
if (process.platform === "win32") return false;
|
|
1257
|
+
if (!Number.isInteger(pgid) || pgid <= 1) return false;
|
|
1258
|
+
try {
|
|
1259
|
+
process.kill(-pgid, 0);
|
|
1260
|
+
return true;
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EPERM");
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
function jobTreeAlive(pid) {
|
|
1266
|
+
if (pid == null) return false;
|
|
1267
|
+
return jobAlive(pid) || processGroupAlive(pid);
|
|
1268
|
+
}
|
|
1269
|
+
function readIntFile(file) {
|
|
1270
|
+
if (!existsSync3(file)) return null;
|
|
1271
|
+
const n = Number.parseInt(readFileSync3(file, "utf8").trim(), 10);
|
|
1272
|
+
return Number.isInteger(n) ? n : null;
|
|
1273
|
+
}
|
|
1274
|
+
function jobDir(root, id, legacy = false) {
|
|
1275
|
+
return join4(root, legacy ? LEGACY_DETACHED_JOBS_DIR : DETACHED_JOBS_DIR, id);
|
|
1276
|
+
}
|
|
1277
|
+
function resolveJobDir(root, id) {
|
|
1278
|
+
const modern = jobDir(root, id, false);
|
|
1279
|
+
if (existsSync3(modern)) return modern;
|
|
1280
|
+
const legacy = jobDir(root, id, true);
|
|
1281
|
+
if (existsSync3(legacy)) return legacy;
|
|
1282
|
+
return modern;
|
|
1283
|
+
}
|
|
1284
|
+
function listJobIdsIn(root, rel) {
|
|
1285
|
+
const dir = join4(root, rel);
|
|
1286
|
+
if (!existsSync3(dir)) return [];
|
|
1287
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && JOB_ID_RE.test(e.name)).map((e) => e.name);
|
|
1288
|
+
}
|
|
1289
|
+
function listRunningDetachedJobs(worktreePath) {
|
|
1290
|
+
const root = worktreePath.trim();
|
|
1291
|
+
if (!root) return [];
|
|
1292
|
+
const ids = /* @__PURE__ */ new Set([
|
|
1293
|
+
...listJobIdsIn(root, DETACHED_JOBS_DIR),
|
|
1294
|
+
...listJobIdsIn(root, LEGACY_DETACHED_JOBS_DIR)
|
|
1295
|
+
]);
|
|
1296
|
+
const running = [];
|
|
1297
|
+
for (const id of ids) {
|
|
1298
|
+
const dir = resolveJobDir(root, id);
|
|
1299
|
+
const pid = readIntFile(join4(dir, "pid"));
|
|
1300
|
+
if (jobTreeAlive(pid)) running.push(id);
|
|
1301
|
+
}
|
|
1302
|
+
return running.sort();
|
|
1303
|
+
}
|
|
1304
|
+
function looksLikeDeferredDonePromise(text) {
|
|
1305
|
+
const t = (text ?? "").trim();
|
|
1306
|
+
if (!t || t.length > DEFERRED_DONE_MAX_CHARS) return false;
|
|
1307
|
+
const unquoted = t.replace(/`[^`]*`/g, " ").replace(/["“”][^"“”]*["“”]/g, " ").replace(/\s+/g, " ").trim();
|
|
1308
|
+
if (!unquoted) return false;
|
|
1309
|
+
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);
|
|
1310
|
+
}
|
|
1311
|
+
function isJobContinuePrompt(text) {
|
|
1312
|
+
if (typeof text !== "string") return false;
|
|
1313
|
+
const t = text.trim();
|
|
1314
|
+
return t.startsWith("Detached job still running:") || t.startsWith("You ended the turn after promising to report later");
|
|
1315
|
+
}
|
|
1316
|
+
function collapseGhRunWatchLog(text) {
|
|
1317
|
+
if (!text.includes(GH_WATCH_REFRESH)) return text;
|
|
1318
|
+
const frames = text.split(/(?=Refreshing run status every 3 seconds\.)/);
|
|
1319
|
+
const watchFrames = frames.filter((f) => f.includes(GH_WATCH_REFRESH));
|
|
1320
|
+
if (watchFrames.length <= 1) return text;
|
|
1321
|
+
const prefix = frames[0]?.includes(GH_WATCH_REFRESH) ? "" : frames[0] ?? "";
|
|
1322
|
+
const last = watchFrames[watchFrames.length - 1] ?? "";
|
|
1323
|
+
const omitted = watchFrames.length - 1;
|
|
1324
|
+
const note = `(${omitted} identical gh run watch refresh${omitted === 1 ? "" : "es"} omitted)
|
|
1325
|
+
`;
|
|
1326
|
+
return [prefix.trimEnd(), note, last.trimStart()].filter(Boolean).join("\n");
|
|
1327
|
+
}
|
|
1328
|
+
function capJobLogDelta(text) {
|
|
1329
|
+
const collapsed = collapseGhRunWatchLog(text);
|
|
1330
|
+
const lines = collapsed.split("\n");
|
|
1331
|
+
let out = lines.length > MAX_JOB_DELTA_LINES ? `\u2026(${lines.length - MAX_JOB_DELTA_LINES} earlier lines omitted)
|
|
1332
|
+
${lines.slice(-MAX_JOB_DELTA_LINES).join("\n")}` : collapsed;
|
|
1333
|
+
if (out.length > MAX_JOB_DELTA_CHARS) {
|
|
1334
|
+
out = `\u2026(truncated ${out.length} chars)
|
|
1335
|
+
${out.slice(-MAX_JOB_DELTA_CHARS)}`;
|
|
1336
|
+
}
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
function formatJobStillRunningContinuePrompt(jobIds) {
|
|
1340
|
+
const ids = jobIds.join(", ");
|
|
1341
|
+
return [
|
|
1342
|
+
`Detached job still running: ${ids}.`,
|
|
1343
|
+
"Do not end this turn. Loop wait_for_job (same id) until stillRunning is false. The log pane updates from each wait result.",
|
|
1344
|
+
"If the job is hanging or doing the wrong thing, call stop_job (same id) instead of looping forever.",
|
|
1345
|
+
"Then report the result. Do not tell the user you will let them know later."
|
|
1346
|
+
].join(" ");
|
|
1347
|
+
}
|
|
1348
|
+
function formatDeferredDoneContinuePrompt() {
|
|
1349
|
+
return [
|
|
1350
|
+
"You ended the turn after promising to report later, but no detached job is running.",
|
|
1351
|
+
"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).",
|
|
1352
|
+
"Do not say you will let the user know later."
|
|
1353
|
+
].join(" ");
|
|
1354
|
+
}
|
|
1355
|
+
function turnWatchedDetachedJob(parts) {
|
|
1356
|
+
return parts.some((p) => {
|
|
1357
|
+
if (p.type !== "tool") return false;
|
|
1358
|
+
if (/wait_for_job$/i.test(p.name ?? "") || /stop_job$/i.test(p.name ?? "")) return true;
|
|
1359
|
+
const blob = [p.name, p.detail, p.description, p.input ? JSON.stringify(p.input) : ""].filter(Boolean).join(" ");
|
|
1360
|
+
return /detached-job\.(?:js|cjs)\b/i.test(blob);
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
function planJobContinue(opts) {
|
|
1364
|
+
if (opts.isOrchestrator) return { action: "none" };
|
|
1365
|
+
if (opts.agent === "brightsy") return { action: "none" };
|
|
1366
|
+
if (opts.queueLength > 0) return { action: "none" };
|
|
1367
|
+
if (opts.continueCount >= MAX_JOB_CONTINUES) return { action: "none" };
|
|
1368
|
+
const farewell = looksLikeDeferredDonePromise(opts.chatText);
|
|
1369
|
+
if (opts.runningJobIds.length > 0 && (farewell || opts.watchedJob)) {
|
|
1370
|
+
return {
|
|
1371
|
+
action: "wait",
|
|
1372
|
+
jobIds: opts.runningJobIds,
|
|
1373
|
+
prompt: formatJobStillRunningContinuePrompt(opts.runningJobIds)
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
if (looksLikeDeferredDonePromise(opts.chatText) && !opts.alreadyNudged) {
|
|
1377
|
+
return { action: "nudge", prompt: formatDeferredDoneContinuePrompt() };
|
|
1378
|
+
}
|
|
1379
|
+
return { action: "none" };
|
|
1380
|
+
}
|
|
1381
|
+
function tailProgress(logFile, maxLines = 12) {
|
|
1382
|
+
if (!existsSync3(logFile)) return "(no log yet)";
|
|
1383
|
+
const lines = readFileSync3(logFile, "utf8").split("\n");
|
|
1384
|
+
return lines.slice(-maxLines).join("\n");
|
|
1385
|
+
}
|
|
1386
|
+
function lastProgressLine(progress) {
|
|
1387
|
+
const last = progress.split("\n").map((l) => l.trim()).filter((l) => l && l !== "(no log yet)").at(-1);
|
|
1388
|
+
return last ? last.slice(0, 72) : void 0;
|
|
1389
|
+
}
|
|
1390
|
+
function readLogLines(file) {
|
|
1391
|
+
if (!existsSync3(file)) return [];
|
|
1392
|
+
const lines = readFileSync3(file, "utf8").split("\n");
|
|
1393
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
1394
|
+
return lines;
|
|
1395
|
+
}
|
|
1396
|
+
function takeDelta(logFile, cursorFile) {
|
|
1397
|
+
const lines = readLogLines(logFile);
|
|
1398
|
+
const cursor = readIntFile(cursorFile) ?? 0;
|
|
1399
|
+
const start = Math.min(Math.max(0, cursor), lines.length);
|
|
1400
|
+
return { delta: capJobLogDelta(lines.slice(start).join("\n")), nextCursor: lines.length };
|
|
1401
|
+
}
|
|
1402
|
+
function snapshotJob(dir) {
|
|
1403
|
+
const pid = readIntFile(join4(dir, "pid"));
|
|
1404
|
+
const running = jobTreeAlive(pid);
|
|
1405
|
+
return {
|
|
1406
|
+
pid,
|
|
1407
|
+
running,
|
|
1408
|
+
exitCode: readIntFile(join4(dir, "exit")),
|
|
1409
|
+
log: join4(dir, "log"),
|
|
1410
|
+
cursor: join4(dir, "present.cursor"),
|
|
1411
|
+
progress: tailProgress(join4(dir, "log"))
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
function toResult(id, snap, extra) {
|
|
1415
|
+
const failed = extra?.failed === true || !snap.running && snap.exitCode != null && snap.exitCode !== 0;
|
|
1416
|
+
const ok = !snap.running && snap.exitCode === 0;
|
|
1417
|
+
const stillRunning = snap.running && !ok;
|
|
1418
|
+
const { delta, nextCursor } = takeDelta(snap.log, snap.cursor);
|
|
1419
|
+
try {
|
|
1420
|
+
mkdirSync4(dirname3(snap.cursor), { recursive: true });
|
|
1421
|
+
writeFileSync3(snap.cursor, `${nextCursor}
|
|
1422
|
+
`);
|
|
1423
|
+
} catch {
|
|
1424
|
+
}
|
|
1425
|
+
const status = ok ? "ok" : failed && !stillRunning ? "failed" : stillRunning ? "running" : "idle";
|
|
1426
|
+
const progress = extra?.progress ?? snap.progress;
|
|
1427
|
+
return {
|
|
1428
|
+
stillRunning,
|
|
1429
|
+
ok,
|
|
1430
|
+
failed: Boolean(failed && !stillRunning && !ok),
|
|
1431
|
+
status,
|
|
1432
|
+
id,
|
|
1433
|
+
pid: snap.pid,
|
|
1434
|
+
exitCode: snap.exitCode ?? void 0,
|
|
1435
|
+
phase: lastProgressLine(progress),
|
|
1436
|
+
delta,
|
|
1437
|
+
progress,
|
|
1438
|
+
hint: stillRunning ? MCP_WAIT_JOB_STILL_RUNNING_HINT : void 0
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
async function sleepMs(ms) {
|
|
1442
|
+
await new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1443
|
+
}
|
|
1444
|
+
async function waitForDetachedJob(cwd, id, opts) {
|
|
1445
|
+
const jobId = sanitizeDetachedJobId(id);
|
|
1446
|
+
const root = cwd.trim() || process.cwd();
|
|
1447
|
+
const dir = resolveJobDir(root, jobId);
|
|
1448
|
+
const timeoutMs = mcpWaitForJobTimeoutMs(opts?.timeoutMs);
|
|
1449
|
+
const sleep = opts?.sleep ?? sleepMs;
|
|
1450
|
+
if (!existsSync3(dir)) {
|
|
1451
|
+
return {
|
|
1452
|
+
stillRunning: false,
|
|
1453
|
+
ok: false,
|
|
1454
|
+
failed: true,
|
|
1455
|
+
status: "failed",
|
|
1456
|
+
id: jobId,
|
|
1457
|
+
delta: "",
|
|
1458
|
+
progress: "No detached job. Start one first.",
|
|
1459
|
+
hint: "Start with detached-job.cjs start <id> -- <command>, then call wait_for_job again."
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
const deadline = Date.now() + timeoutMs;
|
|
1463
|
+
let snap = snapshotJob(dir);
|
|
1464
|
+
if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
|
|
1465
|
+
if (!snap.running && snap.pid == null && snap.progress === "(no log yet)") {
|
|
1466
|
+
return toResult(jobId, snap, {
|
|
1467
|
+
failed: true,
|
|
1468
|
+
progress: "No detached job. Start one first."
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
while (Date.now() < deadline) {
|
|
1472
|
+
snap = snapshotJob(dir);
|
|
1473
|
+
if (snap.exitCode === 0 && !snap.running) return toResult(jobId, snap);
|
|
1474
|
+
if (!snap.running) return toResult(jobId, snap, { failed: true });
|
|
1475
|
+
await sleep(Math.min(2e3, Math.max(50, deadline - Date.now())));
|
|
1476
|
+
}
|
|
1477
|
+
snap = snapshotJob(dir);
|
|
1478
|
+
return toResult(jobId, snap);
|
|
1479
|
+
}
|
|
1480
|
+
function killProcessTree(pid, signal) {
|
|
1481
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
1482
|
+
if (process.platform !== "win32") {
|
|
1483
|
+
try {
|
|
1484
|
+
process.kill(-pid, signal);
|
|
1485
|
+
return;
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
try {
|
|
1490
|
+
process.kill(pid, signal);
|
|
1491
|
+
} catch {
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
function appendJobLog(logFile, line) {
|
|
1495
|
+
try {
|
|
1496
|
+
mkdirSync4(dirname3(logFile), { recursive: true });
|
|
1497
|
+
const body = line.endsWith("\n") ? line : `${line}
|
|
1498
|
+
`;
|
|
1499
|
+
appendFileSync(logFile, body);
|
|
1500
|
+
} catch {
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async function stopDetachedJob(cwd, id, opts) {
|
|
1504
|
+
const jobId = sanitizeDetachedJobId(id);
|
|
1505
|
+
const root = cwd.trim() || process.cwd();
|
|
1506
|
+
const dir = resolveJobDir(root, jobId);
|
|
1507
|
+
const sleep = opts?.sleep ?? sleepMs;
|
|
1508
|
+
const why = opts?.reason?.trim() ?? "";
|
|
1509
|
+
if (!existsSync3(dir)) {
|
|
1510
|
+
return {
|
|
1511
|
+
stillRunning: false,
|
|
1512
|
+
ok: false,
|
|
1513
|
+
failed: true,
|
|
1514
|
+
status: "failed",
|
|
1515
|
+
id: jobId,
|
|
1516
|
+
delta: "",
|
|
1517
|
+
progress: "No detached job. Start one first.",
|
|
1518
|
+
hint: "Start with detached-job.cjs start <id> -- <command>, then wait_for_job or stop_job.",
|
|
1519
|
+
stopped: false,
|
|
1520
|
+
reason: "not-found"
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
let snap = snapshotJob(dir);
|
|
1524
|
+
if (!snap.running) {
|
|
1525
|
+
const result2 = toResult(jobId, snap);
|
|
1526
|
+
return {
|
|
1527
|
+
...result2,
|
|
1528
|
+
stopped: false,
|
|
1529
|
+
reason: "not-running",
|
|
1530
|
+
hint: "That job is already finished. Read the log; do not wait again unless you start a new command."
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
appendJobLog(snap.log, `$ stop${why ? `: ${why}` : ""}`);
|
|
1534
|
+
const targetPid = snap.pid;
|
|
1535
|
+
if (targetPid != null) killProcessTree(targetPid, "SIGTERM");
|
|
1536
|
+
const graceMs = Number.isFinite(opts?.graceMs) ? Math.max(50, opts.graceMs) : 2e3;
|
|
1537
|
+
const deadline = Date.now() + graceMs;
|
|
1538
|
+
while (Date.now() < deadline && jobTreeAlive(targetPid)) {
|
|
1539
|
+
await sleep(Math.min(100, Math.max(20, deadline - Date.now())));
|
|
1540
|
+
}
|
|
1541
|
+
if (targetPid != null) {
|
|
1542
|
+
killProcessTree(targetPid, "SIGKILL");
|
|
1543
|
+
await sleep(100);
|
|
1544
|
+
}
|
|
1545
|
+
const exitFile = join4(dir, "exit");
|
|
1546
|
+
if (!existsSync3(exitFile)) {
|
|
1547
|
+
try {
|
|
1548
|
+
writeFileSync3(exitFile, "1\n");
|
|
1549
|
+
} catch {
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
snap = snapshotJob(dir);
|
|
1553
|
+
const stillRunning = jobTreeAlive(targetPid) || snap.running;
|
|
1554
|
+
const result = toResult(jobId, snap, { failed: true });
|
|
1555
|
+
const stopped = !stillRunning;
|
|
1556
|
+
return {
|
|
1557
|
+
...result,
|
|
1558
|
+
stillRunning,
|
|
1559
|
+
ok: false,
|
|
1560
|
+
failed: true,
|
|
1561
|
+
status: stillRunning ? "running" : "failed",
|
|
1562
|
+
stopped,
|
|
1563
|
+
reason: stopped ? "stopped" : "still-running",
|
|
1564
|
+
stopReason: why || void 0,
|
|
1565
|
+
hint: stopped ? MCP_STOP_JOB_HINT : MCP_WAIT_JOB_STILL_RUNNING_HINT
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// src/hook/port-range.ts
|
|
1570
|
+
import { createServer } from "net";
|
|
1571
|
+
var PORT_RANGE_SIZE = 10;
|
|
1572
|
+
var RECLAIM_RETRY_DELAY_MS = 40;
|
|
1573
|
+
var RECLAIM_TRIES = 8;
|
|
1574
|
+
async function killReclaimable(ports, worktreePath, signal) {
|
|
1575
|
+
const { killListenersOnPorts: killListenersOnPorts2, killStaleWorktreeListeners: killStaleWorktreeListeners2 } = await import("./conductor-NRKTEN5X.js");
|
|
1576
|
+
if (worktreePath) killStaleWorktreeListeners2(ports, worktreePath, signal);
|
|
1577
|
+
else killListenersOnPorts2(ports);
|
|
1578
|
+
}
|
|
1579
|
+
async function reservePort() {
|
|
1580
|
+
return new Promise((resolve2, reject) => {
|
|
1581
|
+
const server = createServer();
|
|
1582
|
+
let released = false;
|
|
1583
|
+
const release = () => new Promise((res) => {
|
|
1584
|
+
if (released) {
|
|
1585
|
+
res();
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
released = true;
|
|
1589
|
+
server.close(() => res());
|
|
1590
|
+
});
|
|
1591
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1592
|
+
const addr = server.address();
|
|
1593
|
+
if (!addr || typeof addr === "string") {
|
|
1594
|
+
void release().then(() => reject(new Error("Failed to allocate port")));
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
resolve2({ port: addr.port, release });
|
|
1598
|
+
});
|
|
1599
|
+
server.on("error", reject);
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
async function allocatePort() {
|
|
1603
|
+
const held = await reservePort();
|
|
1604
|
+
await held.release();
|
|
1605
|
+
return held.port;
|
|
1606
|
+
}
|
|
1607
|
+
async function tryReservePort(port) {
|
|
1608
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
1609
|
+
return new Promise((resolve2) => {
|
|
1610
|
+
const server = createServer();
|
|
1611
|
+
let released = false;
|
|
1612
|
+
const release = () => new Promise((res) => {
|
|
1613
|
+
if (released) {
|
|
1614
|
+
res();
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
released = true;
|
|
1618
|
+
server.close(() => res());
|
|
1619
|
+
});
|
|
1620
|
+
server.once("error", () => {
|
|
1621
|
+
try {
|
|
1622
|
+
server.close();
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
resolve2(null);
|
|
1626
|
+
});
|
|
1627
|
+
server.listen(port, "127.0.0.1", () => {
|
|
1628
|
+
resolve2({ port, release });
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
async function tryReservePorts(ports) {
|
|
1633
|
+
const held = [];
|
|
1634
|
+
for (const port of ports) {
|
|
1635
|
+
const next = await tryReservePort(port);
|
|
1636
|
+
if (!next) {
|
|
1637
|
+
await Promise.all(held.map((h) => h.release()));
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
held.push(next);
|
|
1641
|
+
}
|
|
1642
|
+
return held;
|
|
1643
|
+
}
|
|
1644
|
+
function normalizePreferredPorts(ports, size) {
|
|
1645
|
+
if (!ports?.length) return [];
|
|
1646
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1647
|
+
const out = [];
|
|
1648
|
+
for (const p of ports) {
|
|
1649
|
+
if (!Number.isInteger(p) || p <= 0 || p > 65535 || seen.has(p)) continue;
|
|
1650
|
+
seen.add(p);
|
|
1651
|
+
out.push(p);
|
|
1652
|
+
if (out.length >= size) break;
|
|
1653
|
+
}
|
|
1654
|
+
return out;
|
|
1655
|
+
}
|
|
1656
|
+
function delay(ms) {
|
|
1657
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1658
|
+
}
|
|
1659
|
+
function portSet(ports) {
|
|
1660
|
+
const out = /* @__PURE__ */ new Set();
|
|
1661
|
+
for (const port of ports ?? []) {
|
|
1662
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) out.add(port);
|
|
1663
|
+
}
|
|
1664
|
+
return out;
|
|
1665
|
+
}
|
|
1666
|
+
async function reservePortOutside(avoid) {
|
|
1667
|
+
const skipped = [];
|
|
1668
|
+
try {
|
|
1669
|
+
for (let i = 0; i < 32; i++) {
|
|
1670
|
+
const held = await reservePort();
|
|
1671
|
+
if (!avoid.has(held.port)) {
|
|
1672
|
+
await Promise.all(skipped.map((h) => h.release()));
|
|
1673
|
+
return held;
|
|
1674
|
+
}
|
|
1675
|
+
skipped.push(held);
|
|
1676
|
+
}
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
await Promise.all(skipped.map((h) => h.release()));
|
|
1679
|
+
throw err;
|
|
1680
|
+
}
|
|
1681
|
+
await Promise.all(skipped.map((h) => h.release()));
|
|
1682
|
+
throw new Error(
|
|
1683
|
+
"Failed to allocate a port that is not reserved by another run script"
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
async function fillPortRange(held, size, avoid) {
|
|
1687
|
+
if (held.length >= size) return held;
|
|
1688
|
+
const base = held[0]?.port;
|
|
1689
|
+
const taken = /* @__PURE__ */ new Set([...held.map((h) => h.port), ...avoid]);
|
|
1690
|
+
try {
|
|
1691
|
+
let offset = 0;
|
|
1692
|
+
while (held.length < size) {
|
|
1693
|
+
const candidate = base != null ? base + offset : null;
|
|
1694
|
+
offset += 1;
|
|
1695
|
+
if (candidate != null && (taken.has(candidate) || candidate > 65535)) {
|
|
1696
|
+
if (candidate > 65535) {
|
|
1697
|
+
const ephemeral = await reservePortOutside(avoid);
|
|
1698
|
+
held.push(ephemeral);
|
|
1699
|
+
taken.add(ephemeral.port);
|
|
1700
|
+
}
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
const next = candidate != null ? await tryReservePort(candidate) : null;
|
|
1704
|
+
if (next && !avoid.has(next.port)) {
|
|
1705
|
+
held.push(next);
|
|
1706
|
+
taken.add(next.port);
|
|
1707
|
+
} else {
|
|
1708
|
+
if (next) await next.release();
|
|
1709
|
+
const ephemeral = await reservePortOutside(avoid);
|
|
1710
|
+
held.push(ephemeral);
|
|
1711
|
+
taken.add(ephemeral.port);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return held;
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
await Promise.all(held.map((h) => h.release()));
|
|
1717
|
+
throw err;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
async function tryReserveAvailablePorts(ports) {
|
|
1721
|
+
const held = [];
|
|
1722
|
+
for (const port of ports) {
|
|
1723
|
+
const next = await tryReservePort(port);
|
|
1724
|
+
if (next) held.push(next);
|
|
1725
|
+
}
|
|
1726
|
+
return held;
|
|
1727
|
+
}
|
|
1728
|
+
async function reclaimPreferredPorts(ports, worktreePath, avoid) {
|
|
1729
|
+
const reclaimable = ports.filter((p) => !avoid.has(p));
|
|
1730
|
+
const primary = reclaimable[0];
|
|
1731
|
+
if (primary == null || avoid.has(ports[0] ?? primary)) return null;
|
|
1732
|
+
const takePrimary = async () => {
|
|
1733
|
+
const held2 = await tryReserveAvailablePorts(reclaimable);
|
|
1734
|
+
if (held2.some((h) => h.port === primary)) return held2;
|
|
1735
|
+
await Promise.all(held2.map((h) => h.release()));
|
|
1736
|
+
return null;
|
|
1737
|
+
};
|
|
1738
|
+
let held = await takePrimary();
|
|
1739
|
+
if (held) return held;
|
|
1740
|
+
await killReclaimable(reclaimable, worktreePath, "SIGTERM");
|
|
1741
|
+
for (let i = 0; i < RECLAIM_TRIES; i++) {
|
|
1742
|
+
await delay(RECLAIM_RETRY_DELAY_MS);
|
|
1743
|
+
held = await takePrimary();
|
|
1744
|
+
if (held) return held;
|
|
1745
|
+
if (i === 2 && worktreePath) {
|
|
1746
|
+
await killReclaimable(reclaimable, worktreePath, "SIGKILL");
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
async function reservePortRange(size = PORT_RANGE_SIZE, opts) {
|
|
1752
|
+
const avoid = portSet(opts?.avoid);
|
|
1753
|
+
const preferred = normalizePreferredPorts(opts?.preferred, size);
|
|
1754
|
+
const primary = preferred[0];
|
|
1755
|
+
if (preferred.length && (primary == null || !avoid.has(primary))) {
|
|
1756
|
+
const reused = await reclaimPreferredPorts(
|
|
1757
|
+
preferred,
|
|
1758
|
+
opts?.worktreePath,
|
|
1759
|
+
avoid
|
|
1760
|
+
);
|
|
1761
|
+
if (reused) return fillPortRange(reused, size, avoid);
|
|
1762
|
+
}
|
|
1763
|
+
const held = [await reservePortOutside(avoid)];
|
|
1764
|
+
return fillPortRange(held, size, avoid);
|
|
1765
|
+
}
|
|
1766
|
+
async function allocatePortRange(size = PORT_RANGE_SIZE) {
|
|
1767
|
+
const held = await reservePortRange(size);
|
|
1768
|
+
const ports = held.map((h) => h.port);
|
|
1769
|
+
await Promise.all(held.map((h) => h.release()));
|
|
1770
|
+
return ports;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// src/hook/conductor.ts
|
|
1774
|
+
function matchSimpleGlob(pattern, name) {
|
|
1775
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1776
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
1777
|
+
}
|
|
1778
|
+
function readWorktreeInclude(repoPath) {
|
|
1779
|
+
const path = join5(repoPath, ".worktreeinclude");
|
|
1780
|
+
if (!existsSync4(path)) return [];
|
|
1781
|
+
return readFileSync4(path, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
1782
|
+
}
|
|
1783
|
+
function resolveFilesToCopy(repoPath) {
|
|
1784
|
+
const fromInclude = readWorktreeInclude(repoPath);
|
|
1785
|
+
if (fromInclude.length) return fromInclude;
|
|
1786
|
+
const settings = loadRepoSettings(repoPath);
|
|
1787
|
+
if (settings?.filesToCopy?.length) return settings.filesToCopy;
|
|
1788
|
+
if (settings?.fileIncludeGlobs?.length) {
|
|
1789
|
+
const matched = [];
|
|
1790
|
+
try {
|
|
1791
|
+
for (const entry of readdirSync2(repoPath, { withFileTypes: true })) {
|
|
1792
|
+
if (!entry.isFile()) continue;
|
|
1793
|
+
for (const glob of settings.fileIncludeGlobs) {
|
|
1794
|
+
if (matchSimpleGlob(glob, entry.name) || matchSimpleGlob(basename(glob), entry.name)) {
|
|
1795
|
+
matched.push(entry.name);
|
|
1796
|
+
break;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
} catch {
|
|
1801
|
+
}
|
|
1802
|
+
if (matched.length) return [...new Set(matched)];
|
|
1803
|
+
}
|
|
1804
|
+
const defaults = [];
|
|
1805
|
+
try {
|
|
1806
|
+
for (const entry of readdirSync2(repoPath, { withFileTypes: true })) {
|
|
1807
|
+
if (entry.isFile() && entry.name.startsWith(".env")) {
|
|
1808
|
+
defaults.push(entry.name);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
} catch {
|
|
1812
|
+
}
|
|
1813
|
+
if (defaults.length) return defaults;
|
|
1814
|
+
return [".env.local", ".env"];
|
|
1815
|
+
}
|
|
1816
|
+
function copyConfiguredFiles(repoPath, worktreePath) {
|
|
1817
|
+
const patterns = resolveFilesToCopy(repoPath);
|
|
1818
|
+
const copied = [];
|
|
1819
|
+
for (const rel of patterns) {
|
|
1820
|
+
const src = join5(repoPath, rel);
|
|
1821
|
+
if (!existsSync4(src)) continue;
|
|
1822
|
+
const dest = join5(worktreePath, rel);
|
|
1823
|
+
mkdirSync5(dirname4(dest), { recursive: true });
|
|
1824
|
+
copyFileSync(src, dest);
|
|
1825
|
+
copied.push(rel);
|
|
1826
|
+
}
|
|
1827
|
+
return copied;
|
|
1828
|
+
}
|
|
1829
|
+
var cachedLoginEnv = null;
|
|
1830
|
+
async function captureLoginEnv() {
|
|
1831
|
+
if (cachedLoginEnv) return { ...cachedLoginEnv };
|
|
1832
|
+
const shell = process.env.SHELL?.trim() || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
|
|
1833
|
+
try {
|
|
1834
|
+
const result = await execa(shell, ["-l", "-c", "env -0"], {
|
|
1835
|
+
reject: false,
|
|
1836
|
+
timeout: 5e3
|
|
1837
|
+
});
|
|
1838
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
1839
|
+
const env = { ...process.env };
|
|
1840
|
+
for (const entry of result.stdout.split("\0")) {
|
|
1841
|
+
if (!entry) continue;
|
|
1842
|
+
const eq = entry.indexOf("=");
|
|
1843
|
+
if (eq <= 0) continue;
|
|
1844
|
+
const key = entry.slice(0, eq);
|
|
1845
|
+
const value = entry.slice(eq + 1);
|
|
1846
|
+
if (key) env[key] = value;
|
|
1847
|
+
}
|
|
1848
|
+
cachedLoginEnv = env;
|
|
1849
|
+
return { ...env };
|
|
1850
|
+
}
|
|
1851
|
+
} catch {
|
|
1852
|
+
}
|
|
1853
|
+
cachedLoginEnv = { ...process.env };
|
|
1854
|
+
return { ...cachedLoginEnv };
|
|
1855
|
+
}
|
|
1856
|
+
function buildWorkspaceScriptEnv(opts, baseEnv) {
|
|
1857
|
+
const env = stripNestedElectronEnv({
|
|
1858
|
+
...baseEnv ?? process.env
|
|
1859
|
+
});
|
|
1860
|
+
const name = opts.workspaceName ?? basename(opts.worktreePath);
|
|
1861
|
+
const ports = opts.ports ?? [];
|
|
1862
|
+
const primary = ports[0];
|
|
1863
|
+
env.SIDEBOARD_WORKSPACE_NAME = name;
|
|
1864
|
+
env.SIDEBOARD_WORKSPACE_PATH = opts.worktreePath;
|
|
1865
|
+
env.SIDEBOARD_ROOT_PATH = opts.repoPath;
|
|
1866
|
+
env.SIDEBOARD_IS_LOCAL = "1";
|
|
1867
|
+
if (opts.defaultBranch) env.SIDEBOARD_DEFAULT_BRANCH = opts.defaultBranch;
|
|
1868
|
+
env.CONDUCTOR_WORKSPACE_NAME = name;
|
|
1869
|
+
env.CONDUCTOR_WORKSPACE_PATH = opts.worktreePath;
|
|
1870
|
+
env.CONDUCTOR_ROOT_PATH = opts.repoPath;
|
|
1871
|
+
env.CONDUCTOR_IS_LOCAL = "1";
|
|
1872
|
+
if (opts.defaultBranch) env.CONDUCTOR_DEFAULT_BRANCH = opts.defaultBranch;
|
|
1873
|
+
if (primary != null) {
|
|
1874
|
+
env.SIDEBOARD_PORT = String(primary);
|
|
1875
|
+
env.CONDUCTOR_PORT = String(primary);
|
|
1876
|
+
env.PORT = String(primary);
|
|
1877
|
+
for (let i = 1; i < ports.length; i++) {
|
|
1878
|
+
const p = ports[i];
|
|
1879
|
+
env[`SIDEBOARD_PORT_${i}`] = String(p);
|
|
1880
|
+
env[`CONDUCTOR_PORT_${i}`] = String(p);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
applyWorktreePkgCacheEnv(env, opts.worktreePath);
|
|
1884
|
+
return env;
|
|
1885
|
+
}
|
|
1886
|
+
function pipeLines(stream, onLine) {
|
|
1887
|
+
if (!stream || !onLine) return;
|
|
1888
|
+
const rl = createInterface({ input: stream });
|
|
1889
|
+
rl.on("line", onLine);
|
|
1890
|
+
}
|
|
1891
|
+
function killableListenerPids(raw, selfPid = process.pid) {
|
|
1892
|
+
const pids = [];
|
|
1893
|
+
for (const token of raw.trim().split(/\s+/)) {
|
|
1894
|
+
const pid = Number(token);
|
|
1895
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === selfPid) continue;
|
|
1896
|
+
pids.push(pid);
|
|
1897
|
+
}
|
|
1898
|
+
return pids;
|
|
1899
|
+
}
|
|
1900
|
+
function listUnixListenerPids(port, selfPid) {
|
|
1901
|
+
try {
|
|
1902
|
+
const raw = execFileSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
|
|
1903
|
+
encoding: "utf8",
|
|
1904
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1905
|
+
timeout: 2e3
|
|
1906
|
+
});
|
|
1907
|
+
return killableListenerPids(raw, selfPid);
|
|
1908
|
+
} catch {
|
|
1909
|
+
return [];
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
function listenerCwd(pid) {
|
|
1913
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
1914
|
+
try {
|
|
1915
|
+
const raw = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
1916
|
+
encoding: "utf8",
|
|
1917
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1918
|
+
timeout: 2e3
|
|
1919
|
+
});
|
|
1920
|
+
for (const line of raw.split("\n")) {
|
|
1921
|
+
if (line.startsWith("n") && line.length > 1) return line.slice(1);
|
|
1922
|
+
}
|
|
1923
|
+
} catch {
|
|
1924
|
+
}
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
function realpathOrResolve(path) {
|
|
1928
|
+
try {
|
|
1929
|
+
return realpathSync(path);
|
|
1930
|
+
} catch {
|
|
1931
|
+
return resolve(path);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
function isOtherWorktreeListenerCwd(cwd, worktreePath) {
|
|
1935
|
+
const wt = realpathOrResolve(worktreePath);
|
|
1936
|
+
const c = realpathOrResolve(cwd);
|
|
1937
|
+
if (c === wt || c.startsWith(wt + sep)) return false;
|
|
1938
|
+
const parent = dirname4(wt);
|
|
1939
|
+
const thisName = basename(wt);
|
|
1940
|
+
if (!thisName || c === parent) return false;
|
|
1941
|
+
if (!c.startsWith(parent + sep)) return false;
|
|
1942
|
+
const first = c.slice(parent.length + 1).split(sep)[0];
|
|
1943
|
+
return Boolean(first) && first !== thisName;
|
|
1944
|
+
}
|
|
1945
|
+
function signalPid(pid, signal) {
|
|
1946
|
+
try {
|
|
1947
|
+
process.kill(pid, signal);
|
|
1948
|
+
} catch {
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
function isThisWorktreeListenerCwd(cwd, worktreePath) {
|
|
1952
|
+
const wt = realpathOrResolve(worktreePath);
|
|
1953
|
+
const c = realpathOrResolve(cwd);
|
|
1954
|
+
return c === wt || c.startsWith(wt + sep);
|
|
1955
|
+
}
|
|
1956
|
+
function killDetachedRunTree(pid, worktreePath) {
|
|
1957
|
+
if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) return;
|
|
1958
|
+
const cwd = listenerCwd(pid);
|
|
1959
|
+
if (cwd) {
|
|
1960
|
+
if (!isThisWorktreeListenerCwd(cwd, worktreePath)) return;
|
|
1961
|
+
} else if (!processGroupAlive(pid)) {
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
process.kill(-pid, "SIGTERM");
|
|
1966
|
+
} catch {
|
|
1967
|
+
signalPid(pid, "SIGTERM");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function killListenersOnPorts(ports) {
|
|
1971
|
+
const selfPid = process.pid;
|
|
1972
|
+
for (const port of ports) {
|
|
1973
|
+
if (!Number.isFinite(port) || port <= 0) continue;
|
|
1974
|
+
try {
|
|
1975
|
+
if (process.platform === "win32") {
|
|
1976
|
+
execFileSync(
|
|
1977
|
+
"powershell",
|
|
1978
|
+
[
|
|
1979
|
+
"-NoProfile",
|
|
1980
|
+
"-Command",
|
|
1981
|
+
`Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne ${selfPid} } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`
|
|
1982
|
+
],
|
|
1983
|
+
{ stdio: "ignore", timeout: 3e3 }
|
|
1984
|
+
);
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
for (const pid of listUnixListenerPids(port, selfPid)) {
|
|
1988
|
+
signalPid(pid, "SIGTERM");
|
|
1989
|
+
}
|
|
1990
|
+
} catch {
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
function killStaleWorktreeListeners(ports, worktreePath, signal = "SIGTERM") {
|
|
1995
|
+
if (!worktreePath.trim()) {
|
|
1996
|
+
killListenersOnPorts(ports);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
if (process.platform === "win32") {
|
|
2000
|
+
killListenersOnPorts(ports);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
const selfPid = process.pid;
|
|
2004
|
+
for (const port of ports) {
|
|
2005
|
+
if (!Number.isFinite(port) || port <= 0) continue;
|
|
2006
|
+
for (const pid of listUnixListenerPids(port, selfPid)) {
|
|
2007
|
+
const cwd = listenerCwd(pid);
|
|
2008
|
+
if (cwd && isOtherWorktreeListenerCwd(cwd, worktreePath)) continue;
|
|
2009
|
+
signalPid(pid, signal);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
function killScriptTree(child, ports = []) {
|
|
2014
|
+
const pid = child.pid;
|
|
2015
|
+
if (pid) {
|
|
2016
|
+
try {
|
|
2017
|
+
if (process.platform === "win32") {
|
|
2018
|
+
void execa("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
|
|
2019
|
+
} else {
|
|
2020
|
+
process.kill(-pid, "SIGTERM");
|
|
2021
|
+
setTimeout(() => {
|
|
2022
|
+
try {
|
|
2023
|
+
process.kill(-pid, "SIGKILL");
|
|
2024
|
+
} catch {
|
|
2025
|
+
}
|
|
2026
|
+
}, 2500).unref?.();
|
|
2027
|
+
}
|
|
2028
|
+
} catch {
|
|
2029
|
+
try {
|
|
2030
|
+
child.kill("SIGTERM");
|
|
2031
|
+
} catch {
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
killListenersOnPorts(ports);
|
|
2036
|
+
}
|
|
2037
|
+
async function spawnWorkspaceScript(command, opts) {
|
|
2038
|
+
const loginEnv = await captureLoginEnv();
|
|
2039
|
+
const env = buildWorkspaceScriptEnv(
|
|
2040
|
+
{
|
|
2041
|
+
worktreePath: opts.worktreePath,
|
|
2042
|
+
repoPath: opts.repoPath,
|
|
2043
|
+
workspaceName: opts.workspaceName,
|
|
2044
|
+
defaultBranch: opts.defaultBranch,
|
|
2045
|
+
ports: opts.ports
|
|
2046
|
+
},
|
|
2047
|
+
loginEnv
|
|
2048
|
+
);
|
|
2049
|
+
try {
|
|
2050
|
+
mergeAgentGitAuthEnv(
|
|
2051
|
+
env,
|
|
2052
|
+
await resolveAgentGitAuthEnv(env, { cwd: opts.worktreePath })
|
|
2053
|
+
);
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
if (opts.heldPorts?.length) {
|
|
2057
|
+
await Promise.all(opts.heldPorts.map((h) => h.release()));
|
|
2058
|
+
}
|
|
2059
|
+
const shell = process.platform === "darwin" ? "zsh" : "bash";
|
|
2060
|
+
const child = execa(shell, ["-lc", command], {
|
|
2061
|
+
cwd: opts.worktreePath,
|
|
2062
|
+
reject: false,
|
|
2063
|
+
env,
|
|
2064
|
+
// Own process group so Stop can tear down the whole tree (not just the shell).
|
|
2065
|
+
// There is no settings.toml `stop=` / teardown hook for run scripts.
|
|
2066
|
+
...process.platform === "win32" ? {} : { detached: true }
|
|
2067
|
+
});
|
|
2068
|
+
pipeLines(child.stdout, opts.onLine);
|
|
2069
|
+
pipeLines(child.stderr, opts.onLine);
|
|
2070
|
+
const ports = opts.ports ?? [];
|
|
2071
|
+
return {
|
|
2072
|
+
pid: child.pid,
|
|
2073
|
+
kill: () => killScriptTree(child, ports),
|
|
2074
|
+
done: child.then((r) => r.exitCode ?? null),
|
|
2075
|
+
child
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
async function attachAbort(handle, signal) {
|
|
2079
|
+
if (signal) {
|
|
2080
|
+
const onAbort = () => handle.kill();
|
|
2081
|
+
if (signal.aborted) onAbort();
|
|
2082
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2083
|
+
}
|
|
2084
|
+
return handle.done;
|
|
2085
|
+
}
|
|
2086
|
+
async function runSetupScript(repoPath, worktreePath, onLine, opts) {
|
|
2087
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
2088
|
+
if (!settings?.setup) return { ran: false, exitCode: null, source: null };
|
|
2089
|
+
const source = workspaceSettingsSourceLabel(worktreePath, repoPath);
|
|
2090
|
+
onLine?.(`[setup] ${source ?? "settings.toml"}`);
|
|
2091
|
+
const handle = await spawnWorkspaceScript(settings.setup, {
|
|
2092
|
+
worktreePath,
|
|
2093
|
+
repoPath,
|
|
2094
|
+
defaultBranch: opts?.defaultBranch,
|
|
2095
|
+
onLine
|
|
2096
|
+
});
|
|
2097
|
+
const exitCode = await attachAbort(handle, opts?.signal);
|
|
2098
|
+
return {
|
|
2099
|
+
ran: true,
|
|
2100
|
+
exitCode,
|
|
2101
|
+
source,
|
|
2102
|
+
kill: handle.kill
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
async function runConventionSetup(repoPath, worktreePath, onLine, opts) {
|
|
2106
|
+
const found = findConventionSetup(worktreePath, repoPath);
|
|
2107
|
+
if (!found) return { ran: false, exitCode: null, source: null };
|
|
2108
|
+
onLine?.(`[setup] ${found.source}`);
|
|
2109
|
+
const handle = await spawnWorkspaceScript(found.command, {
|
|
2110
|
+
worktreePath,
|
|
2111
|
+
repoPath,
|
|
2112
|
+
defaultBranch: opts?.defaultBranch,
|
|
2113
|
+
onLine
|
|
2114
|
+
});
|
|
2115
|
+
const exitCode = await attachAbort(handle, opts?.signal);
|
|
2116
|
+
return {
|
|
2117
|
+
ran: true,
|
|
2118
|
+
exitCode,
|
|
2119
|
+
source: found.source,
|
|
2120
|
+
kill: handle.kill
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
async function runWorkspaceSetup(repoPath, worktreePath, onLine, opts) {
|
|
2124
|
+
await ensureWorktreeSideboardIgnored(worktreePath);
|
|
2125
|
+
const review = ensureReviewGuidelinesFile(worktreePath, repoPath);
|
|
2126
|
+
if (review.wrote) {
|
|
2127
|
+
onLine?.(`[setup] wrote ${review.path} \u2014 local Review guidelines`);
|
|
2128
|
+
}
|
|
2129
|
+
let setup = await runSetupScript(repoPath, worktreePath, onLine, opts);
|
|
2130
|
+
if (!setup.ran) {
|
|
2131
|
+
setup = await runCursorWorktreeSetup(repoPath, worktreePath, onLine);
|
|
2132
|
+
}
|
|
2133
|
+
if (!setup.ran) {
|
|
2134
|
+
setup = await runConventionSetup(repoPath, worktreePath, onLine, opts);
|
|
2135
|
+
}
|
|
2136
|
+
return setup;
|
|
2137
|
+
}
|
|
2138
|
+
async function runArchiveScript(repoPath, worktreePath, onLine) {
|
|
2139
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
2140
|
+
if (!settings?.archive) return { ran: false, exitCode: null };
|
|
2141
|
+
const handle = await spawnWorkspaceScript(settings.archive, {
|
|
2142
|
+
worktreePath,
|
|
2143
|
+
repoPath,
|
|
2144
|
+
onLine
|
|
2145
|
+
});
|
|
2146
|
+
const exitCode = await handle.done;
|
|
2147
|
+
return { ran: true, exitCode };
|
|
2148
|
+
}
|
|
2149
|
+
function listRunScripts(worktreePath, repoPath) {
|
|
2150
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
2151
|
+
if (!settings?.runScripts.length) return [];
|
|
2152
|
+
return settings.runScripts.filter((s) => {
|
|
2153
|
+
if (!s.availableIn?.length) return true;
|
|
2154
|
+
return s.availableIn.includes("local");
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
function getDefaultRunScript(worktreePath, repoPath) {
|
|
2158
|
+
const scripts = listRunScripts(worktreePath, repoPath);
|
|
2159
|
+
if (!scripts.length) return null;
|
|
2160
|
+
return scripts.find((s) => s.default === true) ?? scripts.find((s) => s.name === "dev") ?? scripts.find((s) => s.name !== "all") ?? scripts[0] ?? null;
|
|
2161
|
+
}
|
|
2162
|
+
function getRunScript(worktreePath, repoPath, name) {
|
|
2163
|
+
const scripts = listRunScripts(worktreePath, repoPath);
|
|
2164
|
+
if (!scripts.length) return null;
|
|
2165
|
+
if (name) {
|
|
2166
|
+
return scripts.find((s) => s.name === name) ?? null;
|
|
2167
|
+
}
|
|
2168
|
+
return getDefaultRunScript(worktreePath, repoPath);
|
|
2169
|
+
}
|
|
2170
|
+
function getRunMode(worktreePath, repoPath) {
|
|
2171
|
+
return loadWorkspaceSettings(worktreePath, repoPath)?.runMode ?? "concurrent";
|
|
2172
|
+
}
|
|
2173
|
+
async function startDevServer(repoPath, worktreePath, onLine, opts) {
|
|
2174
|
+
const script = getRunScript(worktreePath, repoPath, opts?.scriptName);
|
|
2175
|
+
if (!script) return null;
|
|
2176
|
+
const preferred = loadWorktreeRunPorts(worktreePath, script.name) ?? opts?.preferredPorts;
|
|
2177
|
+
const held = await reservePortRange(PORT_RANGE_SIZE, {
|
|
2178
|
+
preferred: preferred ?? void 0,
|
|
2179
|
+
worktreePath,
|
|
2180
|
+
avoid: loadOtherScriptRunPorts(worktreePath, script.name)
|
|
2181
|
+
});
|
|
2182
|
+
const ports = held.map((h) => h.port);
|
|
2183
|
+
saveWorktreeRunPorts(worktreePath, script.name, ports);
|
|
2184
|
+
try {
|
|
2185
|
+
const handle = await spawnWorkspaceScript(script.command, {
|
|
2186
|
+
worktreePath,
|
|
2187
|
+
repoPath,
|
|
2188
|
+
ports,
|
|
2189
|
+
heldPorts: held,
|
|
2190
|
+
defaultBranch: opts?.defaultBranch,
|
|
2191
|
+
onLine
|
|
2192
|
+
});
|
|
2193
|
+
return {
|
|
2194
|
+
pid: handle.pid,
|
|
2195
|
+
port: ports[0],
|
|
2196
|
+
ports,
|
|
2197
|
+
scriptName: script.name,
|
|
2198
|
+
kill: handle.kill,
|
|
2199
|
+
done: handle.done
|
|
2200
|
+
};
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
await Promise.all(held.map((h) => h.release()));
|
|
2203
|
+
throw err;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
export {
|
|
2208
|
+
needsCoordinatorAction,
|
|
2209
|
+
deriveTaskState,
|
|
2210
|
+
mcpWaitForTurnTimeoutMs,
|
|
2211
|
+
mcpWaitTaskHint,
|
|
2212
|
+
lastActivityAtForWait,
|
|
2213
|
+
waitForTurnToolResult,
|
|
2214
|
+
applyWorktreePkgCacheEnv,
|
|
2215
|
+
buildSessionSeed,
|
|
2216
|
+
buildBrightsySessionSeed,
|
|
2217
|
+
maybeCompactContext,
|
|
2218
|
+
worktreeSetupProcessKey,
|
|
2219
|
+
worktreeRunProcessKey,
|
|
2220
|
+
worktreeDevProcessKey,
|
|
2221
|
+
isWorktreeRunProcessKey,
|
|
2222
|
+
resolveWorktreeSetupLog,
|
|
2223
|
+
mergeWorktreeActiveRuns,
|
|
2224
|
+
threadsSharingWorktree,
|
|
2225
|
+
forkMessageSlice,
|
|
2226
|
+
buildForkTranscriptAttachment,
|
|
2227
|
+
createChatTab,
|
|
2228
|
+
forkChatTab,
|
|
2229
|
+
REVIEW_TAB_TITLE,
|
|
2230
|
+
isReviewWriteGatedThread,
|
|
2231
|
+
formatReviewWriteGateDirective,
|
|
2232
|
+
formatReviewWriteGateReminder,
|
|
2233
|
+
requestReview,
|
|
2234
|
+
RUN_PORTS_REL,
|
|
2235
|
+
worktreeRunPortsPath,
|
|
2236
|
+
loadWorktreeRunPorts,
|
|
2237
|
+
saveWorktreeRunPorts,
|
|
2238
|
+
mcpWaitForJobTimeoutMs,
|
|
2239
|
+
listRunningDetachedJobs,
|
|
2240
|
+
isJobContinuePrompt,
|
|
2241
|
+
turnWatchedDetachedJob,
|
|
2242
|
+
planJobContinue,
|
|
2243
|
+
waitForDetachedJob,
|
|
2244
|
+
stopDetachedJob,
|
|
2245
|
+
PORT_RANGE_SIZE,
|
|
2246
|
+
reservePort,
|
|
2247
|
+
allocatePort,
|
|
2248
|
+
tryReservePort,
|
|
2249
|
+
tryReservePorts,
|
|
2250
|
+
reservePortRange,
|
|
2251
|
+
allocatePortRange,
|
|
2252
|
+
readWorktreeInclude,
|
|
2253
|
+
resolveFilesToCopy,
|
|
2254
|
+
copyConfiguredFiles,
|
|
2255
|
+
captureLoginEnv,
|
|
2256
|
+
buildWorkspaceScriptEnv,
|
|
2257
|
+
killableListenerPids,
|
|
2258
|
+
listenerCwd,
|
|
2259
|
+
isOtherWorktreeListenerCwd,
|
|
2260
|
+
isThisWorktreeListenerCwd,
|
|
2261
|
+
killDetachedRunTree,
|
|
2262
|
+
killListenersOnPorts,
|
|
2263
|
+
killStaleWorktreeListeners,
|
|
2264
|
+
runSetupScript,
|
|
2265
|
+
runConventionSetup,
|
|
2266
|
+
runWorkspaceSetup,
|
|
2267
|
+
runArchiveScript,
|
|
2268
|
+
listRunScripts,
|
|
2269
|
+
getDefaultRunScript,
|
|
2270
|
+
getRunScript,
|
|
2271
|
+
getRunMode,
|
|
2272
|
+
startDevServer
|
|
2273
|
+
};
|