@sideboard-ai/core 0.1.9
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/LICENSE +190 -0
- package/dist/agents/cursor-runner.cjs +173 -0
- package/dist/agents/cursor-runner.d.cts +1 -0
- package/dist/agents/cursor-runner.d.ts +1 -0
- package/dist/agents/cursor-runner.js +102 -0
- package/dist/agents-OAX7XPKX.js +41 -0
- package/dist/app-settings-BDMLWCWI.js +61 -0
- package/dist/chunk-2M4OHXYX.js +198 -0
- package/dist/chunk-2R5VV4BA.js +143 -0
- package/dist/chunk-3DKGI32Q.js +92 -0
- package/dist/chunk-3WF3X46L.js +373 -0
- package/dist/chunk-AJ6ROGD7.js +74 -0
- package/dist/chunk-E4PWXO2C.js +4534 -0
- package/dist/chunk-HYRHI3QU.js +154 -0
- package/dist/chunk-ILQK4P5R.js +311 -0
- package/dist/chunk-LL7DTZ5B.js +1282 -0
- package/dist/chunk-M37RITA6.js +304 -0
- package/dist/chunk-TLJH3L2C.js +80 -0
- package/dist/chunk-WMCPLDW3.js +1413 -0
- package/dist/connected-teams-GF52Q7LB.js +22 -0
- package/dist/coordinator-prompt-6R2TX4WQ.js +22 -0
- package/dist/global-workspace-R44HGBU6.js +36 -0
- package/dist/index.cjs +9940 -0
- package/dist/index.d.cts +2284 -0
- package/dist/index.d.ts +2284 -0
- package/dist/index.js +929 -0
- package/dist/mcp/run-stdio.cjs +8654 -0
- package/dist/mcp/run-stdio.d.cts +2 -0
- package/dist/mcp/run-stdio.d.ts +2 -0
- package/dist/mcp/run-stdio.js +21 -0
- package/dist/paths-VPH3ITBK.js +26 -0
- package/dist/run-LF6E5IKL.js +10 -0
- package/dist/thread-store-UNPZNIFW.js +27 -0
- package/dist/title-4A2ATYNY.js +24 -0
- package/dist/workspaces-TCJFYI35.js +20 -0
- package/dist/worktree-NGFDN3J4.js +79 -0
- package/package.json +63 -0
|
@@ -0,0 +1,4534 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addWorkspace,
|
|
3
|
+
ensureWorkspace,
|
|
4
|
+
removeWorkspace,
|
|
5
|
+
syncWorkspacesFromThreads
|
|
6
|
+
} from "./chunk-TLJH3L2C.js";
|
|
7
|
+
import {
|
|
8
|
+
GLOBAL_WORKSPACE_ID,
|
|
9
|
+
createGlobalChat,
|
|
10
|
+
healOrchestrationSoccerTitles,
|
|
11
|
+
isCloudCoordinatorThread,
|
|
12
|
+
isGlobalRepoPath,
|
|
13
|
+
isGlobalThread,
|
|
14
|
+
isOrchestratorThread,
|
|
15
|
+
orchestratorSessionPoisonedByBuiltins
|
|
16
|
+
} from "./chunk-2M4OHXYX.js";
|
|
17
|
+
import {
|
|
18
|
+
coordinatorSystemPrompt,
|
|
19
|
+
coordinatorTurnReminder,
|
|
20
|
+
enrichWorkspacesWithGithub,
|
|
21
|
+
ensureGlobalCoordinatorCwd
|
|
22
|
+
} from "./chunk-2R5VV4BA.js";
|
|
23
|
+
import {
|
|
24
|
+
PLAN_MODE_INSTRUCTION,
|
|
25
|
+
allAdapters,
|
|
26
|
+
getAdapter,
|
|
27
|
+
parseBrightsyCliLine
|
|
28
|
+
} from "./chunk-WMCPLDW3.js";
|
|
29
|
+
import {
|
|
30
|
+
childEnvWithAppSettings,
|
|
31
|
+
getLinearApiKey,
|
|
32
|
+
isLinearConnected,
|
|
33
|
+
loadAppSettings,
|
|
34
|
+
resolveEffectiveIssueSource,
|
|
35
|
+
updateAdvancedSettings
|
|
36
|
+
} from "./chunk-3WF3X46L.js";
|
|
37
|
+
import {
|
|
38
|
+
allocateTeamName,
|
|
39
|
+
allocateTeamSlug,
|
|
40
|
+
commitAll,
|
|
41
|
+
createOrUpdatePr,
|
|
42
|
+
createThreadWorktree,
|
|
43
|
+
fetchPrHead,
|
|
44
|
+
getPr,
|
|
45
|
+
getPrChecks,
|
|
46
|
+
getPrDetails,
|
|
47
|
+
isDirty,
|
|
48
|
+
isPlaceholderBranch,
|
|
49
|
+
listBranches,
|
|
50
|
+
listPrs,
|
|
51
|
+
listWorktrees,
|
|
52
|
+
mergePr,
|
|
53
|
+
normalizeWorktreePath,
|
|
54
|
+
pushBranch,
|
|
55
|
+
removeWorktree,
|
|
56
|
+
resolveDefaultBranch,
|
|
57
|
+
resolveDiffBaseRef,
|
|
58
|
+
resolveGithubRepoSlug,
|
|
59
|
+
resolvePrSelector,
|
|
60
|
+
resolveRepoRoot,
|
|
61
|
+
takenSlugsFromThread,
|
|
62
|
+
threadDisplayLabel,
|
|
63
|
+
worktreeNameFromPath
|
|
64
|
+
} from "./chunk-LL7DTZ5B.js";
|
|
65
|
+
import {
|
|
66
|
+
appendMessage,
|
|
67
|
+
createEmptyThread,
|
|
68
|
+
deleteThreadRecord,
|
|
69
|
+
findThreadByRef,
|
|
70
|
+
listThreads,
|
|
71
|
+
readThread,
|
|
72
|
+
setStatus,
|
|
73
|
+
updateThread,
|
|
74
|
+
withThreadLock,
|
|
75
|
+
writeThread
|
|
76
|
+
} from "./chunk-HYRHI3QU.js";
|
|
77
|
+
import {
|
|
78
|
+
loadRepoSettings,
|
|
79
|
+
loadWorkspaceSettings,
|
|
80
|
+
sideboardReposDir,
|
|
81
|
+
sideboardWorkspacesDir,
|
|
82
|
+
workspaceSettingsSourceLabel,
|
|
83
|
+
worktreesRoot
|
|
84
|
+
} from "./chunk-M37RITA6.js";
|
|
85
|
+
import {
|
|
86
|
+
ensureAgentPath,
|
|
87
|
+
gh,
|
|
88
|
+
git,
|
|
89
|
+
run
|
|
90
|
+
} from "./chunk-AJ6ROGD7.js";
|
|
91
|
+
|
|
92
|
+
// src/integrations/linear.ts
|
|
93
|
+
var LINEAR_GRAPHQL = "https://api.linear.app/graphql";
|
|
94
|
+
var ASSIGNED_ISSUES_QUERY = `
|
|
95
|
+
query SideboardAssignedIssues($first: Int!) {
|
|
96
|
+
viewer {
|
|
97
|
+
assignedIssues(
|
|
98
|
+
first: $first
|
|
99
|
+
orderBy: updatedAt
|
|
100
|
+
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
|
101
|
+
) {
|
|
102
|
+
nodes {
|
|
103
|
+
id
|
|
104
|
+
identifier
|
|
105
|
+
title
|
|
106
|
+
url
|
|
107
|
+
labels { nodes { name } }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
async function listLinearIssuesDirect(opts) {
|
|
114
|
+
const apiKey = (opts?.apiKey ?? getLinearApiKey())?.trim();
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
throw new Error("Linear is not connected \u2014 add an API key in Account settings");
|
|
117
|
+
}
|
|
118
|
+
const first = Math.max(1, Math.min(100, opts?.limit ?? 50));
|
|
119
|
+
const res = await fetch(LINEAR_GRAPHQL, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
Authorization: apiKey
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
query: ASSIGNED_ISSUES_QUERY,
|
|
127
|
+
variables: { first }
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const body = await res.text().catch(() => "");
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Linear API error ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const json = await res.json();
|
|
137
|
+
if (json.errors?.length) {
|
|
138
|
+
throw new Error(json.errors.map((e) => e.message ?? "Linear error").join("; "));
|
|
139
|
+
}
|
|
140
|
+
const nodes = json.data?.viewer?.assignedIssues?.nodes ?? [];
|
|
141
|
+
return nodes.map((node) => ({
|
|
142
|
+
id: String(node.id ?? node.identifier ?? ""),
|
|
143
|
+
identifier: String(node.identifier ?? node.id ?? ""),
|
|
144
|
+
title: String(node.title ?? ""),
|
|
145
|
+
url: String(node.url ?? ""),
|
|
146
|
+
labels: (node.labels?.nodes ?? []).map((l) => l.name).filter((n) => Boolean(n)),
|
|
147
|
+
provider: "linear"
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
async function validateLinearApiKey(apiKey) {
|
|
151
|
+
const key = apiKey.trim();
|
|
152
|
+
if (!key) return false;
|
|
153
|
+
try {
|
|
154
|
+
await listLinearIssuesDirect({ limit: 1, apiKey: key });
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/integrations/issues.ts
|
|
162
|
+
async function listGitHubIssues(repoPath, opts) {
|
|
163
|
+
const limit = Math.max(1, Math.min(100, opts?.limit ?? 50));
|
|
164
|
+
const slug = await resolveGithubRepoSlug(repoPath);
|
|
165
|
+
const args = [
|
|
166
|
+
"issue",
|
|
167
|
+
"list",
|
|
168
|
+
"--json",
|
|
169
|
+
"number,title,url,labels",
|
|
170
|
+
"--limit",
|
|
171
|
+
String(limit),
|
|
172
|
+
"--state",
|
|
173
|
+
"open"
|
|
174
|
+
];
|
|
175
|
+
if (slug) args.push("--repo", slug);
|
|
176
|
+
const { stdout, exitCode } = await gh(args, repoPath, { reject: false });
|
|
177
|
+
if (exitCode !== 0 || !stdout.trim()) return [];
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(stdout);
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(parsed)) return [];
|
|
185
|
+
return parsed.map((raw) => {
|
|
186
|
+
const item = raw;
|
|
187
|
+
const number = Number(item.number);
|
|
188
|
+
const identifier = Number.isFinite(number) ? `#${number}` : String(item.title ?? "");
|
|
189
|
+
const labels = (item.labels ?? []).map(
|
|
190
|
+
(l) => typeof l === "string" ? l : String(l?.name ?? "")
|
|
191
|
+
).filter(Boolean);
|
|
192
|
+
return {
|
|
193
|
+
id: Number.isFinite(number) ? `gh-${number}` : identifier,
|
|
194
|
+
identifier,
|
|
195
|
+
title: String(item.title ?? ""),
|
|
196
|
+
url: String(item.url ?? ""),
|
|
197
|
+
labels,
|
|
198
|
+
provider: "github"
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async function listIssues(repoPath) {
|
|
203
|
+
const settings = loadAppSettings();
|
|
204
|
+
const preferredSource = settings.integrations.issueSource ?? "github";
|
|
205
|
+
const linearConnected = isLinearConnected(settings);
|
|
206
|
+
const source = resolveEffectiveIssueSource(settings);
|
|
207
|
+
if (source === "linear") {
|
|
208
|
+
const issues2 = await listLinearIssuesDirect();
|
|
209
|
+
return { source, preferredSource, linearConnected, issues: issues2 };
|
|
210
|
+
}
|
|
211
|
+
const issues = await listGitHubIssues(repoPath);
|
|
212
|
+
return { source, preferredSource, linearConnected, issues };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/agents/message-parts.ts
|
|
216
|
+
function asRecord(input) {
|
|
217
|
+
if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
218
|
+
return input;
|
|
219
|
+
}
|
|
220
|
+
return void 0;
|
|
221
|
+
}
|
|
222
|
+
function str(v) {
|
|
223
|
+
return typeof v === "string" && v.trim() ? v : void 0;
|
|
224
|
+
}
|
|
225
|
+
function toolDetail(name, input) {
|
|
226
|
+
if (!input) return void 0;
|
|
227
|
+
const command = str(input.command) ?? str(input.cmd);
|
|
228
|
+
if (command) return command;
|
|
229
|
+
const path = str(input.file_path) ?? str(input.path) ?? str(input.filePath) ?? str(input.filename);
|
|
230
|
+
if (path) return path;
|
|
231
|
+
const pattern = str(input.pattern) ?? str(input.glob) ?? str(input.glob_pattern);
|
|
232
|
+
if (pattern) return pattern;
|
|
233
|
+
const query = str(input.query) ?? str(input.prompt);
|
|
234
|
+
if (query) return query.length > 80 ? `${query.slice(0, 77)}\u2026` : query;
|
|
235
|
+
try {
|
|
236
|
+
const raw = JSON.stringify(input);
|
|
237
|
+
return raw.length > 80 ? `${raw.slice(0, 77)}\u2026` : raw;
|
|
238
|
+
} catch {
|
|
239
|
+
return name;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function toolDescription(name, input) {
|
|
243
|
+
const n = name.replace(/^mcp__/, "").replace(/__/g, " \xB7 ");
|
|
244
|
+
if (/^get_record_types$|recordTypes/i.test(name)) return "List record types";
|
|
245
|
+
if (/connectedAgentRequest/i.test(name)) {
|
|
246
|
+
return str(input?.agent_id) ? `Ask connected agent` : "Ask connected agent";
|
|
247
|
+
}
|
|
248
|
+
if (!input) return n;
|
|
249
|
+
if (/bash|shell|terminal/i.test(name) && str(input.command)) {
|
|
250
|
+
const cmd = str(input.command);
|
|
251
|
+
if (/git\s+fetch/i.test(cmd)) return "Fetch latest from origin and check status";
|
|
252
|
+
if (/git\s+status/i.test(cmd)) return "Check git status";
|
|
253
|
+
if (/git\s+log/i.test(cmd)) return "Inspect recent commits";
|
|
254
|
+
if (/git\s+branch/i.test(cmd)) return "List branches";
|
|
255
|
+
if (/grep|rg\b/i.test(cmd)) return "Search repository";
|
|
256
|
+
return "Run shell command";
|
|
257
|
+
}
|
|
258
|
+
if (/edit|write|apply/i.test(name)) {
|
|
259
|
+
const path = str(input.file_path) ?? str(input.path);
|
|
260
|
+
return path ? `Edit ${path.split("/").pop()}` : "Edit file";
|
|
261
|
+
}
|
|
262
|
+
if (/read/i.test(name)) {
|
|
263
|
+
const path = str(input.file_path) ?? str(input.path);
|
|
264
|
+
return path ? `Read ${path.split("/").pop()}` : "Read file";
|
|
265
|
+
}
|
|
266
|
+
if (/grep/i.test(name)) return "Search files";
|
|
267
|
+
if (/glob/i.test(name)) return "Find files";
|
|
268
|
+
return n;
|
|
269
|
+
}
|
|
270
|
+
function toolFilePath(input) {
|
|
271
|
+
if (!input) return void 0;
|
|
272
|
+
return str(input.file_path) ?? str(input.path) ?? str(input.filePath) ?? str(input.filename);
|
|
273
|
+
}
|
|
274
|
+
function countLines(text) {
|
|
275
|
+
if (!text) return 0;
|
|
276
|
+
return text.split("\n").length;
|
|
277
|
+
}
|
|
278
|
+
function diffFromInput(input) {
|
|
279
|
+
if (!input) return {};
|
|
280
|
+
const oldS = str(input.old_string) ?? str(input.oldString);
|
|
281
|
+
const newS = str(input.new_string) ?? str(input.newString) ?? str(input.content);
|
|
282
|
+
if (oldS != null || newS != null) {
|
|
283
|
+
return {
|
|
284
|
+
additions: countLines(newS),
|
|
285
|
+
deletions: countLines(oldS)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
function parseDiffStat(result) {
|
|
291
|
+
if (!result) return {};
|
|
292
|
+
const plus = result.match(/\+(\d+)/);
|
|
293
|
+
const minus = result.match(/-(\d+)/);
|
|
294
|
+
if (plus || minus) {
|
|
295
|
+
return {
|
|
296
|
+
additions: plus ? Number(plus[1]) : void 0,
|
|
297
|
+
deletions: minus ? Number(minus[1]) : void 0
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {};
|
|
301
|
+
}
|
|
302
|
+
function applyAgentEvent(parts, event) {
|
|
303
|
+
if (event.type === "stdout") {
|
|
304
|
+
const data = event.data;
|
|
305
|
+
if (!data) return parts;
|
|
306
|
+
const next = [...parts];
|
|
307
|
+
const last = next[next.length - 1];
|
|
308
|
+
if (last?.type === "text") {
|
|
309
|
+
next[next.length - 1] = { type: "text", text: last.text + data };
|
|
310
|
+
} else {
|
|
311
|
+
next.push({ type: "text", text: data });
|
|
312
|
+
}
|
|
313
|
+
return next;
|
|
314
|
+
}
|
|
315
|
+
if (event.type === "thinking") {
|
|
316
|
+
const data = event.data;
|
|
317
|
+
if (!data) return parts;
|
|
318
|
+
const next = [...parts];
|
|
319
|
+
const last = next[next.length - 1];
|
|
320
|
+
if (last?.type === "thinking") {
|
|
321
|
+
next[next.length - 1] = { type: "thinking", text: last.text + data };
|
|
322
|
+
} else {
|
|
323
|
+
next.push({ type: "thinking", text: data });
|
|
324
|
+
}
|
|
325
|
+
return next;
|
|
326
|
+
}
|
|
327
|
+
if (event.type === "tool_use") {
|
|
328
|
+
const input = asRecord(event.input);
|
|
329
|
+
const diff = diffFromInput(input);
|
|
330
|
+
const existing = parts.findIndex((p) => p.type === "tool" && p.id === event.id);
|
|
331
|
+
if (existing >= 0) {
|
|
332
|
+
const prev = parts[existing];
|
|
333
|
+
const next = [...parts];
|
|
334
|
+
next[existing] = {
|
|
335
|
+
...prev,
|
|
336
|
+
name: event.name || prev.name,
|
|
337
|
+
input: input ?? prev.input,
|
|
338
|
+
description: toolDescription(event.name || prev.name, input ?? prev.input),
|
|
339
|
+
detail: toolDetail(event.name || prev.name, input ?? prev.input) ?? prev.detail,
|
|
340
|
+
filePath: toolFilePath(input) ?? prev.filePath,
|
|
341
|
+
additions: diff.additions ?? prev.additions,
|
|
342
|
+
deletions: diff.deletions ?? prev.deletions
|
|
343
|
+
};
|
|
344
|
+
return next;
|
|
345
|
+
}
|
|
346
|
+
return [
|
|
347
|
+
...parts,
|
|
348
|
+
{
|
|
349
|
+
type: "tool",
|
|
350
|
+
id: event.id,
|
|
351
|
+
name: event.name,
|
|
352
|
+
input,
|
|
353
|
+
description: toolDescription(event.name, input),
|
|
354
|
+
detail: toolDetail(event.name, input),
|
|
355
|
+
status: "running",
|
|
356
|
+
filePath: toolFilePath(input),
|
|
357
|
+
...diff
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
}
|
|
361
|
+
if (event.type === "tool_result") {
|
|
362
|
+
const next = parts.map((p) => {
|
|
363
|
+
if (p.type !== "tool" || p.id !== event.id) return p;
|
|
364
|
+
const fromResult = parseDiffStat(event.content);
|
|
365
|
+
return {
|
|
366
|
+
...p,
|
|
367
|
+
status: event.isError ? "error" : "done",
|
|
368
|
+
result: event.content,
|
|
369
|
+
additions: fromResult.additions ?? p.additions,
|
|
370
|
+
deletions: fromResult.deletions ?? p.deletions
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
return next;
|
|
374
|
+
}
|
|
375
|
+
return parts;
|
|
376
|
+
}
|
|
377
|
+
function partsToAssistantText(parts) {
|
|
378
|
+
return parts.filter((p) => p.type === "text").map((p) => p.text).join("").trim();
|
|
379
|
+
}
|
|
380
|
+
function stripBrightsyNdjsonNoise(text) {
|
|
381
|
+
if (!text || !text.includes('"type"')) return text;
|
|
382
|
+
let out = text;
|
|
383
|
+
out = out.replace(
|
|
384
|
+
/\{"type":"(?:tool_use|tool_result|tool|thinking|usage|done|error)"[\s\S]*?\}\s*(?=\{"type":"|$|(?=[A-Za-z*#]))/g,
|
|
385
|
+
""
|
|
386
|
+
);
|
|
387
|
+
out = out.replace(/\{"type":"(?:tool_use|tool_result|tool)"[\s\S]*$/g, "");
|
|
388
|
+
return out.trim();
|
|
389
|
+
}
|
|
390
|
+
function isBrightsyNdjsonLine(line) {
|
|
391
|
+
const t = line.trim();
|
|
392
|
+
if (!t.startsWith("{")) return false;
|
|
393
|
+
return /"type"\s*:\s*"(tool_use|tool_result|tool|thinking|usage|done|error|text)"/.test(t);
|
|
394
|
+
}
|
|
395
|
+
function finalizeParts(parts) {
|
|
396
|
+
return parts.map(
|
|
397
|
+
(p) => p.type === "tool" && p.status === "running" ? { ...p, status: "done" } : p
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
function normalizeParseResult(parsed) {
|
|
401
|
+
if (!parsed) return [];
|
|
402
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/agents/usage.ts
|
|
406
|
+
function sumOptional(a, b) {
|
|
407
|
+
if (a == null && b == null) return void 0;
|
|
408
|
+
return (a ?? 0) + (b ?? 0);
|
|
409
|
+
}
|
|
410
|
+
function mergeUsage(a, b) {
|
|
411
|
+
return {
|
|
412
|
+
inputTokens: (a?.inputTokens ?? 0) + b.inputTokens,
|
|
413
|
+
outputTokens: (a?.outputTokens ?? 0) + b.outputTokens,
|
|
414
|
+
cacheReadTokens: sumOptional(a?.cacheReadTokens, b.cacheReadTokens),
|
|
415
|
+
cacheWriteTokens: sumOptional(a?.cacheWriteTokens, b.cacheWriteTokens)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function totalTokens(u) {
|
|
419
|
+
return u.inputTokens + u.outputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/agents/spawn.ts
|
|
423
|
+
import { createInterface } from "readline";
|
|
424
|
+
import { execa } from "execa";
|
|
425
|
+
async function spawnAgentTurn(thread, input, onEvent) {
|
|
426
|
+
ensureAgentPath();
|
|
427
|
+
if (!thread.worktreePath?.trim()) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Cannot spawn ${thread.agent}: thread ${thread.id} has no worktreePath`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-R44HGBU6.js");
|
|
433
|
+
if (isGlobalThread2(thread)) {
|
|
434
|
+
const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-6R2TX4WQ.js");
|
|
435
|
+
ensureGlobalCoordinatorCwd2();
|
|
436
|
+
}
|
|
437
|
+
const adapter = getAdapter(thread.agent);
|
|
438
|
+
const cmd = await adapter.buildTurn(thread, input);
|
|
439
|
+
if (cmd.cwd !== thread.worktreePath) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Agent cwd must be the thread worktree (got ${cmd.cwd}, expected ${thread.worktreePath})`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const child = execa(cmd.file, cmd.args, {
|
|
445
|
+
cwd: cmd.cwd,
|
|
446
|
+
env: childEnvWithAppSettings(cmd.env),
|
|
447
|
+
reject: false,
|
|
448
|
+
stdout: "pipe",
|
|
449
|
+
stderr: "pipe",
|
|
450
|
+
// Claude stream-json turns write a user message to stdin; otherwise ignore
|
|
451
|
+
// so Claude doesn't wait ~3s for an empty pipe.
|
|
452
|
+
stdin: cmd.stdin != null ? "pipe" : "ignore"
|
|
453
|
+
});
|
|
454
|
+
if (cmd.stdin != null && child.stdin) {
|
|
455
|
+
child.stdin.write(cmd.stdin);
|
|
456
|
+
child.stdin.end();
|
|
457
|
+
}
|
|
458
|
+
let sessionId = thread.sessionId;
|
|
459
|
+
let assistantText = "";
|
|
460
|
+
let parts = [];
|
|
461
|
+
let usage = null;
|
|
462
|
+
const consume = (stream, kind) => {
|
|
463
|
+
if (!stream) return;
|
|
464
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
465
|
+
rl.on("line", (line) => {
|
|
466
|
+
if (kind === "stderr") {
|
|
467
|
+
onEvent({ type: "stderr", data: line });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
let events = normalizeParseResult(adapter.parseEvent(line));
|
|
471
|
+
if (thread.agent === "brightsy" && kind === "stdout" && isBrightsyNdjsonLine(line)) {
|
|
472
|
+
const dumped = events.some(
|
|
473
|
+
(e) => e.type === "stdout" && isBrightsyNdjsonLine(e.data)
|
|
474
|
+
);
|
|
475
|
+
if (events.length === 0 || dumped) {
|
|
476
|
+
events = normalizeParseResult(parseBrightsyCliLine(line));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const parsed of events) {
|
|
480
|
+
if (parsed.type === "session_id") {
|
|
481
|
+
sessionId = parsed.data;
|
|
482
|
+
onEvent(parsed);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (parsed.type === "usage") {
|
|
486
|
+
usage = mergeUsage(usage, parsed.data);
|
|
487
|
+
onEvent(parsed);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (parsed.type === "stdout") {
|
|
491
|
+
if (thread.agent === "brightsy" && isBrightsyNdjsonLine(parsed.data)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (assistantText && (parsed.data === assistantText || assistantText.endsWith(parsed.data))) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
assistantText += parsed.data;
|
|
498
|
+
}
|
|
499
|
+
parts = applyAgentEvent(parts, parsed);
|
|
500
|
+
onEvent(parsed);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
consume(child.stdout, "stdout");
|
|
505
|
+
consume(child.stderr, "stderr");
|
|
506
|
+
const done = child.then((result) => {
|
|
507
|
+
const exitCode = result.exitCode ?? null;
|
|
508
|
+
onEvent({ type: "exit", data: exitCode });
|
|
509
|
+
const finalized = finalizeParts(parts);
|
|
510
|
+
const rawText = assistantText.trim() || partsToAssistantText(finalized);
|
|
511
|
+
const text = thread.agent === "brightsy" ? stripBrightsyNdjsonNoise(rawText) : rawText;
|
|
512
|
+
return { exitCode, sessionId, assistantText: text, parts: finalized, usage };
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
pid: child.pid,
|
|
516
|
+
kill: () => {
|
|
517
|
+
try {
|
|
518
|
+
child.kill("SIGTERM");
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
done
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/agents/instructions.ts
|
|
527
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
528
|
+
import { join } from "path";
|
|
529
|
+
function normPath(p) {
|
|
530
|
+
return p.replace(/\/+$/, "");
|
|
531
|
+
}
|
|
532
|
+
function formatRenameBranchDirective(thread, opts) {
|
|
533
|
+
if (!isPlaceholderBranch(thread.branchName, thread.worktreePath)) return null;
|
|
534
|
+
const dir = worktreeNameFromPath(thread.worktreePath);
|
|
535
|
+
const lines = [
|
|
536
|
+
"Branch naming (do this early in the turn):",
|
|
537
|
+
`- Current branch \`${thread.branchName}\` is a temporary placeholder. The worktree folder \`${dir}\` is a stable soccer-team nickname \u2014 do not rename or leave that directory.`,
|
|
538
|
+
"- Rename the git branch to a short kebab-case name that describes this task (what you are changing), e.g. `fix/panel-width` or `feat/dark-mode`:",
|
|
539
|
+
" `git branch -m <new-name>`",
|
|
540
|
+
"- Prefer Conventional Commits style prefixes when they fit (`fix/`, `feat/`, `chore/`, `docs/`).",
|
|
541
|
+
"- Never push or merge to main/master from here."
|
|
542
|
+
];
|
|
543
|
+
const custom = opts?.customPrompt?.trim();
|
|
544
|
+
if (custom) {
|
|
545
|
+
lines.push(`- Repository naming preference: ${custom}`);
|
|
546
|
+
}
|
|
547
|
+
return lines.join("\n");
|
|
548
|
+
}
|
|
549
|
+
function formatWorktreeDirective(thread) {
|
|
550
|
+
const worktree = normPath(thread.worktreePath);
|
|
551
|
+
const repo = normPath(thread.repoPath);
|
|
552
|
+
const dir = worktreeNameFromPath(thread.worktreePath);
|
|
553
|
+
const lines = [
|
|
554
|
+
"Sideboard workspace (mandatory):",
|
|
555
|
+
`- Worktree (your only working directory): ${worktree}`,
|
|
556
|
+
`- Branch: ${thread.branchName}`,
|
|
557
|
+
`- Worktree folder nickname (stable, do not rename): ${dir}`
|
|
558
|
+
];
|
|
559
|
+
if (worktree && repo && worktree !== repo) {
|
|
560
|
+
lines.push(`- Main repo checkout (do NOT edit application code here): ${repo}`);
|
|
561
|
+
lines.push(
|
|
562
|
+
"Stay inside the worktree for all Reads, Edits, Writes, installs, tests, and git commands. The main repo is a separate checkout used as the git base/remote \u2014 changing files there breaks isolation for this thread and other worktrees."
|
|
563
|
+
);
|
|
564
|
+
} else {
|
|
565
|
+
lines.push(
|
|
566
|
+
"All file operations and shell commands must use this worktree as cwd. Do not switch to another checkout."
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
lines.push("");
|
|
570
|
+
lines.push("Pull requests (when the work is ready to share):");
|
|
571
|
+
lines.push(
|
|
572
|
+
"- Derive the PR title and body from what the changes actually do and why \u2014 inspect the diff/commits and the user request. Do not use the soccer-team worktree nickname or placeholder branch as the PR title."
|
|
573
|
+
);
|
|
574
|
+
lines.push(
|
|
575
|
+
"- Prefer a concise imperative title (Conventional Commits style when it fits: feat:/fix:/chore:/docs:). Body should summarize intent, key changes, and test notes."
|
|
576
|
+
);
|
|
577
|
+
lines.push(
|
|
578
|
+
"- Commit with messages that state the purpose of the change (same standard as the PR). Stay on this thread branch; never push or merge to main/master from here."
|
|
579
|
+
);
|
|
580
|
+
if (thread.prUrl) {
|
|
581
|
+
lines.push(
|
|
582
|
+
`- A PR already exists (${thread.prUrl}). Update it (push + edit title/body if the purpose drifted) instead of opening a duplicate.`
|
|
583
|
+
);
|
|
584
|
+
} else {
|
|
585
|
+
lines.push(
|
|
586
|
+
"- Prefer a draft PR first: `gh pr create --draft` (or update via `gh pr edit`) once the change set is coherent. Mark ready for review only when asked. Title/body must reflect the change purpose, not the worktree name."
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
return lines.join("\n");
|
|
590
|
+
}
|
|
591
|
+
var FILES_BY_AGENT = {
|
|
592
|
+
claude: [
|
|
593
|
+
"CLAUDE.md",
|
|
594
|
+
".claude/CLAUDE.md",
|
|
595
|
+
"CLAUDE.local.md",
|
|
596
|
+
"AGENTS.md",
|
|
597
|
+
"AGENT.md"
|
|
598
|
+
],
|
|
599
|
+
codex: ["AGENTS.md", "AGENT.md", ".codex/AGENTS.md", "CLAUDE.md"],
|
|
600
|
+
opencode: ["AGENTS.md", "AGENT.md", "OPENCODE.md", "CLAUDE.md"],
|
|
601
|
+
brightsy: ["AGENTS.md", "AGENT.md", "CLAUDE.md"],
|
|
602
|
+
cursor: ["AGENTS.md", "AGENT.md", ".cursor/rules", "CLAUDE.md"]
|
|
603
|
+
};
|
|
604
|
+
var MAX_CHARS_PER_FILE = 48e3;
|
|
605
|
+
function loadAgentInstructions(worktreePath, agent) {
|
|
606
|
+
const candidates = FILES_BY_AGENT[agent] ?? FILES_BY_AGENT.claude;
|
|
607
|
+
const seen = /* @__PURE__ */ new Set();
|
|
608
|
+
const out = [];
|
|
609
|
+
for (const rel of candidates) {
|
|
610
|
+
if (seen.has(rel)) continue;
|
|
611
|
+
const abs = join(worktreePath, rel);
|
|
612
|
+
if (!existsSync(abs)) continue;
|
|
613
|
+
try {
|
|
614
|
+
if (!statSync(abs).isFile()) continue;
|
|
615
|
+
let content = readFileSync(abs, "utf8");
|
|
616
|
+
if (!content.trim()) continue;
|
|
617
|
+
if (content.length > MAX_CHARS_PER_FILE) {
|
|
618
|
+
content = `${content.slice(0, MAX_CHARS_PER_FILE)}
|
|
619
|
+
|
|
620
|
+
\u2026(truncated)`;
|
|
621
|
+
}
|
|
622
|
+
seen.add(rel);
|
|
623
|
+
out.push({ relativePath: rel, content });
|
|
624
|
+
} catch {
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
function formatAgentInstructions(files) {
|
|
630
|
+
if (files.length === 0) return null;
|
|
631
|
+
const parts = [
|
|
632
|
+
"Project agent instructions (from the worktree \u2014 follow these):",
|
|
633
|
+
""
|
|
634
|
+
];
|
|
635
|
+
for (const f of files) {
|
|
636
|
+
parts.push(`## ${f.relativePath}`);
|
|
637
|
+
parts.push("");
|
|
638
|
+
parts.push(f.content.trim());
|
|
639
|
+
parts.push("");
|
|
640
|
+
parts.push("---");
|
|
641
|
+
parts.push("");
|
|
642
|
+
}
|
|
643
|
+
return parts.join("\n").trimEnd();
|
|
644
|
+
}
|
|
645
|
+
function withAgentInstructions(prompt, files) {
|
|
646
|
+
const prefix = formatAgentInstructions(files);
|
|
647
|
+
if (!prefix) return prompt;
|
|
648
|
+
return `${prefix}
|
|
649
|
+
|
|
650
|
+
${prompt}`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/detect/detect.ts
|
|
654
|
+
async function detectAgents() {
|
|
655
|
+
ensureAgentPath();
|
|
656
|
+
return Promise.all(allAdapters().map((a) => a.detect()));
|
|
657
|
+
}
|
|
658
|
+
async function requireAgent(agent, opts) {
|
|
659
|
+
const status = (await detectAgents()).find((s) => s.agent === agent);
|
|
660
|
+
if (!status) throw new Error(`Unknown agent: ${agent}`);
|
|
661
|
+
if (!status.installed || !status.authenticated) {
|
|
662
|
+
throw new Error(status.reason ?? `${agent} is not available`);
|
|
663
|
+
}
|
|
664
|
+
if (opts?.requireLinear && !status.linearMcp) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`${agent} has no Linear MCP server configured \u2014 ticket sources require it`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return status;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/hook/conductor.ts
|
|
673
|
+
import {
|
|
674
|
+
copyFileSync,
|
|
675
|
+
existsSync as existsSync2,
|
|
676
|
+
mkdirSync,
|
|
677
|
+
readdirSync,
|
|
678
|
+
readFileSync as readFileSync2
|
|
679
|
+
} from "fs";
|
|
680
|
+
import { createServer } from "net";
|
|
681
|
+
import { basename, dirname, join as join2 } from "path";
|
|
682
|
+
import { execa as execa2 } from "execa";
|
|
683
|
+
import { createInterface as createInterface2 } from "readline";
|
|
684
|
+
var PORT_RANGE_SIZE = 10;
|
|
685
|
+
function matchSimpleGlob(pattern, name) {
|
|
686
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
687
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
688
|
+
}
|
|
689
|
+
function readWorktreeInclude(repoPath) {
|
|
690
|
+
const path = join2(repoPath, ".worktreeinclude");
|
|
691
|
+
if (!existsSync2(path)) return [];
|
|
692
|
+
return readFileSync2(path, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
693
|
+
}
|
|
694
|
+
function resolveFilesToCopy(repoPath) {
|
|
695
|
+
const fromInclude = readWorktreeInclude(repoPath);
|
|
696
|
+
if (fromInclude.length) return fromInclude;
|
|
697
|
+
const settings = loadRepoSettings(repoPath);
|
|
698
|
+
if (settings?.filesToCopy?.length) return settings.filesToCopy;
|
|
699
|
+
if (settings?.fileIncludeGlobs?.length) {
|
|
700
|
+
const matched = [];
|
|
701
|
+
try {
|
|
702
|
+
for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
|
|
703
|
+
if (!entry.isFile()) continue;
|
|
704
|
+
for (const glob of settings.fileIncludeGlobs) {
|
|
705
|
+
if (matchSimpleGlob(glob, entry.name) || matchSimpleGlob(basename(glob), entry.name)) {
|
|
706
|
+
matched.push(entry.name);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
if (matched.length) return [...new Set(matched)];
|
|
714
|
+
}
|
|
715
|
+
const defaults = [];
|
|
716
|
+
try {
|
|
717
|
+
for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
|
|
718
|
+
if (entry.isFile() && entry.name.startsWith(".env")) {
|
|
719
|
+
defaults.push(entry.name);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} catch {
|
|
723
|
+
}
|
|
724
|
+
if (defaults.length) return defaults;
|
|
725
|
+
return [".env.local", ".env"];
|
|
726
|
+
}
|
|
727
|
+
function copyConfiguredFiles(repoPath, worktreePath) {
|
|
728
|
+
const patterns = resolveFilesToCopy(repoPath);
|
|
729
|
+
const copied = [];
|
|
730
|
+
for (const rel of patterns) {
|
|
731
|
+
const src = join2(repoPath, rel);
|
|
732
|
+
if (!existsSync2(src)) continue;
|
|
733
|
+
const dest = join2(worktreePath, rel);
|
|
734
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
735
|
+
copyFileSync(src, dest);
|
|
736
|
+
copied.push(rel);
|
|
737
|
+
}
|
|
738
|
+
return copied;
|
|
739
|
+
}
|
|
740
|
+
var cachedLoginEnv = null;
|
|
741
|
+
async function captureLoginEnv() {
|
|
742
|
+
if (cachedLoginEnv) return { ...cachedLoginEnv };
|
|
743
|
+
const shell = process.env.SHELL?.trim() || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
|
|
744
|
+
try {
|
|
745
|
+
const result = await execa2(shell, ["-l", "-c", "env -0"], {
|
|
746
|
+
reject: false,
|
|
747
|
+
timeout: 5e3
|
|
748
|
+
});
|
|
749
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
750
|
+
const env = { ...process.env };
|
|
751
|
+
for (const entry of result.stdout.split("\0")) {
|
|
752
|
+
if (!entry) continue;
|
|
753
|
+
const eq = entry.indexOf("=");
|
|
754
|
+
if (eq <= 0) continue;
|
|
755
|
+
const key = entry.slice(0, eq);
|
|
756
|
+
const value = entry.slice(eq + 1);
|
|
757
|
+
if (key) env[key] = value;
|
|
758
|
+
}
|
|
759
|
+
cachedLoginEnv = env;
|
|
760
|
+
return { ...env };
|
|
761
|
+
}
|
|
762
|
+
} catch {
|
|
763
|
+
}
|
|
764
|
+
cachedLoginEnv = { ...process.env };
|
|
765
|
+
return { ...cachedLoginEnv };
|
|
766
|
+
}
|
|
767
|
+
function buildWorkspaceScriptEnv(opts, baseEnv) {
|
|
768
|
+
const env = { ...baseEnv ?? process.env };
|
|
769
|
+
const name = opts.workspaceName ?? basename(opts.worktreePath);
|
|
770
|
+
const ports = opts.ports ?? [];
|
|
771
|
+
const primary = ports[0];
|
|
772
|
+
env.SIDEBOARD_WORKSPACE_NAME = name;
|
|
773
|
+
env.SIDEBOARD_WORKSPACE_PATH = opts.worktreePath;
|
|
774
|
+
env.SIDEBOARD_ROOT_PATH = opts.repoPath;
|
|
775
|
+
env.SIDEBOARD_IS_LOCAL = "1";
|
|
776
|
+
if (opts.defaultBranch) env.SIDEBOARD_DEFAULT_BRANCH = opts.defaultBranch;
|
|
777
|
+
env.CONDUCTOR_WORKSPACE_NAME = name;
|
|
778
|
+
env.CONDUCTOR_WORKSPACE_PATH = opts.worktreePath;
|
|
779
|
+
env.CONDUCTOR_ROOT_PATH = opts.repoPath;
|
|
780
|
+
env.CONDUCTOR_IS_LOCAL = "1";
|
|
781
|
+
if (opts.defaultBranch) env.CONDUCTOR_DEFAULT_BRANCH = opts.defaultBranch;
|
|
782
|
+
if (primary != null) {
|
|
783
|
+
env.SIDEBOARD_PORT = String(primary);
|
|
784
|
+
env.CONDUCTOR_PORT = String(primary);
|
|
785
|
+
env.PORT = String(primary);
|
|
786
|
+
for (let i = 1; i < ports.length; i++) {
|
|
787
|
+
const p = ports[i];
|
|
788
|
+
env[`SIDEBOARD_PORT_${i}`] = String(p);
|
|
789
|
+
env[`CONDUCTOR_PORT_${i}`] = String(p);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return env;
|
|
793
|
+
}
|
|
794
|
+
function pipeLines(stream, onLine) {
|
|
795
|
+
if (!stream || !onLine) return;
|
|
796
|
+
const rl = createInterface2({ input: stream });
|
|
797
|
+
rl.on("line", onLine);
|
|
798
|
+
}
|
|
799
|
+
async function spawnWorkspaceScript(command, opts) {
|
|
800
|
+
const loginEnv = await captureLoginEnv();
|
|
801
|
+
const env = buildWorkspaceScriptEnv(
|
|
802
|
+
{
|
|
803
|
+
worktreePath: opts.worktreePath,
|
|
804
|
+
repoPath: opts.repoPath,
|
|
805
|
+
workspaceName: opts.workspaceName,
|
|
806
|
+
defaultBranch: opts.defaultBranch,
|
|
807
|
+
ports: opts.ports
|
|
808
|
+
},
|
|
809
|
+
loginEnv
|
|
810
|
+
);
|
|
811
|
+
const shell = process.platform === "darwin" ? "zsh" : "bash";
|
|
812
|
+
const child = execa2(shell, ["-lc", command], {
|
|
813
|
+
cwd: opts.worktreePath,
|
|
814
|
+
reject: false,
|
|
815
|
+
env
|
|
816
|
+
});
|
|
817
|
+
pipeLines(child.stdout, opts.onLine);
|
|
818
|
+
pipeLines(child.stderr, opts.onLine);
|
|
819
|
+
return {
|
|
820
|
+
pid: child.pid,
|
|
821
|
+
kill: () => {
|
|
822
|
+
try {
|
|
823
|
+
child.kill("SIGTERM");
|
|
824
|
+
} catch {
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
done: child.then((r) => r.exitCode ?? null),
|
|
828
|
+
child
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async function runSetupScript(repoPath, worktreePath, onLine, opts) {
|
|
832
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
833
|
+
if (!settings?.setup) return { ran: false, exitCode: null, source: null };
|
|
834
|
+
const handle = await spawnWorkspaceScript(settings.setup, {
|
|
835
|
+
worktreePath,
|
|
836
|
+
repoPath,
|
|
837
|
+
defaultBranch: opts?.defaultBranch,
|
|
838
|
+
onLine
|
|
839
|
+
});
|
|
840
|
+
if (opts?.signal) {
|
|
841
|
+
const onAbort = () => handle.kill();
|
|
842
|
+
if (opts.signal.aborted) onAbort();
|
|
843
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
844
|
+
}
|
|
845
|
+
const exitCode = await handle.done;
|
|
846
|
+
return {
|
|
847
|
+
ran: true,
|
|
848
|
+
exitCode,
|
|
849
|
+
source: workspaceSettingsSourceLabel(worktreePath, repoPath),
|
|
850
|
+
kill: handle.kill
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
async function runArchiveScript(repoPath, worktreePath, onLine) {
|
|
854
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
855
|
+
if (!settings?.archive) return { ran: false, exitCode: null };
|
|
856
|
+
const handle = await spawnWorkspaceScript(settings.archive, {
|
|
857
|
+
worktreePath,
|
|
858
|
+
repoPath,
|
|
859
|
+
onLine
|
|
860
|
+
});
|
|
861
|
+
const exitCode = await handle.done;
|
|
862
|
+
return { ran: true, exitCode };
|
|
863
|
+
}
|
|
864
|
+
function listRunScripts(worktreePath, repoPath) {
|
|
865
|
+
const settings = loadWorkspaceSettings(worktreePath, repoPath);
|
|
866
|
+
if (!settings?.runScripts.length) return [];
|
|
867
|
+
return settings.runScripts.filter((s) => {
|
|
868
|
+
if (!s.availableIn?.length) return true;
|
|
869
|
+
return s.availableIn.includes("local");
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
function getDefaultRunScript(worktreePath, repoPath) {
|
|
873
|
+
const scripts = listRunScripts(worktreePath, repoPath);
|
|
874
|
+
if (!scripts.length) return null;
|
|
875
|
+
return scripts.find((s) => s.default === true) ?? scripts.find((s) => s.name === "dev") ?? scripts.find((s) => s.name !== "all") ?? scripts[0] ?? null;
|
|
876
|
+
}
|
|
877
|
+
function getRunScript(worktreePath, repoPath, name) {
|
|
878
|
+
const scripts = listRunScripts(worktreePath, repoPath);
|
|
879
|
+
if (!scripts.length) return null;
|
|
880
|
+
if (name) {
|
|
881
|
+
return scripts.find((s) => s.name === name) ?? null;
|
|
882
|
+
}
|
|
883
|
+
return getDefaultRunScript(worktreePath, repoPath);
|
|
884
|
+
}
|
|
885
|
+
function getRunMode(worktreePath, repoPath) {
|
|
886
|
+
return loadWorkspaceSettings(worktreePath, repoPath)?.runMode ?? "concurrent";
|
|
887
|
+
}
|
|
888
|
+
async function allocatePort() {
|
|
889
|
+
return new Promise((resolve, reject) => {
|
|
890
|
+
const server = createServer();
|
|
891
|
+
server.listen(0, "127.0.0.1", () => {
|
|
892
|
+
const addr = server.address();
|
|
893
|
+
if (!addr || typeof addr === "string") {
|
|
894
|
+
server.close();
|
|
895
|
+
reject(new Error("Failed to allocate port"));
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const port = addr.port;
|
|
899
|
+
server.close((err) => err ? reject(err) : resolve(port));
|
|
900
|
+
});
|
|
901
|
+
server.on("error", reject);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async function allocatePortRange(size = PORT_RANGE_SIZE) {
|
|
905
|
+
const base = await allocatePort();
|
|
906
|
+
const ports = [base];
|
|
907
|
+
for (let i = 1; i < size; i++) {
|
|
908
|
+
const candidate = base + i;
|
|
909
|
+
const free = await new Promise((resolve) => {
|
|
910
|
+
const server = createServer();
|
|
911
|
+
server.once("error", () => resolve(false));
|
|
912
|
+
server.listen(candidate, "127.0.0.1", () => {
|
|
913
|
+
server.close(() => resolve(true));
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
if (free) {
|
|
917
|
+
ports.push(candidate);
|
|
918
|
+
} else {
|
|
919
|
+
ports.push(await allocatePort());
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return ports;
|
|
923
|
+
}
|
|
924
|
+
async function startDevServer(repoPath, worktreePath, onLine, opts) {
|
|
925
|
+
const script = getRunScript(worktreePath, repoPath, opts?.scriptName);
|
|
926
|
+
if (!script) return null;
|
|
927
|
+
const ports = await allocatePortRange(PORT_RANGE_SIZE);
|
|
928
|
+
const handle = await spawnWorkspaceScript(script.command, {
|
|
929
|
+
worktreePath,
|
|
930
|
+
repoPath,
|
|
931
|
+
ports,
|
|
932
|
+
defaultBranch: opts?.defaultBranch,
|
|
933
|
+
onLine
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
pid: handle.pid,
|
|
937
|
+
port: ports[0],
|
|
938
|
+
ports,
|
|
939
|
+
scriptName: script.name,
|
|
940
|
+
kill: handle.kill,
|
|
941
|
+
done: handle.done
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// src/hook/cursor-worktrees.ts
|
|
946
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
947
|
+
import { join as join3 } from "path";
|
|
948
|
+
import { execa as execa3 } from "execa";
|
|
949
|
+
import { createInterface as createInterface3 } from "readline";
|
|
950
|
+
function loadCursorWorktreesJson(rootPath) {
|
|
951
|
+
const path = join3(rootPath, ".cursor", "worktrees.json");
|
|
952
|
+
if (!existsSync3(path)) return null;
|
|
953
|
+
try {
|
|
954
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
955
|
+
} catch {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function resolveSetupSpec(config) {
|
|
960
|
+
if (process.platform === "win32") {
|
|
961
|
+
return config["setup-worktree-windows"] ?? config["setup-worktree"] ?? null;
|
|
962
|
+
}
|
|
963
|
+
return config["setup-worktree-unix"] ?? config["setup-worktree"] ?? null;
|
|
964
|
+
}
|
|
965
|
+
async function runCursorWorktreeSetup(repoPath, worktreePath, onLine) {
|
|
966
|
+
const fromWorktree = loadCursorWorktreesJson(worktreePath);
|
|
967
|
+
const fromRepo = worktreePath !== repoPath ? loadCursorWorktreesJson(repoPath) : null;
|
|
968
|
+
const config = fromWorktree ?? fromRepo;
|
|
969
|
+
if (!config) return { ran: false, exitCode: null, source: null };
|
|
970
|
+
const spec = resolveSetupSpec(config);
|
|
971
|
+
if (!spec) return { ran: false, exitCode: null, source: null };
|
|
972
|
+
const sourceLabel = fromWorktree ? ".cursor/worktrees.json (worktree)" : ".cursor/worktrees.json (main repo)";
|
|
973
|
+
const env = {
|
|
974
|
+
...process.env,
|
|
975
|
+
ROOT_WORKTREE_PATH: repoPath
|
|
976
|
+
};
|
|
977
|
+
if (process.platform === "win32") {
|
|
978
|
+
env.ROOT_WORKTREE_PATH = repoPath;
|
|
979
|
+
}
|
|
980
|
+
const commands = Array.isArray(spec) ? spec : [spec.endsWith(".sh") || spec.endsWith(".ps1") ? join3(
|
|
981
|
+
fromWorktree ? worktreePath : repoPath,
|
|
982
|
+
".cursor",
|
|
983
|
+
spec
|
|
984
|
+
) : spec];
|
|
985
|
+
let lastExit = 0;
|
|
986
|
+
for (const command of commands) {
|
|
987
|
+
const isScriptPath = typeof spec === "string" && (spec.endsWith(".sh") || spec.endsWith(".ps1"));
|
|
988
|
+
const shell = process.platform === "win32" ? "powershell" : "bash";
|
|
989
|
+
const args = process.platform === "win32" ? isScriptPath ? ["-File", command] : ["-Command", command] : isScriptPath ? [command] : ["-lc", command];
|
|
990
|
+
onLine?.(`[cursor setup] ${command}`);
|
|
991
|
+
const child = execa3(shell, args, {
|
|
992
|
+
cwd: worktreePath,
|
|
993
|
+
reject: false,
|
|
994
|
+
env
|
|
995
|
+
});
|
|
996
|
+
if (child.stdout && onLine) {
|
|
997
|
+
const rl = createInterface3({ input: child.stdout });
|
|
998
|
+
rl.on("line", onLine);
|
|
999
|
+
}
|
|
1000
|
+
if (child.stderr && onLine) {
|
|
1001
|
+
const rl = createInterface3({ input: child.stderr });
|
|
1002
|
+
rl.on("line", onLine);
|
|
1003
|
+
}
|
|
1004
|
+
const result = await child;
|
|
1005
|
+
lastExit = result.exitCode ?? null;
|
|
1006
|
+
if (lastExit !== 0 && lastExit !== null) break;
|
|
1007
|
+
}
|
|
1008
|
+
return { ran: true, exitCode: lastExit, source: sourceLabel };
|
|
1009
|
+
}
|
|
1010
|
+
function hasCursorWorktreeSetup(worktreePath, repoPath) {
|
|
1011
|
+
if (loadCursorWorktreesJson(worktreePath)) return true;
|
|
1012
|
+
if (repoPath && repoPath !== worktreePath && loadCursorWorktreesJson(repoPath)) {
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/diff/diff.ts
|
|
1019
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync as statSync2, writeFileSync } from "fs";
|
|
1020
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
1021
|
+
async function inspectGitWorktree(worktreePath) {
|
|
1022
|
+
if (!worktreePath || !existsSync4(worktreePath)) return "missing_worktree";
|
|
1023
|
+
const check = await git(["rev-parse", "--is-inside-work-tree"], worktreePath, {
|
|
1024
|
+
reject: false
|
|
1025
|
+
});
|
|
1026
|
+
if (check.exitCode !== 0 || check.stdout.trim() !== "true") return "not_git";
|
|
1027
|
+
return "ok";
|
|
1028
|
+
}
|
|
1029
|
+
async function initializeGitRepository(worktreePath) {
|
|
1030
|
+
if (!worktreePath || !existsSync4(worktreePath)) {
|
|
1031
|
+
throw new Error("Worktree not found");
|
|
1032
|
+
}
|
|
1033
|
+
const status = await inspectGitWorktree(worktreePath);
|
|
1034
|
+
if (status === "ok") return;
|
|
1035
|
+
const result = await git(["init"], worktreePath, { reject: false });
|
|
1036
|
+
if (result.exitCode !== 0) {
|
|
1037
|
+
throw new Error(result.stderr.trim() || "Failed to initialize Git repository");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
async function captureTurnBaseline(worktreePath) {
|
|
1041
|
+
const status = await inspectGitWorktree(worktreePath);
|
|
1042
|
+
if (status !== "ok") return null;
|
|
1043
|
+
const stash = await git(["stash", "create"], worktreePath, { reject: false });
|
|
1044
|
+
const stashSha = stash.stdout.trim();
|
|
1045
|
+
if (stashSha) return stashSha;
|
|
1046
|
+
const head = await git(["rev-parse", "--verify", "HEAD"], worktreePath, {
|
|
1047
|
+
reject: false
|
|
1048
|
+
});
|
|
1049
|
+
return head.exitCode === 0 ? head.stdout.trim() : null;
|
|
1050
|
+
}
|
|
1051
|
+
function parseNameStatus(stdout) {
|
|
1052
|
+
const out = [];
|
|
1053
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
1054
|
+
const [status, ...pathParts] = line.split(" ");
|
|
1055
|
+
const path = pathParts[pathParts.length - 1];
|
|
1056
|
+
if (!status || !path) continue;
|
|
1057
|
+
out.push({ status: status[0] ?? status, path });
|
|
1058
|
+
}
|
|
1059
|
+
return out;
|
|
1060
|
+
}
|
|
1061
|
+
function parseNumstat(stdout) {
|
|
1062
|
+
const map = /* @__PURE__ */ new Map();
|
|
1063
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
1064
|
+
const [addRaw, delRaw, ...pathParts] = line.split(" ");
|
|
1065
|
+
const path = pathParts[pathParts.length - 1];
|
|
1066
|
+
if (!path) continue;
|
|
1067
|
+
const additions = addRaw === "-" ? 0 : Number(addRaw);
|
|
1068
|
+
const deletions = delRaw === "-" ? 0 : Number(delRaw);
|
|
1069
|
+
if (!Number.isFinite(additions) && !Number.isFinite(deletions)) continue;
|
|
1070
|
+
map.set(path, {
|
|
1071
|
+
additions: Number.isFinite(additions) ? additions : 0,
|
|
1072
|
+
deletions: Number.isFinite(deletions) ? deletions : 0
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
return map;
|
|
1076
|
+
}
|
|
1077
|
+
function emptyStat() {
|
|
1078
|
+
return { files: 0, additions: 0, deletions: 0 };
|
|
1079
|
+
}
|
|
1080
|
+
function statFromNumstat(stdout, extraFiles = 0) {
|
|
1081
|
+
const map = parseNumstat(stdout);
|
|
1082
|
+
let additions = 0;
|
|
1083
|
+
let deletions = 0;
|
|
1084
|
+
for (const c of map.values()) {
|
|
1085
|
+
additions += c.additions;
|
|
1086
|
+
deletions += c.deletions;
|
|
1087
|
+
}
|
|
1088
|
+
return { files: map.size + extraFiles, additions, deletions };
|
|
1089
|
+
}
|
|
1090
|
+
function capPatch(patch, maxHunk) {
|
|
1091
|
+
if (patch.length <= maxHunk) return patch;
|
|
1092
|
+
return `${patch.slice(0, maxHunk)}
|
|
1093
|
+
|
|
1094
|
+
\u2026 truncated (${patch.length - maxHunk} more chars)`;
|
|
1095
|
+
}
|
|
1096
|
+
async function resolveMergeBase(worktreePath, base, repoPath) {
|
|
1097
|
+
const baseRef = await resolveDiffBaseRef(worktreePath, base, repoPath);
|
|
1098
|
+
const { stdout } = await git(["merge-base", baseRef, "HEAD"], worktreePath, {
|
|
1099
|
+
reject: false
|
|
1100
|
+
});
|
|
1101
|
+
const mb = stdout.trim();
|
|
1102
|
+
return mb || baseRef;
|
|
1103
|
+
}
|
|
1104
|
+
async function countUnpushedCommits(worktreePath) {
|
|
1105
|
+
const upstream = await git(
|
|
1106
|
+
["rev-list", "--count", "@{upstream}..HEAD"],
|
|
1107
|
+
worktreePath,
|
|
1108
|
+
{ reject: false }
|
|
1109
|
+
);
|
|
1110
|
+
if (upstream.exitCode === 0) {
|
|
1111
|
+
const n2 = Number(upstream.stdout.trim());
|
|
1112
|
+
return Number.isFinite(n2) ? n2 : 0;
|
|
1113
|
+
}
|
|
1114
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath, {
|
|
1115
|
+
reject: false
|
|
1116
|
+
});
|
|
1117
|
+
const branch = head.stdout.trim();
|
|
1118
|
+
if (!branch || branch === "HEAD") return 0;
|
|
1119
|
+
const remote = await git(
|
|
1120
|
+
["rev-list", "--count", `origin/${branch}..HEAD`],
|
|
1121
|
+
worktreePath,
|
|
1122
|
+
{ reject: false }
|
|
1123
|
+
);
|
|
1124
|
+
if (remote.exitCode !== 0) return 0;
|
|
1125
|
+
const n = Number(remote.stdout.trim());
|
|
1126
|
+
return Number.isFinite(n) ? n : 0;
|
|
1127
|
+
}
|
|
1128
|
+
function splitCombinedDiff(stdout) {
|
|
1129
|
+
const map = /* @__PURE__ */ new Map();
|
|
1130
|
+
if (!stdout.trim()) return map;
|
|
1131
|
+
const parts = stdout.split(/^diff --git /m);
|
|
1132
|
+
for (const part of parts) {
|
|
1133
|
+
if (!part.trim()) continue;
|
|
1134
|
+
const full = `diff --git ${part}`.replace(/\n$/, "");
|
|
1135
|
+
const header = full.match(/^diff --git a\/(.+?) b\/(.+?)(?:\n|$)/);
|
|
1136
|
+
if (!header) continue;
|
|
1137
|
+
map.set(header[2], full);
|
|
1138
|
+
}
|
|
1139
|
+
return map;
|
|
1140
|
+
}
|
|
1141
|
+
async function untrackedPatch(worktreePath, path, maxHunk) {
|
|
1142
|
+
const { stdout: patch } = await git(
|
|
1143
|
+
["diff", "--no-index", "--", "/dev/null", path],
|
|
1144
|
+
worktreePath,
|
|
1145
|
+
{ reject: false }
|
|
1146
|
+
);
|
|
1147
|
+
const normalized = patch.replace(/^diff --git a\/.+? b\/.+?$/m, `diff --git a/${path} b/${path}`).replace(/^--- .*$/m, "--- /dev/null").replace(/^\+\+\+ .*$/m, `+++ b/${path}`);
|
|
1148
|
+
const addLines = normalized.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
|
|
1149
|
+
return {
|
|
1150
|
+
path,
|
|
1151
|
+
status: "A",
|
|
1152
|
+
patch: capPatch(normalized, maxHunk),
|
|
1153
|
+
additions: addLines,
|
|
1154
|
+
deletions: 0
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
async function listUntrackedPaths(worktreePath) {
|
|
1158
|
+
const { stdout } = await git(
|
|
1159
|
+
["ls-files", "-z", "--others", "--exclude-standard"],
|
|
1160
|
+
worktreePath,
|
|
1161
|
+
{ reject: false }
|
|
1162
|
+
);
|
|
1163
|
+
return stdout.split("\0").filter(Boolean);
|
|
1164
|
+
}
|
|
1165
|
+
async function listUntrackedFiles(worktreePath, maxHunk) {
|
|
1166
|
+
const paths = await listUntrackedPaths(worktreePath);
|
|
1167
|
+
if (paths.length === 0) return [];
|
|
1168
|
+
return Promise.all(paths.map((path) => untrackedPatch(worktreePath, path, maxHunk)));
|
|
1169
|
+
}
|
|
1170
|
+
function filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk) {
|
|
1171
|
+
const counts = parseNumstat(numstat);
|
|
1172
|
+
const patches = splitCombinedDiff(combinedDiff);
|
|
1173
|
+
const files = [];
|
|
1174
|
+
for (const { status, path } of parseNameStatus(nameStatus)) {
|
|
1175
|
+
const n = counts.get(path);
|
|
1176
|
+
files.push({
|
|
1177
|
+
path,
|
|
1178
|
+
status,
|
|
1179
|
+
patch: capPatch(patches.get(path) ?? "", maxHunk),
|
|
1180
|
+
additions: n?.additions,
|
|
1181
|
+
deletions: n?.deletions
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
return files;
|
|
1185
|
+
}
|
|
1186
|
+
async function computeScopeStats(worktreePath, mergeBase, lastTurnBase, untrackedCount) {
|
|
1187
|
+
const [
|
|
1188
|
+
{ stdout: commitsNum },
|
|
1189
|
+
{ stdout: uncommittedNum },
|
|
1190
|
+
{ stdout: stagedNum },
|
|
1191
|
+
{ stdout: unstagedNum },
|
|
1192
|
+
lastTurnNum
|
|
1193
|
+
] = await Promise.all([
|
|
1194
|
+
mergeBase ? git(["diff", "--numstat", mergeBase], worktreePath, { reject: false }) : Promise.resolve({ stdout: "" }),
|
|
1195
|
+
git(["diff", "--numstat", "HEAD"], worktreePath, { reject: false }),
|
|
1196
|
+
git(["diff", "--numstat", "--cached"], worktreePath, { reject: false }),
|
|
1197
|
+
git(["diff", "--numstat"], worktreePath, { reject: false }),
|
|
1198
|
+
lastTurnBase ? git(["diff", "--numstat", lastTurnBase], worktreePath, { reject: false }) : Promise.resolve({ stdout: "" })
|
|
1199
|
+
]);
|
|
1200
|
+
return {
|
|
1201
|
+
commits: mergeBase ? statFromNumstat(commitsNum, untrackedCount) : emptyStat(),
|
|
1202
|
+
uncommitted: statFromNumstat(uncommittedNum, untrackedCount),
|
|
1203
|
+
staged: statFromNumstat(stagedNum),
|
|
1204
|
+
unstaged: statFromNumstat(unstagedNum, untrackedCount),
|
|
1205
|
+
last_turn: lastTurnBase ? statFromNumstat(lastTurnNum.stdout, untrackedCount) : emptyStat()
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
async function listBranchCommits(worktreePath, repoPath, opts) {
|
|
1209
|
+
const status = await inspectGitWorktree(worktreePath);
|
|
1210
|
+
if (status !== "ok") return [];
|
|
1211
|
+
const head = await git(["rev-parse", "--verify", "HEAD"], worktreePath, {
|
|
1212
|
+
reject: false
|
|
1213
|
+
});
|
|
1214
|
+
if (head.exitCode !== 0) return [];
|
|
1215
|
+
let base = opts?.base;
|
|
1216
|
+
try {
|
|
1217
|
+
base = base ?? await resolveDefaultBranch(repoPath);
|
|
1218
|
+
} catch {
|
|
1219
|
+
base = "HEAD";
|
|
1220
|
+
}
|
|
1221
|
+
const mergeBase = await resolveMergeBase(worktreePath, base, repoPath);
|
|
1222
|
+
const max = opts?.max ?? 40;
|
|
1223
|
+
const { stdout } = await git(
|
|
1224
|
+
[
|
|
1225
|
+
"log",
|
|
1226
|
+
"--date=relative",
|
|
1227
|
+
`--max-count=${max}`,
|
|
1228
|
+
"--format=%H%x09%h%x09%s%x09%cr",
|
|
1229
|
+
`${mergeBase}..HEAD`
|
|
1230
|
+
],
|
|
1231
|
+
worktreePath,
|
|
1232
|
+
{ reject: false }
|
|
1233
|
+
);
|
|
1234
|
+
const commits = [];
|
|
1235
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
1236
|
+
const [sha, shortSha, subject, relativeTime] = line.split(" ");
|
|
1237
|
+
if (!sha || !shortSha) continue;
|
|
1238
|
+
commits.push({
|
|
1239
|
+
sha,
|
|
1240
|
+
shortSha,
|
|
1241
|
+
subject: subject || "(no subject)",
|
|
1242
|
+
relativeTime: relativeTime || ""
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
return commits;
|
|
1246
|
+
}
|
|
1247
|
+
async function getDiff(worktreePath, repoPath, opts) {
|
|
1248
|
+
const status = await inspectGitWorktree(worktreePath);
|
|
1249
|
+
if (status === "missing_worktree") {
|
|
1250
|
+
throw new Error("Worktree not found");
|
|
1251
|
+
}
|
|
1252
|
+
if (status === "not_git") {
|
|
1253
|
+
throw new Error("Not a Git repository");
|
|
1254
|
+
}
|
|
1255
|
+
const maxHunk = opts?.maxHunkChars ?? 8e3;
|
|
1256
|
+
const scope = opts?.scope ?? "commits";
|
|
1257
|
+
const lastTurnBase = opts?.lastTurnBase?.trim() || null;
|
|
1258
|
+
const hasLastTurnBase = Boolean(lastTurnBase);
|
|
1259
|
+
const commitSha = opts?.commitSha?.trim() || null;
|
|
1260
|
+
const head = await git(["rev-parse", "--verify", "HEAD"], worktreePath, {
|
|
1261
|
+
reject: false
|
|
1262
|
+
});
|
|
1263
|
+
const hasHead = head.exitCode === 0;
|
|
1264
|
+
const untrackedPaths = await listUntrackedPaths(worktreePath);
|
|
1265
|
+
if (!hasHead) {
|
|
1266
|
+
const files2 = scope === "staged" ? [] : (await listUntrackedFiles(worktreePath, maxHunk)).sort(
|
|
1267
|
+
(a, b) => a.path.localeCompare(b.path)
|
|
1268
|
+
);
|
|
1269
|
+
const untrackedStat = {
|
|
1270
|
+
files: files2.length,
|
|
1271
|
+
additions: files2.reduce((n, f) => n + (f.additions ?? 0), 0),
|
|
1272
|
+
deletions: 0
|
|
1273
|
+
};
|
|
1274
|
+
const zero = emptyStat();
|
|
1275
|
+
return {
|
|
1276
|
+
scope,
|
|
1277
|
+
commitSha: null,
|
|
1278
|
+
base: "(no commits)",
|
|
1279
|
+
files: files2,
|
|
1280
|
+
stat: files2.length ? files2.map((f) => ` ${f.path} | untracked`).join("\n") : "(no changes)",
|
|
1281
|
+
dirty: files2.length > 0,
|
|
1282
|
+
unpushed: 0,
|
|
1283
|
+
hasLastTurnBase,
|
|
1284
|
+
commits: [],
|
|
1285
|
+
scopeStats: {
|
|
1286
|
+
commits: untrackedStat,
|
|
1287
|
+
uncommitted: untrackedStat,
|
|
1288
|
+
staged: zero,
|
|
1289
|
+
unstaged: untrackedStat,
|
|
1290
|
+
last_turn: hasLastTurnBase ? untrackedStat : zero
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
let base = opts?.base;
|
|
1295
|
+
try {
|
|
1296
|
+
base = base ?? await resolveDefaultBranch(repoPath);
|
|
1297
|
+
} catch {
|
|
1298
|
+
base = "HEAD";
|
|
1299
|
+
}
|
|
1300
|
+
base = await resolveDiffBaseRef(worktreePath, base, repoPath);
|
|
1301
|
+
if (base.startsWith("origin/")) {
|
|
1302
|
+
const short = base.slice("origin/".length);
|
|
1303
|
+
await git(["fetch", "origin", short, "--quiet"], worktreePath, {
|
|
1304
|
+
reject: false
|
|
1305
|
+
});
|
|
1306
|
+
base = await resolveDiffBaseRef(worktreePath, short, repoPath);
|
|
1307
|
+
}
|
|
1308
|
+
const mergeBase = await resolveMergeBase(worktreePath, base, repoPath);
|
|
1309
|
+
const scopeStats = await computeScopeStats(
|
|
1310
|
+
worktreePath,
|
|
1311
|
+
mergeBase,
|
|
1312
|
+
lastTurnBase,
|
|
1313
|
+
untrackedPaths.length
|
|
1314
|
+
);
|
|
1315
|
+
const includeUntracked = scope === "commits" && !commitSha || scope === "uncommitted" || scope === "unstaged" || scope === "last_turn";
|
|
1316
|
+
let nameStatus = "";
|
|
1317
|
+
let numstat = "";
|
|
1318
|
+
let combinedDiff = "";
|
|
1319
|
+
let labelBase = base;
|
|
1320
|
+
if (scope === "staged") {
|
|
1321
|
+
const [ns, num, diff] = await Promise.all([
|
|
1322
|
+
git(["diff", "--name-status", "--cached"], worktreePath, { reject: false }),
|
|
1323
|
+
git(["diff", "--numstat", "--cached"], worktreePath, { reject: false }),
|
|
1324
|
+
git(["diff", "--cached"], worktreePath, { reject: false })
|
|
1325
|
+
]);
|
|
1326
|
+
nameStatus = ns.stdout;
|
|
1327
|
+
numstat = num.stdout;
|
|
1328
|
+
combinedDiff = diff.stdout;
|
|
1329
|
+
labelBase = "staged";
|
|
1330
|
+
} else if (scope === "unstaged") {
|
|
1331
|
+
const [ns, num, diff] = await Promise.all([
|
|
1332
|
+
git(["diff", "--name-status"], worktreePath, { reject: false }),
|
|
1333
|
+
git(["diff", "--numstat"], worktreePath, { reject: false }),
|
|
1334
|
+
git(["diff"], worktreePath, { reject: false })
|
|
1335
|
+
]);
|
|
1336
|
+
nameStatus = ns.stdout;
|
|
1337
|
+
numstat = num.stdout;
|
|
1338
|
+
combinedDiff = diff.stdout;
|
|
1339
|
+
labelBase = "unstaged";
|
|
1340
|
+
} else if (scope === "uncommitted") {
|
|
1341
|
+
const [ns, num, diff] = await Promise.all([
|
|
1342
|
+
git(["diff", "--name-status", "HEAD"], worktreePath, { reject: false }),
|
|
1343
|
+
git(["diff", "--numstat", "HEAD"], worktreePath, { reject: false }),
|
|
1344
|
+
git(["diff", "HEAD"], worktreePath, { reject: false })
|
|
1345
|
+
]);
|
|
1346
|
+
nameStatus = ns.stdout;
|
|
1347
|
+
numstat = num.stdout;
|
|
1348
|
+
combinedDiff = diff.stdout;
|
|
1349
|
+
labelBase = "HEAD";
|
|
1350
|
+
} else if (scope === "last_turn") {
|
|
1351
|
+
if (!lastTurnBase) {
|
|
1352
|
+
const commits2 = await listBranchCommits(worktreePath, repoPath, { base });
|
|
1353
|
+
return {
|
|
1354
|
+
scope,
|
|
1355
|
+
commitSha: null,
|
|
1356
|
+
base: "last turn",
|
|
1357
|
+
files: [],
|
|
1358
|
+
stat: "(no last agent turn)",
|
|
1359
|
+
dirty: await isDirty(worktreePath),
|
|
1360
|
+
unpushed: await countUnpushedCommits(worktreePath),
|
|
1361
|
+
hasLastTurnBase: false,
|
|
1362
|
+
commits: commits2,
|
|
1363
|
+
scopeStats
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
const [ns, num, diff] = await Promise.all([
|
|
1367
|
+
git(["diff", "--name-status", lastTurnBase], worktreePath, { reject: false }),
|
|
1368
|
+
git(["diff", "--numstat", lastTurnBase], worktreePath, { reject: false }),
|
|
1369
|
+
git(["diff", lastTurnBase], worktreePath, { reject: false })
|
|
1370
|
+
]);
|
|
1371
|
+
nameStatus = ns.stdout;
|
|
1372
|
+
numstat = num.stdout;
|
|
1373
|
+
combinedDiff = diff.stdout;
|
|
1374
|
+
labelBase = "last turn";
|
|
1375
|
+
} else if (scope === "commits" && commitSha) {
|
|
1376
|
+
const range = `${commitSha}^!`;
|
|
1377
|
+
const [ns, num, diff] = await Promise.all([
|
|
1378
|
+
git(["diff", "--name-status", range], worktreePath, { reject: false }),
|
|
1379
|
+
git(["diff", "--numstat", range], worktreePath, { reject: false }),
|
|
1380
|
+
git(["diff", range], worktreePath, { reject: false })
|
|
1381
|
+
]);
|
|
1382
|
+
nameStatus = ns.stdout;
|
|
1383
|
+
numstat = num.stdout;
|
|
1384
|
+
combinedDiff = diff.stdout;
|
|
1385
|
+
labelBase = commitSha.slice(0, 7);
|
|
1386
|
+
} else {
|
|
1387
|
+
const [ns, num, diff] = await Promise.all([
|
|
1388
|
+
git(["diff", "--name-status", mergeBase], worktreePath, { reject: false }),
|
|
1389
|
+
git(["diff", "--numstat", mergeBase], worktreePath, { reject: false }),
|
|
1390
|
+
git(["diff", mergeBase], worktreePath, { reject: false })
|
|
1391
|
+
]);
|
|
1392
|
+
nameStatus = ns.stdout;
|
|
1393
|
+
numstat = num.stdout;
|
|
1394
|
+
combinedDiff = diff.stdout;
|
|
1395
|
+
labelBase = base;
|
|
1396
|
+
}
|
|
1397
|
+
const commits = await listBranchCommits(worktreePath, repoPath, { base });
|
|
1398
|
+
const filesMap = /* @__PURE__ */ new Map();
|
|
1399
|
+
for (const file of filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk)) {
|
|
1400
|
+
filesMap.set(file.path, file);
|
|
1401
|
+
}
|
|
1402
|
+
if (includeUntracked) {
|
|
1403
|
+
const untrackedFiles = await listUntrackedFiles(worktreePath, maxHunk);
|
|
1404
|
+
for (const file of untrackedFiles) {
|
|
1405
|
+
if (!filesMap.has(file.path)) filesMap.set(file.path, file);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const files = [...filesMap.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
1409
|
+
const { stdout: statOut } = await git(
|
|
1410
|
+
scope === "staged" ? ["diff", "--stat", "--cached"] : scope === "unstaged" ? ["diff", "--stat"] : scope === "uncommitted" ? ["diff", "--stat", "HEAD"] : scope === "last_turn" && lastTurnBase ? ["diff", "--stat", lastTurnBase] : scope === "commits" && commitSha ? ["diff", "--stat", `${commitSha}^!`] : ["diff", "--stat", mergeBase],
|
|
1411
|
+
worktreePath,
|
|
1412
|
+
{ reject: false }
|
|
1413
|
+
);
|
|
1414
|
+
return {
|
|
1415
|
+
scope,
|
|
1416
|
+
commitSha: scope === "commits" ? commitSha : null,
|
|
1417
|
+
base: labelBase,
|
|
1418
|
+
files,
|
|
1419
|
+
stat: statOut.trim() || (files.length ? `${files.length} file(s)` : "(no changes)"),
|
|
1420
|
+
dirty: await isDirty(worktreePath),
|
|
1421
|
+
unpushed: await countUnpushedCommits(worktreePath),
|
|
1422
|
+
hasLastTurnBase,
|
|
1423
|
+
commits,
|
|
1424
|
+
scopeStats
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
async function listWorktreeFiles(worktreePath, opts) {
|
|
1428
|
+
const maxFiles = opts?.maxFiles ?? 5e3;
|
|
1429
|
+
const tracked = await git(["ls-files", "-z"], worktreePath, { reject: false });
|
|
1430
|
+
const others = await git(
|
|
1431
|
+
["ls-files", "-z", "--others", "--exclude-standard"],
|
|
1432
|
+
worktreePath,
|
|
1433
|
+
{ reject: false }
|
|
1434
|
+
);
|
|
1435
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1436
|
+
for (const chunk of [tracked.stdout, others.stdout]) {
|
|
1437
|
+
for (const p of chunk.split("\0")) {
|
|
1438
|
+
if (p) paths.add(p);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return [...paths].sort((a, b) => a.localeCompare(b)).slice(0, maxFiles);
|
|
1442
|
+
}
|
|
1443
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1444
|
+
"png",
|
|
1445
|
+
"jpg",
|
|
1446
|
+
"jpeg",
|
|
1447
|
+
"gif",
|
|
1448
|
+
"webp",
|
|
1449
|
+
"svg",
|
|
1450
|
+
"bmp",
|
|
1451
|
+
"ico"
|
|
1452
|
+
]);
|
|
1453
|
+
function isImageRelativePath(relativePath) {
|
|
1454
|
+
const base = relativePath.split("/").pop()?.toLowerCase() || "";
|
|
1455
|
+
const ext = base.includes(".") ? base.split(".").pop() || "" : "";
|
|
1456
|
+
return IMAGE_EXTENSIONS.has(ext);
|
|
1457
|
+
}
|
|
1458
|
+
function readWorktreeFile(worktreePath, relativePath, opts) {
|
|
1459
|
+
assertSafeRelativePath(relativePath);
|
|
1460
|
+
const maxBytes = opts?.maxBytes ?? 2e5;
|
|
1461
|
+
const abs = join4(worktreePath, relativePath);
|
|
1462
|
+
const st = statSync2(abs);
|
|
1463
|
+
if (!st.isFile()) {
|
|
1464
|
+
throw new Error(`Not a file: ${relativePath}`);
|
|
1465
|
+
}
|
|
1466
|
+
const buf = readFileSync4(abs);
|
|
1467
|
+
if (isImageRelativePath(relativePath)) {
|
|
1468
|
+
const maxImageBytes = Math.max(maxBytes, 15e6);
|
|
1469
|
+
const truncated2 = buf.length > maxImageBytes;
|
|
1470
|
+
const slice = buf.subarray(0, maxImageBytes);
|
|
1471
|
+
return {
|
|
1472
|
+
path: relativePath,
|
|
1473
|
+
content: slice.toString("base64"),
|
|
1474
|
+
truncated: truncated2,
|
|
1475
|
+
binary: true,
|
|
1476
|
+
encoding: "base64"
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
const sample = buf.subarray(0, Math.min(buf.length, 8e3));
|
|
1480
|
+
const binary = sample.includes(0);
|
|
1481
|
+
if (binary) {
|
|
1482
|
+
return {
|
|
1483
|
+
path: relativePath,
|
|
1484
|
+
content: `(binary file, ${st.size} bytes)`,
|
|
1485
|
+
truncated: false,
|
|
1486
|
+
binary: true,
|
|
1487
|
+
encoding: "utf8"
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const truncated = buf.length > maxBytes;
|
|
1491
|
+
const content = buf.subarray(0, maxBytes).toString("utf8");
|
|
1492
|
+
return {
|
|
1493
|
+
path: relativePath,
|
|
1494
|
+
content: truncated ? `${content}
|
|
1495
|
+
|
|
1496
|
+
\u2026 truncated (${buf.length - maxBytes} more bytes)` : content,
|
|
1497
|
+
truncated,
|
|
1498
|
+
binary: false,
|
|
1499
|
+
encoding: "utf8"
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
function assertSafeRelativePath(relativePath) {
|
|
1503
|
+
if (!relativePath || relativePath.includes("..") || relativePath.startsWith("/")) {
|
|
1504
|
+
throw new Error("Invalid path");
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
function writeWorktreeFile(worktreePath, relativePath, content) {
|
|
1508
|
+
assertSafeRelativePath(relativePath);
|
|
1509
|
+
const abs = join4(worktreePath, relativePath);
|
|
1510
|
+
mkdirSync2(dirname2(abs), { recursive: true });
|
|
1511
|
+
writeFileSync(abs, content, "utf8");
|
|
1512
|
+
return { path: relativePath };
|
|
1513
|
+
}
|
|
1514
|
+
async function getDiffSummary(worktreePath, repoPath, opts) {
|
|
1515
|
+
const full = await getDiff(worktreePath, repoPath, {
|
|
1516
|
+
maxHunkChars: opts?.maxHunkChars ?? 2e3,
|
|
1517
|
+
scope: "commits"
|
|
1518
|
+
});
|
|
1519
|
+
const maxFiles = opts?.maxFiles ?? 10;
|
|
1520
|
+
return {
|
|
1521
|
+
base: full.base,
|
|
1522
|
+
dirty: full.dirty,
|
|
1523
|
+
stat: full.stat,
|
|
1524
|
+
files: full.files.slice(0, maxFiles),
|
|
1525
|
+
truncated: full.files.length > maxFiles
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/skills/discover.ts
|
|
1530
|
+
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
|
|
1531
|
+
import { homedir } from "os";
|
|
1532
|
+
import { join as join5 } from "path";
|
|
1533
|
+
function toCommand(name) {
|
|
1534
|
+
return name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1535
|
+
}
|
|
1536
|
+
function parseFrontmatter(content) {
|
|
1537
|
+
const lines = content.split("\n");
|
|
1538
|
+
if (lines[0]?.trim() !== "---") return { name: null, description: null };
|
|
1539
|
+
let end = -1;
|
|
1540
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1541
|
+
if (lines[i]?.trim() === "---") {
|
|
1542
|
+
end = i;
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (end < 0) return { name: null, description: null };
|
|
1547
|
+
const block = lines.slice(1, end).join("\n");
|
|
1548
|
+
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
1549
|
+
let description = null;
|
|
1550
|
+
const descInline = block.match(/^description:\s*(.+)$/m);
|
|
1551
|
+
if (descInline && !descInline[1].startsWith("|") && !descInline[1].startsWith(">")) {
|
|
1552
|
+
description = descInline[1].trim().replace(/^["']|["']$/g, "");
|
|
1553
|
+
} else {
|
|
1554
|
+
const descBlock = block.match(/^description:\s*[|>]?-?\s*\n((?:[ \t]+.+\n?)*)/m);
|
|
1555
|
+
if (descBlock) {
|
|
1556
|
+
description = descBlock[1].split("\n").map((l) => l.replace(/^[ \t]+/, "")).join(" ").trim();
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const name = nameMatch?.[1]?.trim().replace(/^["']|["']$/g, "") ?? null;
|
|
1560
|
+
return { name, description };
|
|
1561
|
+
}
|
|
1562
|
+
function readSkill(skillMd, source) {
|
|
1563
|
+
try {
|
|
1564
|
+
const content = readFileSync5(skillMd, "utf8");
|
|
1565
|
+
const { name: fmName, description } = parseFrontmatter(content);
|
|
1566
|
+
const dirName = skillMd.split("/").slice(-2, -1)[0] || "skill";
|
|
1567
|
+
const name = fmName || dirName;
|
|
1568
|
+
const command = toCommand(name);
|
|
1569
|
+
if (!command) return null;
|
|
1570
|
+
return {
|
|
1571
|
+
id: `${source}:${skillMd}`,
|
|
1572
|
+
name,
|
|
1573
|
+
command,
|
|
1574
|
+
description: (description || "").slice(0, 240),
|
|
1575
|
+
path: skillMd,
|
|
1576
|
+
source
|
|
1577
|
+
};
|
|
1578
|
+
} catch {
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
function scanSkillsDir(dir, source, out) {
|
|
1583
|
+
if (!existsSync5(dir)) return;
|
|
1584
|
+
let entries;
|
|
1585
|
+
try {
|
|
1586
|
+
entries = readdirSync2(dir);
|
|
1587
|
+
} catch {
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
for (const entry of entries) {
|
|
1591
|
+
if (entry.startsWith(".")) continue;
|
|
1592
|
+
const skillMd = join5(dir, entry, "SKILL.md");
|
|
1593
|
+
if (!existsSync5(skillMd)) continue;
|
|
1594
|
+
try {
|
|
1595
|
+
if (!statSync3(skillMd).isFile()) continue;
|
|
1596
|
+
} catch {
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
const skill = readSkill(skillMd, source);
|
|
1600
|
+
if (skill) out.push(skill);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function scanClaudePluginSkills(pluginsRoot, out) {
|
|
1604
|
+
if (!existsSync5(pluginsRoot)) return;
|
|
1605
|
+
const walk = (dir, depth, lookingForSkillsDir) => {
|
|
1606
|
+
if (depth > 7) return;
|
|
1607
|
+
let entries;
|
|
1608
|
+
try {
|
|
1609
|
+
entries = readdirSync2(dir);
|
|
1610
|
+
} catch {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (lookingForSkillsDir && entries.includes("SKILL.md")) {
|
|
1614
|
+
const skill = readSkill(join5(dir, "SKILL.md"), "cli");
|
|
1615
|
+
if (skill) out.push(skill);
|
|
1616
|
+
}
|
|
1617
|
+
for (const entry of entries) {
|
|
1618
|
+
if (entry === "node_modules" || entry === ".git") continue;
|
|
1619
|
+
const full = join5(dir, entry);
|
|
1620
|
+
try {
|
|
1621
|
+
if (!statSync3(full).isDirectory()) continue;
|
|
1622
|
+
} catch {
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
if (entry === "skills") {
|
|
1626
|
+
scanSkillsDir(full, "cli", out);
|
|
1627
|
+
walk(full, depth + 1, true);
|
|
1628
|
+
} else {
|
|
1629
|
+
walk(full, depth + 1, lookingForSkillsDir);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
walk(pluginsRoot, 0, false);
|
|
1634
|
+
}
|
|
1635
|
+
function discoverSkills(worktreePath) {
|
|
1636
|
+
const home = homedir();
|
|
1637
|
+
const collected = [];
|
|
1638
|
+
for (const rel of [".claude/skills", ".cursor/skills", ".sideboard/skills", ".brightsy/skills", "skills"]) {
|
|
1639
|
+
scanSkillsDir(join5(worktreePath, rel), "workspace", collected);
|
|
1640
|
+
}
|
|
1641
|
+
for (const abs of [
|
|
1642
|
+
join5(home, ".claude/skills"),
|
|
1643
|
+
join5(home, ".cursor/skills"),
|
|
1644
|
+
join5(home, ".sideboard/skills"),
|
|
1645
|
+
join5(home, ".brightsy/skills")
|
|
1646
|
+
]) {
|
|
1647
|
+
scanSkillsDir(abs, "user", collected);
|
|
1648
|
+
}
|
|
1649
|
+
scanClaudePluginSkills(join5(home, ".claude/plugins"), collected);
|
|
1650
|
+
const rank = { workspace: 0, user: 1, cli: 2 };
|
|
1651
|
+
const byCommand = /* @__PURE__ */ new Map();
|
|
1652
|
+
for (const skill of collected) {
|
|
1653
|
+
const prev = byCommand.get(skill.command);
|
|
1654
|
+
if (!prev || rank[skill.source] < rank[prev.source]) {
|
|
1655
|
+
byCommand.set(skill.command, skill);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return [...byCommand.values()].sort((a, b) => a.command.localeCompare(b.command));
|
|
1659
|
+
}
|
|
1660
|
+
function readSkillBody(skillPath, maxChars = 12e3) {
|
|
1661
|
+
const raw = readFileSync5(skillPath, "utf8");
|
|
1662
|
+
if (raw.startsWith("---")) {
|
|
1663
|
+
const end = raw.indexOf("\n---", 3);
|
|
1664
|
+
if (end >= 0) {
|
|
1665
|
+
const body = raw.slice(end + 4).trim();
|
|
1666
|
+
return body.length > maxChars ? `${body.slice(0, maxChars)}
|
|
1667
|
+
|
|
1668
|
+
\u2026(truncated)` : body;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return raw.length > maxChars ? `${raw.slice(0, maxChars)}
|
|
1672
|
+
|
|
1673
|
+
\u2026(truncated)` : raw;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// src/composer/expand.ts
|
|
1677
|
+
var FILE_MENTION_RE = /(?:^|[\s])@([A-Za-z0-9_./\-]+(?:\.[A-Za-z0-9]+)?)/g;
|
|
1678
|
+
var SLASH_CMD_RE = /(?:^|\s)\/([a-z0-9][a-z0-9-]*)\b/g;
|
|
1679
|
+
function expandComposerPrompt(worktreePath, prompt, opts) {
|
|
1680
|
+
const skills = opts?.skills ?? discoverSkills(worktreePath);
|
|
1681
|
+
const byCommand = new Map(skills.map((s) => [s.command, s]));
|
|
1682
|
+
const maxFileBytes = opts?.maxFileBytes ?? 8e4;
|
|
1683
|
+
const attachments = opts?.attachments ?? [];
|
|
1684
|
+
const mentionedFiles = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const m of prompt.matchAll(FILE_MENTION_RE)) {
|
|
1686
|
+
const p = m[1];
|
|
1687
|
+
if (p) mentionedFiles.add(p);
|
|
1688
|
+
}
|
|
1689
|
+
const skillsUsed = [];
|
|
1690
|
+
const seenSkill = /* @__PURE__ */ new Set();
|
|
1691
|
+
for (const m of prompt.matchAll(SLASH_CMD_RE)) {
|
|
1692
|
+
const cmd = m[1];
|
|
1693
|
+
if (!cmd || seenSkill.has(cmd)) continue;
|
|
1694
|
+
const skill = byCommand.get(cmd);
|
|
1695
|
+
if (skill) {
|
|
1696
|
+
skillsUsed.push(skill);
|
|
1697
|
+
seenSkill.add(cmd);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
if (mentionedFiles.size === 0 && skillsUsed.length === 0 && attachments.length === 0) {
|
|
1701
|
+
return { agentPrompt: prompt, mentionedFiles: [], skillsUsed: [] };
|
|
1702
|
+
}
|
|
1703
|
+
const parts = [prompt.trim(), "", "---", "Sideboard context (auto-attached):"];
|
|
1704
|
+
for (const att of attachments) {
|
|
1705
|
+
parts.push("");
|
|
1706
|
+
parts.push(`## Attachment: ${att.name}`);
|
|
1707
|
+
parts.push(`Kind: ${att.kind}`);
|
|
1708
|
+
parts.push("");
|
|
1709
|
+
parts.push(att.content);
|
|
1710
|
+
}
|
|
1711
|
+
for (const skill of skillsUsed) {
|
|
1712
|
+
let body = "";
|
|
1713
|
+
try {
|
|
1714
|
+
body = readSkillBody(skill.path);
|
|
1715
|
+
} catch {
|
|
1716
|
+
body = `(could not read skill at ${skill.path})`;
|
|
1717
|
+
}
|
|
1718
|
+
parts.push("");
|
|
1719
|
+
parts.push(`## Skill: /${skill.command} (${skill.name})`);
|
|
1720
|
+
parts.push(`Source: ${skill.source} \xB7 ${skill.path}`);
|
|
1721
|
+
parts.push("Follow this skill for the request above:");
|
|
1722
|
+
parts.push("");
|
|
1723
|
+
parts.push(body);
|
|
1724
|
+
}
|
|
1725
|
+
for (const rel of mentionedFiles) {
|
|
1726
|
+
parts.push("");
|
|
1727
|
+
parts.push(`## Referenced file: @${rel}`);
|
|
1728
|
+
try {
|
|
1729
|
+
const file = readWorktreeFile(worktreePath, rel, { maxBytes: maxFileBytes });
|
|
1730
|
+
if (file.binary) {
|
|
1731
|
+
parts.push(`(binary file \u2014 use the Read tool on \`${rel}\`)`);
|
|
1732
|
+
} else {
|
|
1733
|
+
parts.push(`Path in worktree: \`${rel}\``);
|
|
1734
|
+
parts.push("```");
|
|
1735
|
+
parts.push(file.content);
|
|
1736
|
+
parts.push("```");
|
|
1737
|
+
if (file.truncated) parts.push("(truncated)");
|
|
1738
|
+
}
|
|
1739
|
+
} catch {
|
|
1740
|
+
parts.push(`(file not found or unreadable \u2014 use the Read/Glob tools for \`${rel}\`)`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return {
|
|
1744
|
+
agentPrompt: parts.join("\n"),
|
|
1745
|
+
mentionedFiles: [...mentionedFiles],
|
|
1746
|
+
skillsUsed
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/composer/summarize.ts
|
|
1751
|
+
var MAX_TRANSCRIPT_CHARS = 12e4;
|
|
1752
|
+
var MAX_SUMMARY_CHARS = 6e3;
|
|
1753
|
+
async function summarizeConversation(transcript, opts) {
|
|
1754
|
+
const clipped = transcript.length > MAX_TRANSCRIPT_CHARS ? `${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}
|
|
1755
|
+
|
|
1756
|
+
[\u2026truncated\u2026]` : transcript;
|
|
1757
|
+
const claudeSummary = await tryClaudeSummary(clipped, opts);
|
|
1758
|
+
if (claudeSummary) {
|
|
1759
|
+
return { summary: clipSummary(claudeSummary), method: "claude" };
|
|
1760
|
+
}
|
|
1761
|
+
return { summary: clipSummary(extractiveSummary(clipped)), method: "extractive" };
|
|
1762
|
+
}
|
|
1763
|
+
async function tryClaudeSummary(transcript, opts) {
|
|
1764
|
+
ensureAgentPath();
|
|
1765
|
+
const prompt = [
|
|
1766
|
+
"Summarize this coding-agent conversation for continuity in a fresh session.",
|
|
1767
|
+
"Use concise markdown bullets covering:",
|
|
1768
|
+
"- Goal / request",
|
|
1769
|
+
"- Key decisions and constraints",
|
|
1770
|
+
"- Files / areas touched",
|
|
1771
|
+
"- Current state and open tasks",
|
|
1772
|
+
"- Anything the next turn must remember",
|
|
1773
|
+
"Do not invent details. No preamble.",
|
|
1774
|
+
"",
|
|
1775
|
+
"Transcript:",
|
|
1776
|
+
transcript
|
|
1777
|
+
].join("\n");
|
|
1778
|
+
try {
|
|
1779
|
+
const { stdout, exitCode } = await run(
|
|
1780
|
+
"claude",
|
|
1781
|
+
[
|
|
1782
|
+
"-p",
|
|
1783
|
+
prompt,
|
|
1784
|
+
"--output-format",
|
|
1785
|
+
"text",
|
|
1786
|
+
"--permission-mode",
|
|
1787
|
+
"bypassPermissions",
|
|
1788
|
+
"--model",
|
|
1789
|
+
"haiku"
|
|
1790
|
+
],
|
|
1791
|
+
{ cwd: opts?.cwd, reject: false }
|
|
1792
|
+
);
|
|
1793
|
+
if (exitCode !== 0) return null;
|
|
1794
|
+
const text = stdout.trim();
|
|
1795
|
+
if (text.length < 40) return null;
|
|
1796
|
+
return text;
|
|
1797
|
+
} catch {
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
function extractiveSummary(transcript) {
|
|
1802
|
+
const lines = transcript.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1803
|
+
const users = [];
|
|
1804
|
+
const tools = [];
|
|
1805
|
+
const agents = [];
|
|
1806
|
+
let mode = "none";
|
|
1807
|
+
for (const line of lines) {
|
|
1808
|
+
if (line.startsWith("### User")) {
|
|
1809
|
+
mode = "user";
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (line.startsWith("### Agent") || line.startsWith("## Prior")) {
|
|
1813
|
+
mode = "agent";
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (line.startsWith("- ") && line.includes(":")) {
|
|
1817
|
+
tools.push(line.replace(/^- /, ""));
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (mode === "user" && users.length < 8) {
|
|
1821
|
+
users.push(line.slice(0, 240));
|
|
1822
|
+
} else if (mode === "agent" && agents.length < 6) {
|
|
1823
|
+
agents.push(line.slice(0, 240));
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const uniqueTools = [...new Set(tools)].slice(0, 20);
|
|
1827
|
+
const parts = [
|
|
1828
|
+
"## Compacted context (extractive)",
|
|
1829
|
+
"",
|
|
1830
|
+
"### User goals",
|
|
1831
|
+
...users.length ? users.map((u) => `- ${u}`) : ["- (none captured)"],
|
|
1832
|
+
"",
|
|
1833
|
+
"### Recent agent notes",
|
|
1834
|
+
...agents.length ? agents.map((a) => `- ${a}`) : ["- (none captured)"]
|
|
1835
|
+
];
|
|
1836
|
+
if (uniqueTools.length) {
|
|
1837
|
+
parts.push("", "### Tools / files", ...uniqueTools.map((t) => `- ${t}`));
|
|
1838
|
+
}
|
|
1839
|
+
parts.push(
|
|
1840
|
+
"",
|
|
1841
|
+
"_Automatic extractive summary \u2014 Claude summarizer was unavailable._"
|
|
1842
|
+
);
|
|
1843
|
+
return parts.join("\n");
|
|
1844
|
+
}
|
|
1845
|
+
function clipSummary(text) {
|
|
1846
|
+
if (text.length <= MAX_SUMMARY_CHARS) return text;
|
|
1847
|
+
return `${text.slice(0, MAX_SUMMARY_CHARS)}
|
|
1848
|
+
|
|
1849
|
+
[\u2026summary truncated\u2026]`;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// src/composer/context-compact.ts
|
|
1853
|
+
var CONTEXT_COMPACT_CHARS = 1e5;
|
|
1854
|
+
var CONTEXT_KEEP_RECENT_CHARS = 24e3;
|
|
1855
|
+
var CONTEXT_KEEP_RECENT_MESSAGES = 12;
|
|
1856
|
+
var CONTEXT_MIN_MESSAGES = 10;
|
|
1857
|
+
function estimateMessageChars(message) {
|
|
1858
|
+
let n = message.text.length + 16;
|
|
1859
|
+
for (const part of message.parts ?? []) {
|
|
1860
|
+
n += estimatePartChars(part);
|
|
1861
|
+
}
|
|
1862
|
+
return n;
|
|
1863
|
+
}
|
|
1864
|
+
function estimatePartChars(part) {
|
|
1865
|
+
switch (part.type) {
|
|
1866
|
+
case "text":
|
|
1867
|
+
case "thinking":
|
|
1868
|
+
return part.text.length;
|
|
1869
|
+
case "tool": {
|
|
1870
|
+
const input = part.input ? JSON.stringify(part.input) : "";
|
|
1871
|
+
return part.name.length + (part.description?.length ?? 0) + (part.detail?.length ?? 0) + (part.result?.length ?? 0) + input.length + 32;
|
|
1872
|
+
}
|
|
1873
|
+
default:
|
|
1874
|
+
return 0;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
function estimateThreadChars(messages) {
|
|
1878
|
+
return messages.reduce((sum, m) => sum + estimateMessageChars(m), 0);
|
|
1879
|
+
}
|
|
1880
|
+
function shouldCompactContext(messages, thresholds = {}) {
|
|
1881
|
+
const maxChars = thresholds.maxChars ?? CONTEXT_COMPACT_CHARS;
|
|
1882
|
+
const minMessages = thresholds.minMessages ?? CONTEXT_MIN_MESSAGES;
|
|
1883
|
+
if (messages.length < minMessages) return false;
|
|
1884
|
+
return estimateThreadChars(messages) >= maxChars;
|
|
1885
|
+
}
|
|
1886
|
+
function splitForCompaction(messages, thresholds = {}) {
|
|
1887
|
+
const keepChars = thresholds.keepRecentChars ?? CONTEXT_KEEP_RECENT_CHARS;
|
|
1888
|
+
const keepCount = thresholds.keepRecentMessages ?? CONTEXT_KEEP_RECENT_MESSAGES;
|
|
1889
|
+
if (messages.length === 0) return { older: [], recent: [] };
|
|
1890
|
+
let recentChars = 0;
|
|
1891
|
+
let cut = messages.length;
|
|
1892
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1893
|
+
const next = recentChars + estimateMessageChars(messages[i]);
|
|
1894
|
+
const kept = messages.length - i;
|
|
1895
|
+
if (kept >= keepCount && next > keepChars) break;
|
|
1896
|
+
recentChars = next;
|
|
1897
|
+
cut = i;
|
|
1898
|
+
}
|
|
1899
|
+
const minRecent = Math.min(keepCount, messages.length);
|
|
1900
|
+
cut = Math.min(cut, messages.length - minRecent);
|
|
1901
|
+
if (cut <= 0) return { older: [], recent: messages };
|
|
1902
|
+
return {
|
|
1903
|
+
older: messages.slice(0, cut),
|
|
1904
|
+
recent: messages.slice(cut)
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
function formatToolPart(t, detail) {
|
|
1908
|
+
if (detail === "summary") {
|
|
1909
|
+
const label = t.description || t.detail || t.name;
|
|
1910
|
+
const path = t.filePath ? ` (${t.filePath})` : "";
|
|
1911
|
+
return `- ${t.name}: ${label}${path}`;
|
|
1912
|
+
}
|
|
1913
|
+
const lines = [`#### Tool: ${t.name}`, `Status: ${t.status}`];
|
|
1914
|
+
if (t.description) lines.push(`Description: ${t.description}`);
|
|
1915
|
+
if (t.filePath) lines.push(`Path: ${t.filePath}`);
|
|
1916
|
+
if (t.input && Object.keys(t.input).length > 0) {
|
|
1917
|
+
lines.push("Input:");
|
|
1918
|
+
lines.push("```json");
|
|
1919
|
+
lines.push(JSON.stringify(t.input, null, 2));
|
|
1920
|
+
lines.push("```");
|
|
1921
|
+
} else if (t.detail) {
|
|
1922
|
+
lines.push(`Detail: ${t.detail}`);
|
|
1923
|
+
}
|
|
1924
|
+
if (t.result != null && t.result !== "") {
|
|
1925
|
+
lines.push("Result:");
|
|
1926
|
+
lines.push("```");
|
|
1927
|
+
lines.push(t.result);
|
|
1928
|
+
lines.push("```");
|
|
1929
|
+
}
|
|
1930
|
+
return lines.join("\n");
|
|
1931
|
+
}
|
|
1932
|
+
function formatMessagesAsTranscript(messages, opts) {
|
|
1933
|
+
const tools = opts?.tools ?? "full";
|
|
1934
|
+
const blocks = [];
|
|
1935
|
+
for (const m of messages) {
|
|
1936
|
+
if (m.role === "summary") {
|
|
1937
|
+
blocks.push(`## Prior summary
|
|
1938
|
+
${m.text}`);
|
|
1939
|
+
continue;
|
|
1940
|
+
}
|
|
1941
|
+
if (m.role === "user") {
|
|
1942
|
+
blocks.push(`### User
|
|
1943
|
+
${m.text}`);
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
const bits = [];
|
|
1947
|
+
if (m.text?.trim()) {
|
|
1948
|
+
bits.push(`### Agent
|
|
1949
|
+
${m.text}`);
|
|
1950
|
+
} else {
|
|
1951
|
+
bits.push("### Agent");
|
|
1952
|
+
}
|
|
1953
|
+
const thinking = (m.parts ?? []).filter((p) => p.type === "thinking");
|
|
1954
|
+
for (const th of thinking) {
|
|
1955
|
+
if (th.type !== "thinking" || !th.text.trim()) continue;
|
|
1956
|
+
bits.push("Thinking:");
|
|
1957
|
+
bits.push(th.text);
|
|
1958
|
+
}
|
|
1959
|
+
const toolParts = (m.parts ?? []).filter(
|
|
1960
|
+
(p) => p.type === "tool"
|
|
1961
|
+
);
|
|
1962
|
+
if (tools !== "none" && toolParts.length > 0) {
|
|
1963
|
+
if (tools === "summary") bits.push("Tools:");
|
|
1964
|
+
for (const t of toolParts) {
|
|
1965
|
+
bits.push(formatToolPart(t, tools));
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
blocks.push(bits.join("\n"));
|
|
1969
|
+
}
|
|
1970
|
+
return blocks.join("\n\n");
|
|
1971
|
+
}
|
|
1972
|
+
function buildSessionSeed(messages, opts) {
|
|
1973
|
+
if (messages.length === 0) return null;
|
|
1974
|
+
const body = formatMessagesAsTranscript(messages, { tools: opts?.tools ?? "full" });
|
|
1975
|
+
if (!body.trim()) return null;
|
|
1976
|
+
return [
|
|
1977
|
+
"Sideboard conversation context (restored after compaction or a new session):",
|
|
1978
|
+
"",
|
|
1979
|
+
body,
|
|
1980
|
+
"",
|
|
1981
|
+
"Continue from this context. Do not repeat the summary unless asked."
|
|
1982
|
+
].join("\n");
|
|
1983
|
+
}
|
|
1984
|
+
function applyCompaction(messages, summaryText, thresholds = {}) {
|
|
1985
|
+
const { older, recent } = splitForCompaction(messages, thresholds);
|
|
1986
|
+
if (older.length === 0) return messages;
|
|
1987
|
+
const summary = {
|
|
1988
|
+
role: "summary",
|
|
1989
|
+
text: summaryText.trim(),
|
|
1990
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1991
|
+
};
|
|
1992
|
+
return [summary, ...recent];
|
|
1993
|
+
}
|
|
1994
|
+
async function maybeCompactContext(thread, thresholds = {}, summarize = summarizeConversation) {
|
|
1995
|
+
if (!shouldCompactContext(thread.messages, thresholds)) {
|
|
1996
|
+
return { didCompact: false, thread };
|
|
1997
|
+
}
|
|
1998
|
+
const { older } = splitForCompaction(thread.messages, thresholds);
|
|
1999
|
+
if (older.length === 0) {
|
|
2000
|
+
return { didCompact: false, thread };
|
|
2001
|
+
}
|
|
2002
|
+
const transcript = formatMessagesAsTranscript(older, { tools: "summary" });
|
|
2003
|
+
const { summary, method } = await summarize(transcript, {
|
|
2004
|
+
cwd: thread.worktreePath
|
|
2005
|
+
});
|
|
2006
|
+
const messages = applyCompaction(thread.messages, summary, thresholds);
|
|
2007
|
+
const next = {
|
|
2008
|
+
...thread,
|
|
2009
|
+
messages,
|
|
2010
|
+
sessionId: null,
|
|
2011
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2012
|
+
};
|
|
2013
|
+
return {
|
|
2014
|
+
didCompact: true,
|
|
2015
|
+
thread: next,
|
|
2016
|
+
summary,
|
|
2017
|
+
method,
|
|
2018
|
+
olderCount: older.length
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/land/pr-metadata.ts
|
|
2023
|
+
var GENERIC_TITLE_RE = /^(sideboard:|thread\/|wip\b|tmp\b|update\b|changes?\b|misc\b|stuff\b)/i;
|
|
2024
|
+
function looksGeneric(title) {
|
|
2025
|
+
const t = title.trim();
|
|
2026
|
+
if (!t) return true;
|
|
2027
|
+
if (t.length < 4) return true;
|
|
2028
|
+
if (GENERIC_TITLE_RE.test(t)) return true;
|
|
2029
|
+
if (!/[\s:/\-_]/.test(t) && t.length <= 24) return true;
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
function truncateTitle(title, max = 72) {
|
|
2033
|
+
const t = title.trim().replace(/\s+/g, " ");
|
|
2034
|
+
if (t.length <= max) return t;
|
|
2035
|
+
return `${t.slice(0, max - 1).trimEnd()}\u2026`;
|
|
2036
|
+
}
|
|
2037
|
+
function titleFromPaths(paths) {
|
|
2038
|
+
if (paths.length === 0) return null;
|
|
2039
|
+
const top = paths.slice(0, 3).map((p) => p.replace(/\/$/, ""));
|
|
2040
|
+
if (paths.length === 1) return truncateTitle(`Update ${top[0]}`);
|
|
2041
|
+
if (paths.length <= 3) return truncateTitle(`Update ${top.join(", ")}`);
|
|
2042
|
+
return truncateTitle(`Update ${top[0]} and ${paths.length - 1} more files`);
|
|
2043
|
+
}
|
|
2044
|
+
async function suggestPrMetadata(worktreePath, opts) {
|
|
2045
|
+
const [{ stdout: logOut }, { stdout: nameStatus }, { stdout: statOut }] = await Promise.all([
|
|
2046
|
+
git(["log", "--format=%s", `${opts.base}..HEAD`], worktreePath, {
|
|
2047
|
+
reject: false
|
|
2048
|
+
}),
|
|
2049
|
+
git(["diff", "--name-only", `${opts.base}...HEAD`], worktreePath, {
|
|
2050
|
+
reject: false
|
|
2051
|
+
}),
|
|
2052
|
+
git(["diff", "--stat", `${opts.base}...HEAD`], worktreePath, {
|
|
2053
|
+
reject: false
|
|
2054
|
+
})
|
|
2055
|
+
]);
|
|
2056
|
+
const { stdout: dirtyNames } = await git(["diff", "--name-only", "HEAD"], worktreePath, {
|
|
2057
|
+
reject: false
|
|
2058
|
+
});
|
|
2059
|
+
const { stdout: untracked } = await git(
|
|
2060
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
2061
|
+
worktreePath,
|
|
2062
|
+
{ reject: false }
|
|
2063
|
+
);
|
|
2064
|
+
const subjects = logOut.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2065
|
+
const paths = [
|
|
2066
|
+
...new Set(
|
|
2067
|
+
[...nameStatus.split("\n"), ...dirtyNames.split("\n"), ...untracked.split("\n")].map((p) => p.trim()).filter(Boolean)
|
|
2068
|
+
)
|
|
2069
|
+
].sort((a, b) => a.localeCompare(b));
|
|
2070
|
+
const meaningfulSubjects = subjects.filter((s) => !looksGeneric(s));
|
|
2071
|
+
const fallback = opts.fallbackTitle?.trim() || null;
|
|
2072
|
+
const fallbackOk = fallback && !looksGeneric(fallback) ? fallback : null;
|
|
2073
|
+
const title = truncateTitle(
|
|
2074
|
+
meaningfulSubjects[0] ?? fallbackOk ?? titleFromPaths(paths) ?? "Sideboard changes"
|
|
2075
|
+
);
|
|
2076
|
+
const commitMessage = meaningfulSubjects[0] ?? fallbackOk ?? titleFromPaths(paths) ?? `sideboard: ${title}`;
|
|
2077
|
+
const summaryLines = meaningfulSubjects.length > 0 ? meaningfulSubjects.slice(0, 12).map((s) => `- ${s}`) : paths.slice(0, 20).map((p) => `- \`${p}\``);
|
|
2078
|
+
const bodyParts = [
|
|
2079
|
+
"## Summary",
|
|
2080
|
+
summaryLines.length > 0 ? summaryLines.join("\n") : "- Changes landed via Sideboard.",
|
|
2081
|
+
""
|
|
2082
|
+
];
|
|
2083
|
+
if (statOut.trim()) {
|
|
2084
|
+
bodyParts.push("## Diff", "```", statOut.trim(), "```", "");
|
|
2085
|
+
}
|
|
2086
|
+
if (opts.sourceLabel) {
|
|
2087
|
+
bodyParts.push(`_Landed via Sideboard from ${opts.sourceLabel}_`);
|
|
2088
|
+
}
|
|
2089
|
+
return {
|
|
2090
|
+
title,
|
|
2091
|
+
body: bodyParts.join("\n").trim() + "\n",
|
|
2092
|
+
commitMessage: truncateTitle(commitMessage, 72)
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// src/land/land.ts
|
|
2097
|
+
async function previewLand(thread) {
|
|
2098
|
+
if (thread.sourceIsFork) {
|
|
2099
|
+
return {
|
|
2100
|
+
branch: thread.branchName,
|
|
2101
|
+
target: "(unknown)",
|
|
2102
|
+
diffStat: "",
|
|
2103
|
+
dirty: await isDirty(thread.worktreePath),
|
|
2104
|
+
blocked: true,
|
|
2105
|
+
blockReason: "Cross-repository (fork) PR sources cannot be landed in v1",
|
|
2106
|
+
isFork: true
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
const target = await resolveDefaultBranch(thread.repoPath);
|
|
2110
|
+
if (thread.branchName === target || thread.branchName === "main" || thread.branchName === "master") {
|
|
2111
|
+
}
|
|
2112
|
+
const { stdout: head } = await import("./run-LF6E5IKL.js").then(
|
|
2113
|
+
({ git: git2 }) => git2(["rev-parse", "--abbrev-ref", "HEAD"], thread.worktreePath)
|
|
2114
|
+
);
|
|
2115
|
+
const current = head.trim();
|
|
2116
|
+
if (current === target) {
|
|
2117
|
+
return {
|
|
2118
|
+
branch: current,
|
|
2119
|
+
target,
|
|
2120
|
+
diffStat: "",
|
|
2121
|
+
dirty: await isDirty(thread.worktreePath),
|
|
2122
|
+
blocked: true,
|
|
2123
|
+
blockReason: `Hard-blocked: branch resolves to default branch "${target}"`,
|
|
2124
|
+
isFork: false
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const diff = await getDiff(thread.worktreePath, thread.repoPath, { base: target });
|
|
2128
|
+
return {
|
|
2129
|
+
branch: thread.branchName,
|
|
2130
|
+
target,
|
|
2131
|
+
diffStat: diff.stat,
|
|
2132
|
+
dirty: diff.dirty,
|
|
2133
|
+
blocked: false,
|
|
2134
|
+
isFork: false
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
async function confirmLand(thread, opts) {
|
|
2138
|
+
const preview = await previewLand(thread);
|
|
2139
|
+
if (preview.blocked) {
|
|
2140
|
+
throw new Error(preview.blockReason ?? "Land blocked");
|
|
2141
|
+
}
|
|
2142
|
+
const meta = await suggestPrMetadata(thread.worktreePath, {
|
|
2143
|
+
base: preview.target,
|
|
2144
|
+
fallbackTitle: thread.title,
|
|
2145
|
+
sourceLabel: `${thread.sourceType}:${thread.sourceRef}`
|
|
2146
|
+
});
|
|
2147
|
+
let committed = false;
|
|
2148
|
+
if (preview.dirty) {
|
|
2149
|
+
committed = await commitAll(thread.worktreePath, meta.commitMessage);
|
|
2150
|
+
}
|
|
2151
|
+
const { git: git2 } = await import("./run-LF6E5IKL.js");
|
|
2152
|
+
const { stdout: headOut } = await git2(
|
|
2153
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
2154
|
+
thread.worktreePath,
|
|
2155
|
+
{ reject: false }
|
|
2156
|
+
);
|
|
2157
|
+
const head = headOut.trim();
|
|
2158
|
+
const branch = head && head !== "HEAD" ? head : thread.branchName;
|
|
2159
|
+
await pushBranch(thread.worktreePath, branch);
|
|
2160
|
+
const prUrl = await createOrUpdatePr(thread.worktreePath, {
|
|
2161
|
+
title: meta.title,
|
|
2162
|
+
body: meta.body,
|
|
2163
|
+
base: preview.target,
|
|
2164
|
+
head: branch,
|
|
2165
|
+
draft: opts?.draft,
|
|
2166
|
+
web: opts?.web
|
|
2167
|
+
});
|
|
2168
|
+
return { prUrl, pushed: true, committed };
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// src/threads/create.ts
|
|
2172
|
+
import { existsSync as existsSync6 } from "fs";
|
|
2173
|
+
async function createThread(input, onSetupLine) {
|
|
2174
|
+
await requireAgent(input.agent);
|
|
2175
|
+
const repoPath = await resolveRepoRoot(input.repoPath);
|
|
2176
|
+
if (!existsSync6(repoPath)) {
|
|
2177
|
+
throw new Error(`Repo not found: ${repoPath}`);
|
|
2178
|
+
}
|
|
2179
|
+
let sourceRef = input.sourceRef;
|
|
2180
|
+
let sourceIsFork = false;
|
|
2181
|
+
let prUrl = null;
|
|
2182
|
+
if (input.sourceType === "pr") {
|
|
2183
|
+
const num = Number(input.sourceRef.replace(/^#/, ""));
|
|
2184
|
+
if (!Number.isFinite(num)) throw new Error(`Invalid PR number: ${input.sourceRef}`);
|
|
2185
|
+
const pr = await getPr(repoPath, num);
|
|
2186
|
+
if (!pr) throw new Error(`PR #${num} not found`);
|
|
2187
|
+
sourceIsFork = pr.isCrossRepository;
|
|
2188
|
+
prUrl = pr.url;
|
|
2189
|
+
const localFetchBranch = `sideboard-pr-${pr.number}`;
|
|
2190
|
+
await fetchPrHead(repoPath, pr.number, localFetchBranch);
|
|
2191
|
+
sourceRef = localFetchBranch;
|
|
2192
|
+
} else if (input.sourceType === "ticket") {
|
|
2193
|
+
sourceRef = await resolveDefaultBranch(repoPath);
|
|
2194
|
+
} else if (input.sourceType === "branch") {
|
|
2195
|
+
if (!sourceRef || sourceRef === "HEAD" || sourceRef === "default") {
|
|
2196
|
+
sourceRef = await resolveDefaultBranch(repoPath);
|
|
2197
|
+
}
|
|
2198
|
+
} else if (input.sourceType === "adopt") {
|
|
2199
|
+
throw new Error("Use adoptThread() for adopt sources");
|
|
2200
|
+
}
|
|
2201
|
+
const team = allocateTeamSlug(repoPath);
|
|
2202
|
+
const { branchName, worktreePath } = await createThreadWorktree({
|
|
2203
|
+
repoPath,
|
|
2204
|
+
sourceRef,
|
|
2205
|
+
slug: team.slug
|
|
2206
|
+
});
|
|
2207
|
+
copyConfiguredFiles(repoPath, worktreePath);
|
|
2208
|
+
const explicitTitle = input.title?.trim();
|
|
2209
|
+
const thread = createEmptyThread({
|
|
2210
|
+
title: explicitTitle || team.name,
|
|
2211
|
+
userSetTitle: Boolean(explicitTitle),
|
|
2212
|
+
sourceType: input.sourceType,
|
|
2213
|
+
sourceRef: input.sourceRef === "default" ? sourceRef : input.sourceRef,
|
|
2214
|
+
branchName,
|
|
2215
|
+
worktreePath,
|
|
2216
|
+
repoPath,
|
|
2217
|
+
agent: input.agent,
|
|
2218
|
+
autonomy: input.autonomy ?? "default",
|
|
2219
|
+
model: input.model ?? null,
|
|
2220
|
+
fast: Boolean(input.fast),
|
|
2221
|
+
planMode: Boolean(input.planMode),
|
|
2222
|
+
attachments: input.attachments ?? [],
|
|
2223
|
+
sourceIsFork,
|
|
2224
|
+
parentThreadId: input.parentThreadId ?? null,
|
|
2225
|
+
status: "idle",
|
|
2226
|
+
prUrl
|
|
2227
|
+
});
|
|
2228
|
+
writeThread(thread);
|
|
2229
|
+
await ensureWorkspace(repoPath);
|
|
2230
|
+
try {
|
|
2231
|
+
let setup = await runSetupScript(repoPath, worktreePath, onSetupLine);
|
|
2232
|
+
if (!setup.ran) {
|
|
2233
|
+
setup = await runCursorWorktreeSetup(repoPath, worktreePath, onSetupLine);
|
|
2234
|
+
}
|
|
2235
|
+
if (setup.ran && setup.exitCode !== 0 && setup.exitCode !== null) {
|
|
2236
|
+
updateThread(thread.id, {
|
|
2237
|
+
lastError: `Setup exited ${setup.exitCode} (thread is still usable)`
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
} catch (err) {
|
|
2241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2242
|
+
updateThread(thread.id, {
|
|
2243
|
+
lastError: `Setup failed: ${message}`
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
return readThread(thread.id) ?? thread;
|
|
2247
|
+
}
|
|
2248
|
+
async function listLinearIssues(agent, repoPath) {
|
|
2249
|
+
const { getAdapter: getAdapter2 } = await import("./agents-OAX7XPKX.js");
|
|
2250
|
+
await requireAgent(agent, { requireLinear: true });
|
|
2251
|
+
const adapter = getAdapter2(agent);
|
|
2252
|
+
if (!adapter.listLinearIssues) {
|
|
2253
|
+
throw new Error(`${agent} does not support Linear issue listing`);
|
|
2254
|
+
}
|
|
2255
|
+
return adapter.listLinearIssues(repoPath);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// src/threads/chat-tabs.ts
|
|
2259
|
+
import { randomUUID } from "crypto";
|
|
2260
|
+
function sameWorktreePath(a, b) {
|
|
2261
|
+
return normalizeWorktreePath(a) === normalizeWorktreePath(b);
|
|
2262
|
+
}
|
|
2263
|
+
function takenTeamSlugsForChatTab(worktreePath) {
|
|
2264
|
+
const key = normalizeWorktreePath(worktreePath);
|
|
2265
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2266
|
+
const dir = worktreeNameFromPath(key);
|
|
2267
|
+
if (dir) taken.add(dir.toLowerCase());
|
|
2268
|
+
for (const sibling of threadsSharingWorktree(key)) {
|
|
2269
|
+
for (const slug of takenSlugsFromThread(sibling)) {
|
|
2270
|
+
taken.add(slug);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return [...taken];
|
|
2274
|
+
}
|
|
2275
|
+
function requireThread(idOrRef) {
|
|
2276
|
+
const thread = findThreadByRef(idOrRef) ?? null;
|
|
2277
|
+
if (!thread) throw new Error(`Thread not found: ${idOrRef}`);
|
|
2278
|
+
return thread;
|
|
2279
|
+
}
|
|
2280
|
+
function worktreeBindingFrom(from) {
|
|
2281
|
+
return {
|
|
2282
|
+
worktreePath: normalizeWorktreePath(from.worktreePath),
|
|
2283
|
+
repoPath: from.repoPath,
|
|
2284
|
+
branchName: from.branchName,
|
|
2285
|
+
sourceRef: from.sourceRef,
|
|
2286
|
+
// Legacy pinned-repo orchestration: new tabs become normal worktree agents.
|
|
2287
|
+
// Global: keep orchestration so chat tabs stay fleet orchestrators (MCP + Bash).
|
|
2288
|
+
sourceType: isGlobalThread(from) ? "orchestration" : from.sourceType === "orchestration" ? "branch" : from.sourceType,
|
|
2289
|
+
sourceIsFork: from.sourceIsFork,
|
|
2290
|
+
parentThreadId: from.parentThreadId,
|
|
2291
|
+
prUrl: from.prUrl,
|
|
2292
|
+
prTitle: from.prTitle
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
function threadsSharingWorktree(worktreePath) {
|
|
2296
|
+
const key = normalizeWorktreePath(worktreePath);
|
|
2297
|
+
return listThreads({ includeArchived: true }).filter((t) => sameWorktreePath(t.worktreePath, key) && t.status !== "archived").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
2298
|
+
}
|
|
2299
|
+
function formatTranscriptMarkdown(title, messages) {
|
|
2300
|
+
const body = formatMessagesAsTranscript(messages, { tools: "full" });
|
|
2301
|
+
return [`# Transcript: ${title}`, "", `Forked ${(/* @__PURE__ */ new Date()).toISOString()}`, "", body].join("\n");
|
|
2302
|
+
}
|
|
2303
|
+
function forkMessageSlice(from, throughIndex) {
|
|
2304
|
+
const through = throughIndex === void 0 ? from.messages.length - 1 : Math.min(throughIndex, from.messages.length - 1);
|
|
2305
|
+
return through >= 0 ? from.messages.slice(0, through + 1) : [];
|
|
2306
|
+
}
|
|
2307
|
+
function buildForkTranscriptAttachment(baseTitle, messages) {
|
|
2308
|
+
const title = baseTitle || "Chat";
|
|
2309
|
+
return {
|
|
2310
|
+
id: randomUUID(),
|
|
2311
|
+
name: `Transcript of ${title}.md`,
|
|
2312
|
+
kind: "transcript",
|
|
2313
|
+
content: formatTranscriptMarkdown(title, messages)
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
function createChatTab(input) {
|
|
2317
|
+
const from = requireThread(input.fromThreadId);
|
|
2318
|
+
const binding = worktreeBindingFrom(from);
|
|
2319
|
+
const explicitTitle = input.title?.trim();
|
|
2320
|
+
const title = explicitTitle || allocateTeamName(takenTeamSlugsForChatTab(binding.worktreePath)).name;
|
|
2321
|
+
const thread = createEmptyThread({
|
|
2322
|
+
title,
|
|
2323
|
+
// Chat-tab nicknames (soccer team or explicit) must stick. Post-turn
|
|
2324
|
+
// syncThreadBranchFromGit would otherwise rewrite every sibling to the
|
|
2325
|
+
// shared worktree folder name (e.g. fork "Arsenal" → "Monaco").
|
|
2326
|
+
userSetTitle: true,
|
|
2327
|
+
...binding,
|
|
2328
|
+
agent: input.agent ?? from.agent,
|
|
2329
|
+
model: input.agent && input.agent !== from.agent ? null : from.model,
|
|
2330
|
+
fast: from.fast,
|
|
2331
|
+
planMode: from.planMode,
|
|
2332
|
+
autonomy: from.autonomy,
|
|
2333
|
+
attachments: input.attachments ?? [],
|
|
2334
|
+
status: "idle"
|
|
2335
|
+
});
|
|
2336
|
+
writeThread(thread);
|
|
2337
|
+
return thread;
|
|
2338
|
+
}
|
|
2339
|
+
function forkChatTab(input) {
|
|
2340
|
+
const from = requireThread(input.threadId);
|
|
2341
|
+
const slice = forkMessageSlice(from, input.throughIndex);
|
|
2342
|
+
const attachment = buildForkTranscriptAttachment(from.title || "Chat", slice);
|
|
2343
|
+
return createChatTab({
|
|
2344
|
+
fromThreadId: input.threadId,
|
|
2345
|
+
agent: input.agent ?? from.agent,
|
|
2346
|
+
title: input.title?.trim() || void 0,
|
|
2347
|
+
attachments: [attachment]
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// src/threads/fork-worktree.ts
|
|
2352
|
+
function requireThread2(idOrRef) {
|
|
2353
|
+
const thread = findThreadByRef(idOrRef) ?? null;
|
|
2354
|
+
if (!thread) throw new Error(`Thread not found: ${idOrRef}`);
|
|
2355
|
+
return thread;
|
|
2356
|
+
}
|
|
2357
|
+
async function forkThreadWorktree(input, onSetupLine) {
|
|
2358
|
+
const from = requireThread2(input.threadId);
|
|
2359
|
+
const slice = forkMessageSlice(from, input.throughIndex);
|
|
2360
|
+
const attachment = buildForkTranscriptAttachment(from.title || "Chat", slice);
|
|
2361
|
+
const thread = await createThread(
|
|
2362
|
+
{
|
|
2363
|
+
sourceType: "branch",
|
|
2364
|
+
sourceRef: from.branchName,
|
|
2365
|
+
repoPath: from.repoPath,
|
|
2366
|
+
agent: input.agent ?? from.agent,
|
|
2367
|
+
autonomy: from.autonomy,
|
|
2368
|
+
title: input.title?.trim() || void 0,
|
|
2369
|
+
parentThreadId: from.id
|
|
2370
|
+
},
|
|
2371
|
+
onSetupLine
|
|
2372
|
+
);
|
|
2373
|
+
return updateThread(thread.id, { attachments: [attachment] });
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// src/threads/adopt.ts
|
|
2377
|
+
import { execFileSync } from "child_process";
|
|
2378
|
+
import {
|
|
2379
|
+
copyFileSync as copyFileSync2,
|
|
2380
|
+
existsSync as existsSync7,
|
|
2381
|
+
mkdtempSync,
|
|
2382
|
+
readdirSync as readdirSync3,
|
|
2383
|
+
readFileSync as readFileSync6,
|
|
2384
|
+
rmSync
|
|
2385
|
+
} from "fs";
|
|
2386
|
+
import { tmpdir } from "os";
|
|
2387
|
+
import { join as join6 } from "path";
|
|
2388
|
+
import Database from "better-sqlite3";
|
|
2389
|
+
var CONDUCTOR_APP_SUPPORT = join6(
|
|
2390
|
+
process.env.HOME ?? "",
|
|
2391
|
+
"Library",
|
|
2392
|
+
"Application Support",
|
|
2393
|
+
"com.conductor.app"
|
|
2394
|
+
);
|
|
2395
|
+
var CONDUCTOR_DB = join6(CONDUCTOR_APP_SUPPORT, "conductor.db");
|
|
2396
|
+
var CURSOR_SDK_STORE = join6(CONDUCTOR_APP_SUPPORT, "cursor-sdk-store");
|
|
2397
|
+
function mapAgentType(raw) {
|
|
2398
|
+
if (!raw) return null;
|
|
2399
|
+
const v = raw.toLowerCase();
|
|
2400
|
+
if (v.includes("cursor")) return "cursor";
|
|
2401
|
+
if (v.includes("claude")) return "claude";
|
|
2402
|
+
if (v.includes("codex")) return "codex";
|
|
2403
|
+
if (v.includes("opencode") || v.includes("open-code")) return "opencode";
|
|
2404
|
+
if (v.includes("brightsy")) return "brightsy";
|
|
2405
|
+
return null;
|
|
2406
|
+
}
|
|
2407
|
+
function resolveConductorCursorAgentId(workspacePath) {
|
|
2408
|
+
if (!workspacePath || !existsSync7(CURSOR_SDK_STORE)) return null;
|
|
2409
|
+
const normalized = workspacePath.replace(/\/$/, "");
|
|
2410
|
+
let best = null;
|
|
2411
|
+
let hashes;
|
|
2412
|
+
try {
|
|
2413
|
+
hashes = readdirSync3(CURSOR_SDK_STORE);
|
|
2414
|
+
} catch {
|
|
2415
|
+
return null;
|
|
2416
|
+
}
|
|
2417
|
+
for (const hash of hashes) {
|
|
2418
|
+
const agentsFile = join6(CURSOR_SDK_STORE, hash, "agents.ndjson");
|
|
2419
|
+
if (!existsSync7(agentsFile)) continue;
|
|
2420
|
+
let text;
|
|
2421
|
+
try {
|
|
2422
|
+
text = readFileSync6(agentsFile, "utf8");
|
|
2423
|
+
} catch {
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
for (const line of text.split("\n")) {
|
|
2427
|
+
const trimmed = line.trim();
|
|
2428
|
+
if (!trimmed) continue;
|
|
2429
|
+
try {
|
|
2430
|
+
const row = JSON.parse(trimmed);
|
|
2431
|
+
if (!row.agentId || !row.cwd) continue;
|
|
2432
|
+
if (row.agentId.startsWith("conductor-one-shot-")) continue;
|
|
2433
|
+
const cwd = String(row.cwd).replace(/\/$/, "");
|
|
2434
|
+
if (cwd !== normalized) continue;
|
|
2435
|
+
const updatedAt = Number(row.updatedAt ?? 0);
|
|
2436
|
+
if (!best || updatedAt >= best.updatedAt) {
|
|
2437
|
+
best = { agentId: row.agentId, updatedAt };
|
|
2438
|
+
}
|
|
2439
|
+
} catch {
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
return best?.agentId ?? null;
|
|
2444
|
+
}
|
|
2445
|
+
async function adoptThread(input) {
|
|
2446
|
+
if (!existsSync7(input.worktreePath)) {
|
|
2447
|
+
throw new Error(`Worktree not found: ${input.worktreePath}`);
|
|
2448
|
+
}
|
|
2449
|
+
const repoPath = await resolveRepoRoot(input.worktreePath);
|
|
2450
|
+
const { stdout: branch } = await import("./run-LF6E5IKL.js").then(
|
|
2451
|
+
({ git: git2 }) => git2(["rev-parse", "--abbrev-ref", "HEAD"], input.worktreePath)
|
|
2452
|
+
);
|
|
2453
|
+
const thread = createEmptyThread({
|
|
2454
|
+
title: input.title ?? `Adopted ${branch.trim()}`,
|
|
2455
|
+
sourceType: "adopt",
|
|
2456
|
+
sourceRef: input.sourceRef ?? input.worktreePath,
|
|
2457
|
+
branchName: branch.trim(),
|
|
2458
|
+
worktreePath: input.worktreePath,
|
|
2459
|
+
repoPath,
|
|
2460
|
+
agent: input.agent,
|
|
2461
|
+
sessionId: input.sessionId ?? null,
|
|
2462
|
+
messages: input.messages ?? []
|
|
2463
|
+
});
|
|
2464
|
+
writeThread(thread);
|
|
2465
|
+
const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-TCJFYI35.js");
|
|
2466
|
+
await ensureWorkspace2(repoPath);
|
|
2467
|
+
return thread;
|
|
2468
|
+
}
|
|
2469
|
+
function conductorDbPath() {
|
|
2470
|
+
return CONDUCTOR_DB;
|
|
2471
|
+
}
|
|
2472
|
+
function listConductorWorkspaces() {
|
|
2473
|
+
if (!existsSync7(CONDUCTOR_DB)) {
|
|
2474
|
+
throw new Error(`Conductor DB not found at ${CONDUCTOR_DB}`);
|
|
2475
|
+
}
|
|
2476
|
+
const tmp = mkdtempSync(join6(tmpdir(), "sideboard-conductor-"));
|
|
2477
|
+
const snapshot = join6(tmp, "conductor.db");
|
|
2478
|
+
try {
|
|
2479
|
+
copyFileSync2(CONDUCTOR_DB, snapshot);
|
|
2480
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
2481
|
+
const src = `${CONDUCTOR_DB}${suffix}`;
|
|
2482
|
+
if (existsSync7(src)) {
|
|
2483
|
+
try {
|
|
2484
|
+
copyFileSync2(src, `${snapshot}${suffix}`);
|
|
2485
|
+
} catch {
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
const db = new Database(snapshot, { readonly: true, fileMustExist: true });
|
|
2490
|
+
try {
|
|
2491
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all();
|
|
2492
|
+
const names = new Set(tables.map((t) => t.name));
|
|
2493
|
+
if (!names.has("workspaces")) {
|
|
2494
|
+
throw new Error("Conductor schema missing workspaces table");
|
|
2495
|
+
}
|
|
2496
|
+
const rows = db.prepare(
|
|
2497
|
+
`SELECT
|
|
2498
|
+
w.id as id,
|
|
2499
|
+
w.workspace_path as workspacePath,
|
|
2500
|
+
w.branch as branch,
|
|
2501
|
+
COALESCE(w.workspace_name, w.directory_name, w.branch) as workspaceName,
|
|
2502
|
+
w.pr_title as prTitle,
|
|
2503
|
+
w.pr_description as prDescription,
|
|
2504
|
+
w.intended_target_branch as intendedTargetBranch,
|
|
2505
|
+
w.notes as notes,
|
|
2506
|
+
w.active_session_id as activeSessionId
|
|
2507
|
+
FROM workspaces w
|
|
2508
|
+
WHERE w.state IS NULL OR w.state != 'archived'
|
|
2509
|
+
ORDER BY w.updated_at DESC`
|
|
2510
|
+
).all();
|
|
2511
|
+
return rows.map((row) => {
|
|
2512
|
+
let claudeSessionId = null;
|
|
2513
|
+
let agentType = null;
|
|
2514
|
+
let messageCount = 0;
|
|
2515
|
+
const activeSessionId = row.activeSessionId;
|
|
2516
|
+
if (names.has("sessions") && activeSessionId) {
|
|
2517
|
+
try {
|
|
2518
|
+
const session = db.prepare(
|
|
2519
|
+
`SELECT claude_session_id as claudeSessionId, agent_type as agentType
|
|
2520
|
+
FROM sessions WHERE id = ?`
|
|
2521
|
+
).get(activeSessionId);
|
|
2522
|
+
claudeSessionId = session?.claudeSessionId ?? null;
|
|
2523
|
+
agentType = mapAgentType(session?.agentType) ?? "claude";
|
|
2524
|
+
} catch {
|
|
2525
|
+
agentType = "claude";
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
if (names.has("session_messages") && activeSessionId) {
|
|
2529
|
+
try {
|
|
2530
|
+
const count = db.prepare(
|
|
2531
|
+
`SELECT COUNT(*) as c FROM session_messages WHERE session_id = ?`
|
|
2532
|
+
).get(activeSessionId);
|
|
2533
|
+
messageCount = count.c;
|
|
2534
|
+
} catch {
|
|
2535
|
+
messageCount = 0;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
const workspacePath = String(row.workspacePath ?? "");
|
|
2539
|
+
if (agentType === "cursor" && !claudeSessionId) {
|
|
2540
|
+
claudeSessionId = resolveConductorCursorAgentId(workspacePath);
|
|
2541
|
+
}
|
|
2542
|
+
return {
|
|
2543
|
+
id: String(row.id),
|
|
2544
|
+
workspacePath,
|
|
2545
|
+
branch: String(row.branch ?? ""),
|
|
2546
|
+
workspaceName: String(row.workspaceName ?? row.branch ?? "workspace"),
|
|
2547
|
+
prTitle: row.prTitle ?? null,
|
|
2548
|
+
prDescription: row.prDescription ?? null,
|
|
2549
|
+
intendedTargetBranch: row.intendedTargetBranch ?? null,
|
|
2550
|
+
notes: row.notes ?? null,
|
|
2551
|
+
claudeSessionId,
|
|
2552
|
+
agentType,
|
|
2553
|
+
messageCount
|
|
2554
|
+
};
|
|
2555
|
+
});
|
|
2556
|
+
} finally {
|
|
2557
|
+
db.close();
|
|
2558
|
+
}
|
|
2559
|
+
} finally {
|
|
2560
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
function importConductorWorkspace(workspaceId) {
|
|
2564
|
+
if (!existsSync7(CONDUCTOR_DB)) {
|
|
2565
|
+
throw new Error(`Conductor DB not found at ${CONDUCTOR_DB}`);
|
|
2566
|
+
}
|
|
2567
|
+
const tmp = mkdtempSync(join6(tmpdir(), "sideboard-conductor-"));
|
|
2568
|
+
const snapshot = join6(tmp, "conductor.db");
|
|
2569
|
+
try {
|
|
2570
|
+
copyFileSync2(CONDUCTOR_DB, snapshot);
|
|
2571
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
2572
|
+
const src = `${CONDUCTOR_DB}${suffix}`;
|
|
2573
|
+
if (existsSync7(src)) {
|
|
2574
|
+
try {
|
|
2575
|
+
copyFileSync2(src, `${snapshot}${suffix}`);
|
|
2576
|
+
} catch {
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
const db = new Database(snapshot, { readonly: true, fileMustExist: true });
|
|
2581
|
+
try {
|
|
2582
|
+
const row = db.prepare(
|
|
2583
|
+
`SELECT
|
|
2584
|
+
w.id as id,
|
|
2585
|
+
w.workspace_path as workspacePath,
|
|
2586
|
+
w.branch as branch,
|
|
2587
|
+
COALESCE(w.workspace_name, w.directory_name, w.branch) as workspaceName,
|
|
2588
|
+
w.active_session_id as activeSessionId
|
|
2589
|
+
FROM workspaces w WHERE w.id = ?`
|
|
2590
|
+
).get(workspaceId);
|
|
2591
|
+
if (!row) throw new Error(`Conductor workspace not found: ${workspaceId}`);
|
|
2592
|
+
const worktreePath = String(row.workspacePath);
|
|
2593
|
+
if (!existsSync7(worktreePath)) {
|
|
2594
|
+
throw new Error(`Conductor worktree missing on disk: ${worktreePath}`);
|
|
2595
|
+
}
|
|
2596
|
+
let sessionId = null;
|
|
2597
|
+
let agent = "claude";
|
|
2598
|
+
const messages = [];
|
|
2599
|
+
const activeSessionId = row.activeSessionId;
|
|
2600
|
+
if (activeSessionId) {
|
|
2601
|
+
try {
|
|
2602
|
+
const session = db.prepare(
|
|
2603
|
+
`SELECT claude_session_id as claudeSessionId, agent_type as agentType
|
|
2604
|
+
FROM sessions WHERE id = ?`
|
|
2605
|
+
).get(activeSessionId);
|
|
2606
|
+
sessionId = session?.claudeSessionId ?? null;
|
|
2607
|
+
agent = mapAgentType(session?.agentType) ?? "claude";
|
|
2608
|
+
} catch {
|
|
2609
|
+
}
|
|
2610
|
+
try {
|
|
2611
|
+
const rows = db.prepare(
|
|
2612
|
+
`SELECT role, content, COALESCE(sent_at, created_at) as ts
|
|
2613
|
+
FROM session_messages
|
|
2614
|
+
WHERE session_id = ?
|
|
2615
|
+
ORDER BY COALESCE(sent_at, created_at) ASC`
|
|
2616
|
+
).all(activeSessionId);
|
|
2617
|
+
for (const m of rows) {
|
|
2618
|
+
const role = m.role === "assistant" || m.role === "agent" ? "agent" : "user";
|
|
2619
|
+
messages.push({
|
|
2620
|
+
role,
|
|
2621
|
+
text: m.content ?? "",
|
|
2622
|
+
ts: m.ts ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
} catch {
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
if (agent === "cursor" && !sessionId) {
|
|
2629
|
+
sessionId = resolveConductorCursorAgentId(worktreePath);
|
|
2630
|
+
}
|
|
2631
|
+
let repoPath = worktreePath;
|
|
2632
|
+
try {
|
|
2633
|
+
repoPath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
2634
|
+
cwd: worktreePath,
|
|
2635
|
+
encoding: "utf8"
|
|
2636
|
+
}).trim();
|
|
2637
|
+
} catch {
|
|
2638
|
+
}
|
|
2639
|
+
const thread = createEmptyThread({
|
|
2640
|
+
title: String(row.workspaceName ?? row.branch),
|
|
2641
|
+
sourceType: "adopt",
|
|
2642
|
+
sourceRef: `conductor:${workspaceId}`,
|
|
2643
|
+
branchName: String(row.branch ?? ""),
|
|
2644
|
+
worktreePath,
|
|
2645
|
+
repoPath,
|
|
2646
|
+
agent,
|
|
2647
|
+
sessionId,
|
|
2648
|
+
messages
|
|
2649
|
+
});
|
|
2650
|
+
writeThread(thread);
|
|
2651
|
+
return thread;
|
|
2652
|
+
} finally {
|
|
2653
|
+
db.close();
|
|
2654
|
+
}
|
|
2655
|
+
} finally {
|
|
2656
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
async function importConductorWorkspaceAsync(workspaceId) {
|
|
2660
|
+
return importConductorWorkspace(workspaceId);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// src/git/orphan-cleanup.ts
|
|
2664
|
+
import { existsSync as existsSync8, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
|
|
2665
|
+
import { join as join7 } from "path";
|
|
2666
|
+
function isSideboardWorktreePath(path) {
|
|
2667
|
+
return path.includes("/.sideboard/worktrees/") || path.includes("/sideboard/workspaces/");
|
|
2668
|
+
}
|
|
2669
|
+
async function findOrphanWorktrees(repoPaths) {
|
|
2670
|
+
const threads = listThreads({ includeArchived: true });
|
|
2671
|
+
const known = new Set(threads.map((t) => t.worktreePath.replace(/\/$/, "")));
|
|
2672
|
+
const repos = new Set(
|
|
2673
|
+
repoPaths?.length ? repoPaths : threads.map((t) => t.repoPath).filter(Boolean)
|
|
2674
|
+
);
|
|
2675
|
+
const homeRoot = sideboardWorkspacesDir();
|
|
2676
|
+
if (existsSync8(homeRoot)) {
|
|
2677
|
+
try {
|
|
2678
|
+
for (const entry of readdirSync4(homeRoot, { withFileTypes: true })) {
|
|
2679
|
+
if (!entry.isDirectory()) continue;
|
|
2680
|
+
void entry;
|
|
2681
|
+
}
|
|
2682
|
+
} catch {
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
const orphans = [];
|
|
2686
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2687
|
+
for (const repoPath of repos) {
|
|
2688
|
+
if (!repoPath || !existsSync8(repoPath)) continue;
|
|
2689
|
+
try {
|
|
2690
|
+
const wts = await listWorktrees(repoPath);
|
|
2691
|
+
for (const wt of wts) {
|
|
2692
|
+
const path = wt.path.replace(/\/$/, "");
|
|
2693
|
+
if (!isSideboardWorktreePath(path)) continue;
|
|
2694
|
+
if (known.has(path)) continue;
|
|
2695
|
+
if (seen.has(path)) continue;
|
|
2696
|
+
seen.add(path);
|
|
2697
|
+
let mtimeMs = 0;
|
|
2698
|
+
try {
|
|
2699
|
+
mtimeMs = statSync4(path).mtimeMs;
|
|
2700
|
+
} catch {
|
|
2701
|
+
mtimeMs = 0;
|
|
2702
|
+
}
|
|
2703
|
+
orphans.push({ path, repoPath, mtimeMs });
|
|
2704
|
+
}
|
|
2705
|
+
} catch {
|
|
2706
|
+
}
|
|
2707
|
+
try {
|
|
2708
|
+
const root = worktreesRoot(repoPath);
|
|
2709
|
+
if (existsSync8(root)) {
|
|
2710
|
+
for (const entry of readdirSync4(root, { withFileTypes: true })) {
|
|
2711
|
+
if (!entry.isDirectory()) continue;
|
|
2712
|
+
const path = join7(root, entry.name).replace(/\/$/, "");
|
|
2713
|
+
if (known.has(path) || seen.has(path)) continue;
|
|
2714
|
+
if (!existsSync8(join7(path, ".git"))) continue;
|
|
2715
|
+
seen.add(path);
|
|
2716
|
+
let mtimeMs = 0;
|
|
2717
|
+
try {
|
|
2718
|
+
mtimeMs = statSync4(path).mtimeMs;
|
|
2719
|
+
} catch {
|
|
2720
|
+
mtimeMs = Date.now();
|
|
2721
|
+
}
|
|
2722
|
+
orphans.push({ path, repoPath, mtimeMs });
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
} catch {
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
return orphans.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2729
|
+
}
|
|
2730
|
+
async function cleanupOrphanWorktrees(opts) {
|
|
2731
|
+
const settings = loadAppSettings();
|
|
2732
|
+
const maxCount = opts?.maxCount ?? settings.advanced.worktreeMaxCount ?? 25;
|
|
2733
|
+
const orphans = await findOrphanWorktrees(opts?.repoPaths);
|
|
2734
|
+
const activeCount = listThreads().filter((t) => t.status !== "archived").length;
|
|
2735
|
+
const budget = Math.max(0, maxCount - activeCount);
|
|
2736
|
+
const sortedNewestFirst = [...orphans].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2737
|
+
const keep = new Set(sortedNewestFirst.slice(0, budget).map((o) => o.path));
|
|
2738
|
+
const removed = [];
|
|
2739
|
+
const kept = [];
|
|
2740
|
+
for (const orphan of orphans) {
|
|
2741
|
+
if (keep.has(orphan.path)) {
|
|
2742
|
+
kept.push(orphan.path);
|
|
2743
|
+
continue;
|
|
2744
|
+
}
|
|
2745
|
+
if (opts?.dryRun) {
|
|
2746
|
+
removed.push(orphan.path);
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
try {
|
|
2750
|
+
await removeWorktree(orphan.repoPath, orphan.path);
|
|
2751
|
+
removed.push(orphan.path);
|
|
2752
|
+
} catch {
|
|
2753
|
+
kept.push(orphan.path);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
if (!opts?.dryRun) {
|
|
2757
|
+
updateAdvancedSettings({
|
|
2758
|
+
worktreeLastCleanupAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2759
|
+
});
|
|
2760
|
+
}
|
|
2761
|
+
return { removed, kept, orphans };
|
|
2762
|
+
}
|
|
2763
|
+
function shouldRunWorktreeCleanup(settings = loadAppSettings()) {
|
|
2764
|
+
const intervalHours = settings.advanced.worktreeCleanupIntervalHours ?? 6;
|
|
2765
|
+
const last = settings.advanced.worktreeLastCleanupAt;
|
|
2766
|
+
if (!last) return true;
|
|
2767
|
+
const elapsed = Date.now() - Date.parse(last);
|
|
2768
|
+
return elapsed >= intervalHours * 36e5;
|
|
2769
|
+
}
|
|
2770
|
+
function worktreeCleanupSettings() {
|
|
2771
|
+
const a = loadAppSettings().advanced;
|
|
2772
|
+
return {
|
|
2773
|
+
worktreeMaxCount: a.worktreeMaxCount,
|
|
2774
|
+
worktreeCleanupIntervalHours: a.worktreeCleanupIntervalHours,
|
|
2775
|
+
worktreeLastCleanupAt: a.worktreeLastCleanupAt,
|
|
2776
|
+
autoCleanupOrphans: a.autoCleanupOrphans
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// src/git/apply-into-main.ts
|
|
2781
|
+
import { execa as execa4 } from "execa";
|
|
2782
|
+
async function applyThreadIntoMain(thread, opts) {
|
|
2783
|
+
const method = opts?.method ?? "merge";
|
|
2784
|
+
const target = opts?.targetBranch?.trim() || await resolveDefaultBranch(thread.repoPath);
|
|
2785
|
+
const status = await execa4("git", ["status", "--porcelain"], {
|
|
2786
|
+
cwd: thread.repoPath,
|
|
2787
|
+
reject: false
|
|
2788
|
+
});
|
|
2789
|
+
if (status.stdout.trim()) {
|
|
2790
|
+
throw new Error(
|
|
2791
|
+
`Main checkout is dirty \u2014 commit or stash before apply-into-main (${thread.repoPath})`
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
const checkout = await execa4("git", ["checkout", target], {
|
|
2795
|
+
cwd: thread.repoPath,
|
|
2796
|
+
reject: false
|
|
2797
|
+
});
|
|
2798
|
+
if (checkout.exitCode !== 0) {
|
|
2799
|
+
throw new Error(
|
|
2800
|
+
`Failed to checkout ${target}: ${checkout.stderr.trim() || checkout.stdout.trim()}`
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
if (method === "cherry-pick") {
|
|
2804
|
+
const log = await execa4(
|
|
2805
|
+
"git",
|
|
2806
|
+
["log", "--reverse", "--format=%H", `${target}..${thread.branchName}`],
|
|
2807
|
+
{ cwd: thread.repoPath, reject: false }
|
|
2808
|
+
);
|
|
2809
|
+
const shas = log.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2810
|
+
if (!shas.length) {
|
|
2811
|
+
return {
|
|
2812
|
+
applied: false,
|
|
2813
|
+
method,
|
|
2814
|
+
targetBranch: target,
|
|
2815
|
+
message: `No commits on ${thread.branchName} not already in ${target}`
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
for (const sha of shas) {
|
|
2819
|
+
const cp = await execa4("git", ["cherry-pick", sha], {
|
|
2820
|
+
cwd: thread.repoPath,
|
|
2821
|
+
reject: false
|
|
2822
|
+
});
|
|
2823
|
+
if (cp.exitCode !== 0) {
|
|
2824
|
+
await execa4("git", ["cherry-pick", "--abort"], {
|
|
2825
|
+
cwd: thread.repoPath,
|
|
2826
|
+
reject: false
|
|
2827
|
+
});
|
|
2828
|
+
throw new Error(
|
|
2829
|
+
`Cherry-pick ${sha.slice(0, 8)} failed: ${cp.stderr.trim() || cp.stdout.trim()}`
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
return {
|
|
2834
|
+
applied: true,
|
|
2835
|
+
method,
|
|
2836
|
+
targetBranch: target,
|
|
2837
|
+
message: `Cherry-picked ${shas.length} commit(s) from ${thread.branchName} into ${target}`
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
const merge = await execa4(
|
|
2841
|
+
"git",
|
|
2842
|
+
["merge", "--no-ff", "-m", `Apply Sideboard thread ${thread.branchName}`, thread.branchName],
|
|
2843
|
+
{ cwd: thread.repoPath, reject: false }
|
|
2844
|
+
);
|
|
2845
|
+
if (merge.exitCode !== 0) {
|
|
2846
|
+
await execa4("git", ["merge", "--abort"], {
|
|
2847
|
+
cwd: thread.repoPath,
|
|
2848
|
+
reject: false
|
|
2849
|
+
});
|
|
2850
|
+
throw new Error(
|
|
2851
|
+
`Merge failed: ${merge.stderr.trim() || merge.stdout.trim()}`
|
|
2852
|
+
);
|
|
2853
|
+
}
|
|
2854
|
+
return {
|
|
2855
|
+
applied: true,
|
|
2856
|
+
method: "merge",
|
|
2857
|
+
targetBranch: target,
|
|
2858
|
+
message: `Merged ${thread.branchName} into ${target} at ${thread.repoPath}`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// src/git/clone-repo.ts
|
|
2863
|
+
import { existsSync as existsSync9 } from "fs";
|
|
2864
|
+
import { basename as basename2, join as join8 } from "path";
|
|
2865
|
+
import { execa as execa5 } from "execa";
|
|
2866
|
+
async function cloneRepoIntoSideboard(opts) {
|
|
2867
|
+
const url = opts.url.trim();
|
|
2868
|
+
if (!url) throw new Error("Clone URL is required");
|
|
2869
|
+
let name = opts.name?.trim();
|
|
2870
|
+
if (!name) {
|
|
2871
|
+
const leaf = basename2(url.replace(/\/$/, "").replace(/\.git$/, ""));
|
|
2872
|
+
name = leaf || "repo";
|
|
2873
|
+
}
|
|
2874
|
+
name = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
|
|
2875
|
+
const dest = join8(sideboardReposDir(), name);
|
|
2876
|
+
if (existsSync9(dest)) {
|
|
2877
|
+
const repoPath2 = await resolveRepoRoot(dest);
|
|
2878
|
+
const workspace2 = await ensureWorkspace(repoPath2);
|
|
2879
|
+
return { repoPath: repoPath2, workspace: workspace2 };
|
|
2880
|
+
}
|
|
2881
|
+
const result = await execa5("git", ["clone", url, dest], { reject: false });
|
|
2882
|
+
if (result.exitCode !== 0) {
|
|
2883
|
+
throw new Error(
|
|
2884
|
+
`git clone failed: ${result.stderr?.trim() || result.stdout?.trim() || `exit ${result.exitCode}`}`
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
const repoPath = await resolveRepoRoot(dest);
|
|
2888
|
+
const workspace = await ensureWorkspace(repoPath);
|
|
2889
|
+
return { repoPath, workspace };
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// src/orchestrator/orchestrator.ts
|
|
2893
|
+
import { EventEmitter } from "events";
|
|
2894
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2895
|
+
|
|
2896
|
+
// src/threads/sync-branch.ts
|
|
2897
|
+
async function syncThreadBranchFromGit(threadId) {
|
|
2898
|
+
const thread = readThread(threadId);
|
|
2899
|
+
if (!thread?.worktreePath?.trim()) return;
|
|
2900
|
+
const { stdout, exitCode } = await git(
|
|
2901
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
2902
|
+
thread.worktreePath,
|
|
2903
|
+
{ reject: false }
|
|
2904
|
+
);
|
|
2905
|
+
if (exitCode !== 0) return;
|
|
2906
|
+
const branch = stdout.trim();
|
|
2907
|
+
if (!branch || branch === "HEAD") return;
|
|
2908
|
+
const siblings = threadsSharingWorktree(thread.worktreePath);
|
|
2909
|
+
const canonicalId = siblings[0]?.id ?? null;
|
|
2910
|
+
for (const sibling of siblings) {
|
|
2911
|
+
const patch = {};
|
|
2912
|
+
if (sibling.branchName !== branch) patch.branchName = branch;
|
|
2913
|
+
if (sibling.id === canonicalId && !sibling.userSetTitle) {
|
|
2914
|
+
const nextTitle = threadDisplayLabel({
|
|
2915
|
+
...sibling,
|
|
2916
|
+
branchName: branch
|
|
2917
|
+
});
|
|
2918
|
+
if (sibling.title !== nextTitle) patch.title = nextTitle;
|
|
2919
|
+
}
|
|
2920
|
+
if (Object.keys(patch).length > 0) {
|
|
2921
|
+
updateThread(sibling.id, patch);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// src/orchestrator/orchestrator.ts
|
|
2927
|
+
var Orchestrator = class {
|
|
2928
|
+
events = new EventEmitter();
|
|
2929
|
+
processes = /* @__PURE__ */ new Map();
|
|
2930
|
+
activeTurns = /* @__PURE__ */ new Map();
|
|
2931
|
+
draining = /* @__PURE__ */ new Set();
|
|
2932
|
+
/** Threads past setStatus(running) but not yet in activeTurns (spawn in flight). */
|
|
2933
|
+
startingTurns = /* @__PURE__ */ new Set();
|
|
2934
|
+
/**
|
|
2935
|
+
* Threads intentionally force-stopped. Prevents runTurn from re-asserting
|
|
2936
|
+
* `running` after spawn, and from overwriting `stopped` with idle/error when
|
|
2937
|
+
* the killed turn's handle.done resolves.
|
|
2938
|
+
*/
|
|
2939
|
+
stoppedTurns = /* @__PURE__ */ new Set();
|
|
2940
|
+
/** WIP snapshot SHA at the start of the latest agent turn (per thread). */
|
|
2941
|
+
turnBaselines = /* @__PURE__ */ new Map();
|
|
2942
|
+
maxConcurrent;
|
|
2943
|
+
runningCount = 0;
|
|
2944
|
+
constructor(opts) {
|
|
2945
|
+
this.maxConcurrent = opts?.maxConcurrent ?? 3;
|
|
2946
|
+
}
|
|
2947
|
+
on(listener) {
|
|
2948
|
+
this.events.on("event", listener);
|
|
2949
|
+
return () => this.events.off("event", listener);
|
|
2950
|
+
}
|
|
2951
|
+
emit(event) {
|
|
2952
|
+
this.events.emit("event", event);
|
|
2953
|
+
}
|
|
2954
|
+
/** True when disk says running but this process is not actually turning. */
|
|
2955
|
+
isStaleRunningThread(threadId, status) {
|
|
2956
|
+
return status === "running" && !this.activeTurns.has(threadId) && !this.startingTurns.has(threadId);
|
|
2957
|
+
}
|
|
2958
|
+
async reconcile(repoPath, opts) {
|
|
2959
|
+
const reclaimStaleTurns = opts?.reclaimStaleTurns !== false;
|
|
2960
|
+
healOrchestrationSoccerTitles();
|
|
2961
|
+
for (const thread of listThreads({ includeArchived: true })) {
|
|
2962
|
+
if (thread.status === "archived") continue;
|
|
2963
|
+
if (isGlobalThread(thread)) {
|
|
2964
|
+
ensureGlobalCoordinatorCwd();
|
|
2965
|
+
const heal = {};
|
|
2966
|
+
if (thread.sourceType !== "orchestration") {
|
|
2967
|
+
heal.sourceType = "orchestration";
|
|
2968
|
+
}
|
|
2969
|
+
if (thread.sessionId && orchestratorSessionPoisonedByBuiltins(thread)) {
|
|
2970
|
+
heal.sessionId = null;
|
|
2971
|
+
}
|
|
2972
|
+
if (Object.keys(heal).length) {
|
|
2973
|
+
updateThread(thread.id, heal);
|
|
2974
|
+
}
|
|
2975
|
+
if (reclaimStaleTurns && this.isStaleRunningThread(thread.id, thread.status)) {
|
|
2976
|
+
setStatus(thread.id, "stopped", "Process died (reconciled on startup)");
|
|
2977
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: "stopped" });
|
|
2978
|
+
}
|
|
2979
|
+
continue;
|
|
2980
|
+
}
|
|
2981
|
+
if (!existsSync10(thread.worktreePath)) {
|
|
2982
|
+
setStatus(thread.id, "broken", "Worktree missing on disk");
|
|
2983
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: "broken" });
|
|
2984
|
+
continue;
|
|
2985
|
+
}
|
|
2986
|
+
if (reclaimStaleTurns && this.isStaleRunningThread(thread.id, thread.status)) {
|
|
2987
|
+
setStatus(thread.id, "stopped", "Process died (reconciled on startup)");
|
|
2988
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: "stopped" });
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
const repoPaths = (repoPath ? [repoPath] : [...new Set(listThreads({ includeArchived: true }).map((t) => t.repoPath))]).filter((p) => !isGlobalRepoPath(p));
|
|
2992
|
+
try {
|
|
2993
|
+
const orphans = await findOrphanWorktrees(repoPaths);
|
|
2994
|
+
if (orphans.length) {
|
|
2995
|
+
this.emit({
|
|
2996
|
+
type: "orphan_worktrees",
|
|
2997
|
+
orphans: orphans.map((o) => ({ path: o.path, repoPath: o.repoPath }))
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
const { autoCleanupOrphansEnabled } = await import("./app-settings-BDMLWCWI.js");
|
|
3001
|
+
if (autoCleanupOrphansEnabled() && shouldRunWorktreeCleanup() && orphans.length > 0) {
|
|
3002
|
+
await cleanupOrphanWorktrees({ repoPaths });
|
|
3003
|
+
}
|
|
3004
|
+
} catch {
|
|
3005
|
+
}
|
|
3006
|
+
for (const thread of listThreads()) {
|
|
3007
|
+
if (thread.queue.length > 0) {
|
|
3008
|
+
void this.drainQueue(thread.id);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
getThreads(includeArchived = false) {
|
|
3013
|
+
return listThreads({ includeArchived });
|
|
3014
|
+
}
|
|
3015
|
+
getThread(idOrRef) {
|
|
3016
|
+
return findThreadByRef(idOrRef) ?? readThread(idOrRef);
|
|
3017
|
+
}
|
|
3018
|
+
async createThread(input) {
|
|
3019
|
+
let thread = await createThread(input, (line) => {
|
|
3020
|
+
this.emit({
|
|
3021
|
+
type: "turn_output",
|
|
3022
|
+
threadId: "pending",
|
|
3023
|
+
event: { type: "stdout", data: line }
|
|
3024
|
+
});
|
|
3025
|
+
});
|
|
3026
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: thread.status });
|
|
3027
|
+
const { autoRunAfterSetupEnabled } = await import("./app-settings-BDMLWCWI.js");
|
|
3028
|
+
if (autoRunAfterSetupEnabled()) {
|
|
3029
|
+
try {
|
|
3030
|
+
await this.startDev(thread.id);
|
|
3031
|
+
} catch {
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
const prompt = input.prompt?.trim();
|
|
3035
|
+
if (prompt) {
|
|
3036
|
+
thread = await this.send(thread.id, prompt);
|
|
3037
|
+
}
|
|
3038
|
+
return thread;
|
|
3039
|
+
}
|
|
3040
|
+
listWorkspaces() {
|
|
3041
|
+
const fromThreads = listThreads({ includeArchived: true }).map((t) => t.repoPath);
|
|
3042
|
+
return syncWorkspacesFromThreads(fromThreads);
|
|
3043
|
+
}
|
|
3044
|
+
async addWorkspace(repoPath) {
|
|
3045
|
+
return addWorkspace(repoPath);
|
|
3046
|
+
}
|
|
3047
|
+
removeWorkspace(repoPath) {
|
|
3048
|
+
removeWorkspace(repoPath);
|
|
3049
|
+
}
|
|
3050
|
+
async adopt(input) {
|
|
3051
|
+
const thread = await adoptThread(input);
|
|
3052
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: thread.status });
|
|
3053
|
+
return thread;
|
|
3054
|
+
}
|
|
3055
|
+
listConductor() {
|
|
3056
|
+
return listConductorWorkspaces();
|
|
3057
|
+
}
|
|
3058
|
+
async adoptFromConductor(workspaceId) {
|
|
3059
|
+
const thread = await importConductorWorkspaceAsync(workspaceId);
|
|
3060
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: thread.status });
|
|
3061
|
+
return thread;
|
|
3062
|
+
}
|
|
3063
|
+
async send(threadRef, prompt) {
|
|
3064
|
+
const thread = this.requireThread(threadRef);
|
|
3065
|
+
return withThreadLock(thread.id, async () => {
|
|
3066
|
+
const current = this.requireThread(thread.id);
|
|
3067
|
+
const queue = [...current.queue, prompt];
|
|
3068
|
+
updateThread(thread.id, { queue, status: "queued" });
|
|
3069
|
+
this.emit({ type: "queue_changed", threadId: thread.id, queue });
|
|
3070
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: "queued" });
|
|
3071
|
+
void this.drainQueue(thread.id);
|
|
3072
|
+
return this.requireThread(thread.id);
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
async fanOut(threadRefs, prompt) {
|
|
3076
|
+
const results = [];
|
|
3077
|
+
for (const ref of threadRefs) {
|
|
3078
|
+
results.push(await this.send(ref, prompt));
|
|
3079
|
+
}
|
|
3080
|
+
return results;
|
|
3081
|
+
}
|
|
3082
|
+
async drainQueue(threadId) {
|
|
3083
|
+
if (this.draining.has(threadId)) return;
|
|
3084
|
+
this.draining.add(threadId);
|
|
3085
|
+
try {
|
|
3086
|
+
while (true) {
|
|
3087
|
+
const thread = readThread(threadId);
|
|
3088
|
+
if (!thread || thread.queue.length === 0) {
|
|
3089
|
+
if (thread && thread.status === "queued") {
|
|
3090
|
+
setStatus(threadId, "idle");
|
|
3091
|
+
this.emit({ type: "status_changed", threadId, status: "idle" });
|
|
3092
|
+
}
|
|
3093
|
+
break;
|
|
3094
|
+
}
|
|
3095
|
+
if (this.runningCount >= this.maxConcurrent) {
|
|
3096
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
3097
|
+
continue;
|
|
3098
|
+
}
|
|
3099
|
+
if (this.activeTurns.has(threadId)) {
|
|
3100
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3101
|
+
continue;
|
|
3102
|
+
}
|
|
3103
|
+
const prompt = thread.queue[0];
|
|
3104
|
+
const remaining = thread.queue.slice(1);
|
|
3105
|
+
updateThread(threadId, { queue: remaining });
|
|
3106
|
+
this.emit({ type: "queue_changed", threadId, queue: remaining });
|
|
3107
|
+
await this.runTurn(threadId, prompt);
|
|
3108
|
+
}
|
|
3109
|
+
} finally {
|
|
3110
|
+
this.draining.delete(threadId);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
async runTurn(threadId, prompt) {
|
|
3114
|
+
let thread = this.requireThread(threadId);
|
|
3115
|
+
if (isGlobalThread(thread) && thread.sessionId && orchestratorSessionPoisonedByBuiltins(thread)) {
|
|
3116
|
+
thread = updateThread(threadId, { sessionId: null });
|
|
3117
|
+
}
|
|
3118
|
+
this.runningCount += 1;
|
|
3119
|
+
const turnStartedAt = Date.now();
|
|
3120
|
+
this.startingTurns.add(threadId);
|
|
3121
|
+
setStatus(threadId, "running");
|
|
3122
|
+
this.emit({ type: "status_changed", threadId, status: "running" });
|
|
3123
|
+
this.emit({ type: "turn_started", threadId, prompt });
|
|
3124
|
+
try {
|
|
3125
|
+
const baseline = await captureTurnBaseline(thread.worktreePath);
|
|
3126
|
+
if (baseline) this.turnBaselines.set(threadId, baseline);
|
|
3127
|
+
} catch {
|
|
3128
|
+
}
|
|
3129
|
+
try {
|
|
3130
|
+
const compact = await maybeCompactContext(thread);
|
|
3131
|
+
if (compact.didCompact) {
|
|
3132
|
+
thread = updateThread(threadId, {
|
|
3133
|
+
messages: compact.thread.messages,
|
|
3134
|
+
sessionId: null
|
|
3135
|
+
});
|
|
3136
|
+
this.emit({
|
|
3137
|
+
type: "context_compacted",
|
|
3138
|
+
threadId,
|
|
3139
|
+
olderCount: compact.olderCount ?? 0,
|
|
3140
|
+
method: compact.method ?? "extractive"
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3145
|
+
this.emit({ type: "error", threadId, message: `context compact failed: ${message}` });
|
|
3146
|
+
thread = this.requireThread(threadId);
|
|
3147
|
+
}
|
|
3148
|
+
appendMessage(threadId, {
|
|
3149
|
+
role: "user",
|
|
3150
|
+
text: prompt,
|
|
3151
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3152
|
+
});
|
|
3153
|
+
thread = this.requireThread(threadId);
|
|
3154
|
+
const { agentPrompt: expandedPrompt } = expandComposerPrompt(
|
|
3155
|
+
thread.worktreePath,
|
|
3156
|
+
prompt,
|
|
3157
|
+
{
|
|
3158
|
+
attachments: thread.attachments
|
|
3159
|
+
}
|
|
3160
|
+
);
|
|
3161
|
+
const orchestrationReminder = isOrchestratorThread(thread) ? coordinatorTurnReminder({
|
|
3162
|
+
parentId: threadId,
|
|
3163
|
+
goal: thread.sourceRef || thread.title
|
|
3164
|
+
}) : null;
|
|
3165
|
+
const agentPrompt = [
|
|
3166
|
+
thread.planMode ? PLAN_MODE_INSTRUCTION : null,
|
|
3167
|
+
orchestrationReminder,
|
|
3168
|
+
expandedPrompt
|
|
3169
|
+
].filter(Boolean).join("\n\n");
|
|
3170
|
+
const instructionFiles = loadAgentInstructions(thread.worktreePath, thread.agent);
|
|
3171
|
+
if (thread.attachments.length > 0) {
|
|
3172
|
+
updateThread(threadId, { attachments: [] });
|
|
3173
|
+
}
|
|
3174
|
+
const adapter = getAdapter(thread.agent);
|
|
3175
|
+
const resolved = await adapter.resolveSessionId(thread.worktreePath, thread.sessionId);
|
|
3176
|
+
if (resolved && resolved !== thread.sessionId) {
|
|
3177
|
+
updateThread(threadId, { sessionId: resolved });
|
|
3178
|
+
}
|
|
3179
|
+
const fresh = this.requireThread(threadId);
|
|
3180
|
+
if (!fresh.worktreePath?.trim()) {
|
|
3181
|
+
throw new Error(
|
|
3182
|
+
`Thread ${threadId} has no worktreePath \u2014 refusing to start an agent outside an isolated worktree`
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
const isBrightsy = fresh.agent === "brightsy";
|
|
3186
|
+
const isOrchestration = isOrchestratorThread(fresh);
|
|
3187
|
+
const worktreeDirective = isBrightsy || isOrchestration ? null : formatWorktreeDirective(fresh);
|
|
3188
|
+
const settings = loadWorkspaceSettings(fresh.worktreePath, fresh.repoPath);
|
|
3189
|
+
const { autoRenameBranchEnabled } = await import("./app-settings-BDMLWCWI.js");
|
|
3190
|
+
const renameBranchDirective = !isBrightsy && !isOrchestration && autoRenameBranchEnabled() ? formatRenameBranchDirective(fresh, {
|
|
3191
|
+
customPrompt: settings?.prompts?.renameBranch
|
|
3192
|
+
}) : null;
|
|
3193
|
+
const instructions = fresh.agent === "claude" || isBrightsy ? null : formatAgentInstructions(instructionFiles);
|
|
3194
|
+
let seed = null;
|
|
3195
|
+
if (!fresh.sessionId) {
|
|
3196
|
+
const prior = fresh.messages.slice(0, -1);
|
|
3197
|
+
seed = isBrightsy ? buildSessionSeed(prior.slice(-6), { tools: "none" }) : buildSessionSeed(prior);
|
|
3198
|
+
}
|
|
3199
|
+
let coordinatorDirective = null;
|
|
3200
|
+
if (isOrchestration) {
|
|
3201
|
+
if (isGlobalThread(fresh)) ensureGlobalCoordinatorCwd();
|
|
3202
|
+
if (!fresh.sessionId) {
|
|
3203
|
+
const inventory = await enrichWorkspacesWithGithub(this.listWorkspaces());
|
|
3204
|
+
coordinatorDirective = coordinatorSystemPrompt({
|
|
3205
|
+
goal: fresh.sourceRef || fresh.title || "Orchestration",
|
|
3206
|
+
parentId: fresh.id,
|
|
3207
|
+
workspaces: inventory,
|
|
3208
|
+
audience: "desktop"
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
const cachedPrefix = [
|
|
3213
|
+
coordinatorDirective,
|
|
3214
|
+
worktreeDirective,
|
|
3215
|
+
renameBranchDirective,
|
|
3216
|
+
...fresh.agent === "claude" && fresh.sessionId ? [] : [instructions, seed]
|
|
3217
|
+
].filter(Boolean).join("\n\n---\n\n");
|
|
3218
|
+
try {
|
|
3219
|
+
const handle = await spawnAgentTurn(
|
|
3220
|
+
fresh,
|
|
3221
|
+
{ cachedPrefix, prompt: agentPrompt },
|
|
3222
|
+
(event) => {
|
|
3223
|
+
this.emit({ type: "turn_output", threadId, event });
|
|
3224
|
+
if (event.type === "session_id") {
|
|
3225
|
+
updateThread(threadId, { sessionId: event.data });
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
);
|
|
3229
|
+
this.activeTurns.set(threadId, handle);
|
|
3230
|
+
this.startingTurns.delete(threadId);
|
|
3231
|
+
if (this.stoppedTurns.has(threadId)) {
|
|
3232
|
+
handle.kill();
|
|
3233
|
+
} else {
|
|
3234
|
+
const live = readThread(threadId);
|
|
3235
|
+
if (live && (live.status !== "running" || live.lastError)) {
|
|
3236
|
+
setStatus(threadId, "running");
|
|
3237
|
+
this.emit({ type: "status_changed", threadId, status: "running" });
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
this.processes.set(`${threadId}:agent`, {
|
|
3241
|
+
kind: "agent",
|
|
3242
|
+
pid: handle.pid,
|
|
3243
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3244
|
+
kill: handle.kill
|
|
3245
|
+
});
|
|
3246
|
+
const result = await handle.done;
|
|
3247
|
+
if (result.sessionId) {
|
|
3248
|
+
updateThread(threadId, { sessionId: result.sessionId });
|
|
3249
|
+
}
|
|
3250
|
+
if (result.assistantText.trim() || result.parts.length > 0) {
|
|
3251
|
+
appendMessage(threadId, {
|
|
3252
|
+
role: "agent",
|
|
3253
|
+
text: result.assistantText.trim(),
|
|
3254
|
+
parts: result.parts.length > 0 ? result.parts : void 0,
|
|
3255
|
+
durationMs: Math.max(0, Date.now() - turnStartedAt),
|
|
3256
|
+
usage: result.usage ?? void 0,
|
|
3257
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
const afterTurn = this.requireThread(threadId);
|
|
3261
|
+
if (afterTurn.planMode && afterTurn.agent === "claude" && result.parts.some(
|
|
3262
|
+
(p) => p.type === "tool" && /exitplanmode/i.test(p.name)
|
|
3263
|
+
)) {
|
|
3264
|
+
updateThread(threadId, { sessionId: null });
|
|
3265
|
+
}
|
|
3266
|
+
await syncThreadBranchFromGit(threadId);
|
|
3267
|
+
if (this.stoppedTurns.has(threadId)) {
|
|
3268
|
+
setStatus(threadId, "stopped");
|
|
3269
|
+
this.emit({ type: "status_changed", threadId, status: "stopped" });
|
|
3270
|
+
this.emit({ type: "turn_finished", threadId, exitCode: result.exitCode });
|
|
3271
|
+
} else {
|
|
3272
|
+
setStatus(threadId, result.exitCode === 0 ? "idle" : "error", result.exitCode === 0 ? null : `exit ${result.exitCode}`);
|
|
3273
|
+
this.emit({
|
|
3274
|
+
type: "status_changed",
|
|
3275
|
+
threadId,
|
|
3276
|
+
status: result.exitCode === 0 ? "idle" : "error"
|
|
3277
|
+
});
|
|
3278
|
+
this.emit({ type: "turn_finished", threadId, exitCode: result.exitCode });
|
|
3279
|
+
}
|
|
3280
|
+
} catch (err) {
|
|
3281
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3282
|
+
await syncThreadBranchFromGit(threadId).catch(() => void 0);
|
|
3283
|
+
if (this.stoppedTurns.has(threadId)) {
|
|
3284
|
+
setStatus(threadId, "stopped");
|
|
3285
|
+
this.emit({ type: "status_changed", threadId, status: "stopped" });
|
|
3286
|
+
this.emit({ type: "turn_finished", threadId, exitCode: 1 });
|
|
3287
|
+
} else {
|
|
3288
|
+
setStatus(threadId, "error", message);
|
|
3289
|
+
this.emit({ type: "error", threadId, message });
|
|
3290
|
+
this.emit({ type: "status_changed", threadId, status: "error" });
|
|
3291
|
+
this.emit({ type: "turn_finished", threadId, exitCode: 1 });
|
|
3292
|
+
}
|
|
3293
|
+
} finally {
|
|
3294
|
+
this.startingTurns.delete(threadId);
|
|
3295
|
+
this.activeTurns.delete(threadId);
|
|
3296
|
+
this.processes.delete(`${threadId}:agent`);
|
|
3297
|
+
this.stoppedTurns.delete(threadId);
|
|
3298
|
+
this.runningCount = Math.max(0, this.runningCount - 1);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Stop an in-flight agent turn.
|
|
3303
|
+
* Default `clearQueue: true` (force-stop): kills the turn AND empties queued prompts
|
|
3304
|
+
* so drainQueue cannot continue / re-start work after an intentional stop. Desktop,
|
|
3305
|
+
* CLI, MCP, and cloud-connect all share this default.
|
|
3306
|
+
*/
|
|
3307
|
+
stop(threadRef, opts) {
|
|
3308
|
+
const clearQueue = opts?.clearQueue !== false;
|
|
3309
|
+
const thread = this.requireThread(threadRef);
|
|
3310
|
+
const inFlight = this.activeTurns.has(thread.id) || this.startingTurns.has(thread.id);
|
|
3311
|
+
if (inFlight) {
|
|
3312
|
+
this.stoppedTurns.add(thread.id);
|
|
3313
|
+
}
|
|
3314
|
+
if (clearQueue && thread.queue.length > 0) {
|
|
3315
|
+
updateThread(thread.id, { queue: [] });
|
|
3316
|
+
this.emit({ type: "queue_changed", threadId: thread.id, queue: [] });
|
|
3317
|
+
}
|
|
3318
|
+
const handle = this.activeTurns.get(thread.id);
|
|
3319
|
+
if (handle) handle.kill();
|
|
3320
|
+
const proc = this.processes.get(`${thread.id}:agent`);
|
|
3321
|
+
if (proc) proc.kill();
|
|
3322
|
+
setStatus(thread.id, "stopped");
|
|
3323
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: "stopped" });
|
|
3324
|
+
return this.requireThread(thread.id);
|
|
3325
|
+
}
|
|
3326
|
+
async startDev(threadRef, scriptName) {
|
|
3327
|
+
const thread = this.requireThread(threadRef);
|
|
3328
|
+
this.assertNotGlobal(thread, "Run script");
|
|
3329
|
+
const scripts = listRunScripts(thread.worktreePath, thread.repoPath);
|
|
3330
|
+
const resolvedName = scriptName ?? scripts.find((s) => s.default === true)?.name ?? scripts.find((s) => s.name === "dev")?.name ?? scripts.find((s) => s.name !== "all")?.name ?? scripts[0]?.name;
|
|
3331
|
+
if (!resolvedName) {
|
|
3332
|
+
throw new Error(
|
|
3333
|
+
"No run script found for this worktree. Add [scripts.run.*] in the worktree\u2019s .sideboard/settings.toml or .conductor/settings.toml (or settings.local.toml on the main repo)."
|
|
3334
|
+
);
|
|
3335
|
+
}
|
|
3336
|
+
const runKey = `${thread.id}:run:${resolvedName}`;
|
|
3337
|
+
const existing = this.processes.get(runKey);
|
|
3338
|
+
const active = (thread.activeRuns ?? []).find((r) => r.scriptName === resolvedName);
|
|
3339
|
+
if (existing && active) {
|
|
3340
|
+
return { port: active.port, scriptName: resolvedName, ports: active.ports };
|
|
3341
|
+
}
|
|
3342
|
+
if (!scriptName) {
|
|
3343
|
+
const legacy = this.processes.get(`${thread.id}:dev`);
|
|
3344
|
+
if (legacy && thread.devPort) {
|
|
3345
|
+
return { port: thread.devPort, scriptName: resolvedName, ports: [thread.devPort] };
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
const mode = getRunMode(thread.worktreePath, thread.repoPath);
|
|
3349
|
+
if (mode === "nonconcurrent") {
|
|
3350
|
+
for (const t of listThreads()) {
|
|
3351
|
+
if (t.id === thread.id) continue;
|
|
3352
|
+
const runs = t.activeRuns ?? [];
|
|
3353
|
+
if (runs.length > 0 || t.devPort) {
|
|
3354
|
+
throw new Error(
|
|
3355
|
+
`run_mode is nonconcurrent \u2014 stop running scripts on thread ${t.id.slice(0, 8)} first`
|
|
3356
|
+
);
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
const handle = await startDevServer(
|
|
3361
|
+
thread.repoPath,
|
|
3362
|
+
thread.worktreePath,
|
|
3363
|
+
(line) => {
|
|
3364
|
+
this.emit({
|
|
3365
|
+
type: "run_output",
|
|
3366
|
+
threadId: thread.id,
|
|
3367
|
+
scriptName: resolvedName,
|
|
3368
|
+
line
|
|
3369
|
+
});
|
|
3370
|
+
},
|
|
3371
|
+
{ scriptName: resolvedName }
|
|
3372
|
+
);
|
|
3373
|
+
if (!handle) {
|
|
3374
|
+
throw new Error(`Run script not found: ${resolvedName}`);
|
|
3375
|
+
}
|
|
3376
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3377
|
+
this.processes.set(runKey, {
|
|
3378
|
+
kind: "dev",
|
|
3379
|
+
pid: handle.pid,
|
|
3380
|
+
startedAt,
|
|
3381
|
+
scriptName: resolvedName,
|
|
3382
|
+
kill: handle.kill
|
|
3383
|
+
});
|
|
3384
|
+
const isDefault = scripts.find((s) => s.default)?.name === resolvedName || !scripts.some((s) => s.default) && (resolvedName === "dev" || resolvedName === scripts[0]?.name);
|
|
3385
|
+
if (isDefault) {
|
|
3386
|
+
this.processes.set(`${thread.id}:dev`, {
|
|
3387
|
+
kind: "dev",
|
|
3388
|
+
pid: handle.pid,
|
|
3389
|
+
startedAt,
|
|
3390
|
+
scriptName: resolvedName,
|
|
3391
|
+
kill: handle.kill
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
const run2 = {
|
|
3395
|
+
scriptName: resolvedName,
|
|
3396
|
+
port: handle.port,
|
|
3397
|
+
ports: handle.ports,
|
|
3398
|
+
startedAt
|
|
3399
|
+
};
|
|
3400
|
+
const nextRuns = [
|
|
3401
|
+
...(thread.activeRuns ?? []).filter((r) => r.scriptName !== resolvedName),
|
|
3402
|
+
run2
|
|
3403
|
+
];
|
|
3404
|
+
updateThread(thread.id, {
|
|
3405
|
+
activeRuns: nextRuns,
|
|
3406
|
+
devPort: isDefault ? handle.port : thread.devPort
|
|
3407
|
+
});
|
|
3408
|
+
this.emit({
|
|
3409
|
+
type: "dev_server_started",
|
|
3410
|
+
threadId: thread.id,
|
|
3411
|
+
port: handle.port,
|
|
3412
|
+
scriptName: resolvedName
|
|
3413
|
+
});
|
|
3414
|
+
void handle.done.then(() => {
|
|
3415
|
+
this.processes.delete(runKey);
|
|
3416
|
+
if (isDefault) this.processes.delete(`${thread.id}:dev`);
|
|
3417
|
+
const latest = readThread(thread.id);
|
|
3418
|
+
const remaining = (latest?.activeRuns ?? []).filter(
|
|
3419
|
+
(r) => r.scriptName !== resolvedName
|
|
3420
|
+
);
|
|
3421
|
+
updateThread(thread.id, {
|
|
3422
|
+
activeRuns: remaining,
|
|
3423
|
+
devPort: isDefault ? null : latest?.devPort ?? null
|
|
3424
|
+
});
|
|
3425
|
+
this.emit({
|
|
3426
|
+
type: "dev_server_stopped",
|
|
3427
|
+
threadId: thread.id,
|
|
3428
|
+
scriptName: resolvedName
|
|
3429
|
+
});
|
|
3430
|
+
});
|
|
3431
|
+
return { port: handle.port, scriptName: resolvedName, ports: handle.ports };
|
|
3432
|
+
}
|
|
3433
|
+
stopDev(threadRef, scriptName) {
|
|
3434
|
+
const thread = this.requireThread(threadRef);
|
|
3435
|
+
if (scriptName) {
|
|
3436
|
+
const runKey = `${thread.id}:run:${scriptName}`;
|
|
3437
|
+
const proc = this.processes.get(runKey);
|
|
3438
|
+
if (proc) proc.kill();
|
|
3439
|
+
this.processes.delete(runKey);
|
|
3440
|
+
const remaining = (thread.activeRuns ?? []).filter((r) => r.scriptName !== scriptName);
|
|
3441
|
+
const isPrimary = thread.devPort != null && thread.activeRuns?.find((r) => r.scriptName === scriptName)?.port === thread.devPort;
|
|
3442
|
+
updateThread(thread.id, {
|
|
3443
|
+
activeRuns: remaining,
|
|
3444
|
+
devPort: isPrimary ? null : thread.devPort
|
|
3445
|
+
});
|
|
3446
|
+
this.emit({ type: "dev_server_stopped", threadId: thread.id, scriptName });
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
for (const [key, proc] of [...this.processes.entries()]) {
|
|
3450
|
+
if (key.startsWith(`${thread.id}:run:`) || key === `${thread.id}:dev`) {
|
|
3451
|
+
proc.kill();
|
|
3452
|
+
this.processes.delete(key);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
updateThread(thread.id, { activeRuns: [], devPort: null });
|
|
3456
|
+
this.emit({ type: "dev_server_stopped", threadId: thread.id });
|
|
3457
|
+
}
|
|
3458
|
+
listThreadRunScripts(threadRef) {
|
|
3459
|
+
const thread = this.requireThread(threadRef);
|
|
3460
|
+
return listRunScripts(thread.worktreePath, thread.repoPath);
|
|
3461
|
+
}
|
|
3462
|
+
getActiveRuns(threadRef) {
|
|
3463
|
+
return this.requireThread(threadRef).activeRuns ?? [];
|
|
3464
|
+
}
|
|
3465
|
+
async runSetup(threadRef) {
|
|
3466
|
+
const thread = this.requireThread(threadRef);
|
|
3467
|
+
this.assertNotGlobal(thread, "Setup");
|
|
3468
|
+
const key = `${thread.id}:setup`;
|
|
3469
|
+
if (this.processes.has(key)) {
|
|
3470
|
+
throw new Error("Setup already running for this thread");
|
|
3471
|
+
}
|
|
3472
|
+
const abort = new AbortController();
|
|
3473
|
+
this.processes.set(key, {
|
|
3474
|
+
kind: "setup",
|
|
3475
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3476
|
+
kill: () => abort.abort()
|
|
3477
|
+
});
|
|
3478
|
+
this.emit({ type: "setup_started", threadId: thread.id });
|
|
3479
|
+
try {
|
|
3480
|
+
let setup = await runSetupScript(
|
|
3481
|
+
thread.repoPath,
|
|
3482
|
+
thread.worktreePath,
|
|
3483
|
+
(line) => {
|
|
3484
|
+
this.emit({ type: "setup_output", threadId: thread.id, line });
|
|
3485
|
+
},
|
|
3486
|
+
{ signal: abort.signal }
|
|
3487
|
+
);
|
|
3488
|
+
if (!setup.ran) {
|
|
3489
|
+
setup = await runCursorWorktreeSetup(
|
|
3490
|
+
thread.repoPath,
|
|
3491
|
+
thread.worktreePath,
|
|
3492
|
+
(line) => {
|
|
3493
|
+
this.emit({ type: "setup_output", threadId: thread.id, line });
|
|
3494
|
+
}
|
|
3495
|
+
);
|
|
3496
|
+
}
|
|
3497
|
+
if (!setup.ran) {
|
|
3498
|
+
throw new Error(
|
|
3499
|
+
"No setup script in .sideboard/settings.toml, .conductor/settings.toml, or .cursor/worktrees.json"
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
3502
|
+
if (setup.exitCode !== 0 && setup.exitCode !== null) {
|
|
3503
|
+
updateThread(thread.id, {
|
|
3504
|
+
lastError: `Setup exited ${setup.exitCode}`
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
this.emit({ type: "setup_finished", threadId: thread.id, exitCode: setup.exitCode });
|
|
3508
|
+
return { exitCode: setup.exitCode, source: setup.source };
|
|
3509
|
+
} finally {
|
|
3510
|
+
this.processes.delete(key);
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
cancelSetup(threadRef) {
|
|
3514
|
+
const thread = this.requireThread(threadRef);
|
|
3515
|
+
const proc = this.processes.get(`${thread.id}:setup`);
|
|
3516
|
+
if (proc) proc.kill();
|
|
3517
|
+
}
|
|
3518
|
+
async applyIntoMain(threadRef, opts) {
|
|
3519
|
+
const thread = this.requireThread(threadRef);
|
|
3520
|
+
return applyThreadIntoMain(thread, opts);
|
|
3521
|
+
}
|
|
3522
|
+
async cloneRepo(url, name) {
|
|
3523
|
+
return cloneRepoIntoSideboard({ url, name });
|
|
3524
|
+
}
|
|
3525
|
+
async listOrphanWorktrees(repoPath) {
|
|
3526
|
+
const repos = repoPath ? [repoPath] : [...new Set(listThreads({ includeArchived: true }).map((t) => t.repoPath))];
|
|
3527
|
+
return findOrphanWorktrees(repos);
|
|
3528
|
+
}
|
|
3529
|
+
async cleanupOrphans(opts) {
|
|
3530
|
+
const repoPaths = opts?.repoPath ? [opts.repoPath] : [...new Set(listThreads({ includeArchived: true }).map((t) => t.repoPath))];
|
|
3531
|
+
return cleanupOrphanWorktrees({
|
|
3532
|
+
dryRun: opts?.dryRun,
|
|
3533
|
+
maxCount: opts?.maxCount,
|
|
3534
|
+
repoPaths
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* Best-of-n / fanout: create N threads (one per agent) with the same prompt.
|
|
3539
|
+
*/
|
|
3540
|
+
async bestOfN(opts) {
|
|
3541
|
+
const agents = opts.agents.length ? opts.agents : ["claude"];
|
|
3542
|
+
const sourceType = opts.sourceType ?? "branch";
|
|
3543
|
+
const sourceRef = opts.sourceRef ?? "default";
|
|
3544
|
+
const created = [];
|
|
3545
|
+
for (const agent of agents) {
|
|
3546
|
+
const thread = await this.createThread({
|
|
3547
|
+
sourceType,
|
|
3548
|
+
sourceRef,
|
|
3549
|
+
agent,
|
|
3550
|
+
repoPath: opts.repoPath,
|
|
3551
|
+
title: opts.title ? `${opts.title} (${agent})` : `best-of-n: ${opts.prompt.slice(0, 48)} (${agent})`,
|
|
3552
|
+
prompt: opts.prompt
|
|
3553
|
+
});
|
|
3554
|
+
created.push(thread);
|
|
3555
|
+
}
|
|
3556
|
+
return created;
|
|
3557
|
+
}
|
|
3558
|
+
async waitForTurn(threadRef, timeoutMs = 6e5) {
|
|
3559
|
+
const thread = this.requireThread(threadRef);
|
|
3560
|
+
const start = Date.now();
|
|
3561
|
+
return new Promise((resolve, reject) => {
|
|
3562
|
+
if (!["running", "queued"].includes(thread.status)) {
|
|
3563
|
+
resolve(thread);
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
const off = this.on((event) => {
|
|
3567
|
+
if (event.type === "turn_finished" && event.threadId === thread.id) {
|
|
3568
|
+
off();
|
|
3569
|
+
resolve(this.requireThread(thread.id));
|
|
3570
|
+
}
|
|
3571
|
+
if (event.type === "error" && event.threadId === thread.id) {
|
|
3572
|
+
off();
|
|
3573
|
+
resolve(this.requireThread(thread.id));
|
|
3574
|
+
}
|
|
3575
|
+
});
|
|
3576
|
+
const timer = setInterval(() => {
|
|
3577
|
+
if (Date.now() - start > timeoutMs) {
|
|
3578
|
+
clearInterval(timer);
|
|
3579
|
+
off();
|
|
3580
|
+
reject(new Error("wait_for_turn timed out"));
|
|
3581
|
+
}
|
|
3582
|
+
const current = readThread(thread.id);
|
|
3583
|
+
if (current && !["running", "queued"].includes(current.status)) {
|
|
3584
|
+
clearInterval(timer);
|
|
3585
|
+
off();
|
|
3586
|
+
resolve(current);
|
|
3587
|
+
}
|
|
3588
|
+
}, 200);
|
|
3589
|
+
});
|
|
3590
|
+
}
|
|
3591
|
+
getTurnResult(threadRef) {
|
|
3592
|
+
const thread = this.requireThread(threadRef);
|
|
3593
|
+
const lastAgent = [...thread.messages].reverse().find((m) => m.role === "agent");
|
|
3594
|
+
return {
|
|
3595
|
+
text: lastAgent?.text ?? "",
|
|
3596
|
+
status: thread.status,
|
|
3597
|
+
sessionId: thread.sessionId
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3600
|
+
assertNotGlobal(thread, action) {
|
|
3601
|
+
if (isGlobalThread(thread)) {
|
|
3602
|
+
throw new Error(`${action} is not available on the global coordinator`);
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
async diff(threadRef, opts) {
|
|
3606
|
+
const thread = this.requireThread(threadRef);
|
|
3607
|
+
this.assertNotGlobal(thread, "Diff");
|
|
3608
|
+
return getDiff(thread.worktreePath, thread.repoPath, {
|
|
3609
|
+
scope: opts?.scope,
|
|
3610
|
+
commitSha: opts?.commitSha,
|
|
3611
|
+
lastTurnBase: this.turnBaselines.get(thread.id) ?? null
|
|
3612
|
+
});
|
|
3613
|
+
}
|
|
3614
|
+
async diffSummary(threadRef) {
|
|
3615
|
+
const thread = this.requireThread(threadRef);
|
|
3616
|
+
this.assertNotGlobal(thread, "Diff");
|
|
3617
|
+
return getDiffSummary(thread.worktreePath, thread.repoPath);
|
|
3618
|
+
}
|
|
3619
|
+
async initializeGit(threadRef) {
|
|
3620
|
+
const thread = this.requireThread(threadRef);
|
|
3621
|
+
this.assertNotGlobal(thread, "Initialize git");
|
|
3622
|
+
await initializeGitRepository(thread.worktreePath);
|
|
3623
|
+
}
|
|
3624
|
+
async listFiles(threadRef) {
|
|
3625
|
+
const thread = this.requireThread(threadRef);
|
|
3626
|
+
return listWorktreeFiles(thread.worktreePath);
|
|
3627
|
+
}
|
|
3628
|
+
async readFile(threadRef, relativePath) {
|
|
3629
|
+
const thread = this.requireThread(threadRef);
|
|
3630
|
+
if (relativePath.includes("..") || relativePath.startsWith("/")) {
|
|
3631
|
+
throw new Error("Invalid path");
|
|
3632
|
+
}
|
|
3633
|
+
return readWorktreeFile(thread.worktreePath, relativePath);
|
|
3634
|
+
}
|
|
3635
|
+
async writeFile(threadRef, relativePath, content) {
|
|
3636
|
+
const thread = this.requireThread(threadRef);
|
|
3637
|
+
if (relativePath.includes("..") || relativePath.startsWith("/")) {
|
|
3638
|
+
throw new Error("Invalid path");
|
|
3639
|
+
}
|
|
3640
|
+
return writeWorktreeFile(thread.worktreePath, relativePath, content);
|
|
3641
|
+
}
|
|
3642
|
+
listSkills(threadRef) {
|
|
3643
|
+
const thread = this.requireThread(threadRef);
|
|
3644
|
+
return discoverSkills(thread.worktreePath);
|
|
3645
|
+
}
|
|
3646
|
+
async previewLand(threadRef) {
|
|
3647
|
+
const thread = this.requireThread(threadRef);
|
|
3648
|
+
this.assertNotGlobal(thread, "Land");
|
|
3649
|
+
return previewLand(thread);
|
|
3650
|
+
}
|
|
3651
|
+
async confirmLand(threadRef, opts) {
|
|
3652
|
+
const thread = this.requireThread(threadRef);
|
|
3653
|
+
this.assertNotGlobal(thread, "Land");
|
|
3654
|
+
const result = await confirmLand(thread, opts);
|
|
3655
|
+
if (result.prUrl) {
|
|
3656
|
+
const patch = { prUrl: result.prUrl };
|
|
3657
|
+
try {
|
|
3658
|
+
const details = await getPrDetails(thread.worktreePath, result.prUrl);
|
|
3659
|
+
if (details?.title) patch.prTitle = details.title;
|
|
3660
|
+
} catch {
|
|
3661
|
+
}
|
|
3662
|
+
updateThread(thread.id, patch);
|
|
3663
|
+
await syncThreadBranchFromGit(thread.id);
|
|
3664
|
+
}
|
|
3665
|
+
return result;
|
|
3666
|
+
}
|
|
3667
|
+
async mergePr(threadRef) {
|
|
3668
|
+
const { thread, selector, cwd } = await this.withPrSelector(threadRef);
|
|
3669
|
+
this.assertNotGlobal(thread, "Merge PR");
|
|
3670
|
+
if (!selector) throw new Error("No pull request linked to this thread");
|
|
3671
|
+
const result = await mergePr(cwd, selector);
|
|
3672
|
+
if (result.url && result.url !== thread.prUrl) {
|
|
3673
|
+
updateThread(thread.id, { prUrl: result.url });
|
|
3674
|
+
}
|
|
3675
|
+
return result;
|
|
3676
|
+
}
|
|
3677
|
+
/** Resolve PR selector and optionally persist `prUrl` when found. */
|
|
3678
|
+
async withPrSelector(threadRef) {
|
|
3679
|
+
const thread = this.requireThread(threadRef);
|
|
3680
|
+
const selector = resolvePrSelector(thread);
|
|
3681
|
+
const cwd = thread.worktreePath;
|
|
3682
|
+
if (!cwd?.trim()) {
|
|
3683
|
+
throw new Error(`Thread ${threadRef} has no worktreePath`);
|
|
3684
|
+
}
|
|
3685
|
+
return { thread, selector, cwd };
|
|
3686
|
+
}
|
|
3687
|
+
async getPrChecks(threadRef) {
|
|
3688
|
+
const { selector, cwd } = await this.withPrSelector(threadRef);
|
|
3689
|
+
if (!selector) return null;
|
|
3690
|
+
return getPrChecks(cwd, selector);
|
|
3691
|
+
}
|
|
3692
|
+
async getPrDetails(threadRef) {
|
|
3693
|
+
const { thread, selector, cwd } = await this.withPrSelector(threadRef);
|
|
3694
|
+
if (!selector) return null;
|
|
3695
|
+
const details = await getPrDetails(cwd, selector);
|
|
3696
|
+
if (details) {
|
|
3697
|
+
const patch = {};
|
|
3698
|
+
if (details.url && details.url !== thread.prUrl) patch.prUrl = details.url;
|
|
3699
|
+
if (details.title && details.title !== thread.prTitle) patch.prTitle = details.title;
|
|
3700
|
+
if (Object.keys(patch).length > 0) {
|
|
3701
|
+
updateThread(thread.id, patch);
|
|
3702
|
+
const latest = this.requireThread(thread.id);
|
|
3703
|
+
if (!latest.userSetTitle && details.title && latest.title !== details.title) {
|
|
3704
|
+
updateThread(thread.id, { title: details.title });
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
return details;
|
|
3709
|
+
}
|
|
3710
|
+
setAutonomy(threadRef, autonomy) {
|
|
3711
|
+
return this.setThreadOptions(threadRef, { autonomy });
|
|
3712
|
+
}
|
|
3713
|
+
setThreadOptions(threadRef, patch) {
|
|
3714
|
+
const thread = this.requireThread(threadRef);
|
|
3715
|
+
const next = {};
|
|
3716
|
+
if (patch.autonomy !== void 0) next.autonomy = patch.autonomy;
|
|
3717
|
+
if (patch.fast !== void 0) next.fast = patch.fast;
|
|
3718
|
+
if (patch.planMode !== void 0) next.planMode = patch.planMode;
|
|
3719
|
+
if (patch.model !== void 0) next.model = patch.model;
|
|
3720
|
+
if (patch.agent !== void 0) {
|
|
3721
|
+
next.agent = patch.agent;
|
|
3722
|
+
if (patch.agent !== "claude" && patch.model === void 0) next.model = null;
|
|
3723
|
+
next.sessionId = null;
|
|
3724
|
+
}
|
|
3725
|
+
return updateThread(thread.id, next);
|
|
3726
|
+
}
|
|
3727
|
+
createChatTab(input) {
|
|
3728
|
+
return createChatTab(input);
|
|
3729
|
+
}
|
|
3730
|
+
forkChatTab(input) {
|
|
3731
|
+
return forkChatTab(input);
|
|
3732
|
+
}
|
|
3733
|
+
async forkThreadWorktree(input) {
|
|
3734
|
+
const thread = await forkThreadWorktree(input, (line) => {
|
|
3735
|
+
this.emit({
|
|
3736
|
+
type: "turn_output",
|
|
3737
|
+
threadId: "pending",
|
|
3738
|
+
event: { type: "stdout", data: line }
|
|
3739
|
+
});
|
|
3740
|
+
});
|
|
3741
|
+
this.emit({ type: "status_changed", threadId: thread.id, status: thread.status });
|
|
3742
|
+
return thread;
|
|
3743
|
+
}
|
|
3744
|
+
renameThread(threadRef, title) {
|
|
3745
|
+
const thread = this.requireThread(threadRef);
|
|
3746
|
+
const next = title.trim();
|
|
3747
|
+
if (!next) throw new Error("Title cannot be empty");
|
|
3748
|
+
return updateThread(thread.id, { title: next, userSetTitle: true });
|
|
3749
|
+
}
|
|
3750
|
+
setAttachments(threadRef, attachments) {
|
|
3751
|
+
return updateThread(this.requireThread(threadRef).id, { attachments });
|
|
3752
|
+
}
|
|
3753
|
+
listWorktreeChats(threadRef) {
|
|
3754
|
+
const thread = this.requireThread(threadRef);
|
|
3755
|
+
return threadsSharingWorktree(thread.worktreePath);
|
|
3756
|
+
}
|
|
3757
|
+
async archive(threadRef) {
|
|
3758
|
+
const thread = this.requireThread(threadRef);
|
|
3759
|
+
this.stop(thread.id);
|
|
3760
|
+
if (isGlobalThread(thread)) {
|
|
3761
|
+
return setStatus(thread.id, "archived");
|
|
3762
|
+
}
|
|
3763
|
+
const siblings = threadsSharingWorktree(thread.worktreePath).filter((t) => t.id !== thread.id);
|
|
3764
|
+
if (siblings.length === 0) {
|
|
3765
|
+
this.stopDev(thread.id);
|
|
3766
|
+
try {
|
|
3767
|
+
await runArchiveScript(thread.repoPath, thread.worktreePath, (line) => {
|
|
3768
|
+
this.emit({
|
|
3769
|
+
type: "turn_output",
|
|
3770
|
+
threadId: thread.id,
|
|
3771
|
+
event: { type: "stdout", data: `[archive] ${line}` }
|
|
3772
|
+
});
|
|
3773
|
+
});
|
|
3774
|
+
} catch {
|
|
3775
|
+
}
|
|
3776
|
+
await removeWorktree(thread.repoPath, thread.worktreePath);
|
|
3777
|
+
}
|
|
3778
|
+
return setStatus(thread.id, "archived");
|
|
3779
|
+
}
|
|
3780
|
+
async purge(threadRef, opts) {
|
|
3781
|
+
const thread = this.requireThread(threadRef);
|
|
3782
|
+
this.stop(thread.id);
|
|
3783
|
+
if (isGlobalThread(thread)) {
|
|
3784
|
+
deleteThreadRecord(thread.id);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
const siblings = threadsSharingWorktree(thread.worktreePath).filter((t) => t.id !== thread.id);
|
|
3788
|
+
if (siblings.length === 0) {
|
|
3789
|
+
this.stopDev(thread.id);
|
|
3790
|
+
try {
|
|
3791
|
+
await runArchiveScript(thread.repoPath, thread.worktreePath);
|
|
3792
|
+
} catch {
|
|
3793
|
+
}
|
|
3794
|
+
const { deleteBranchOnPurgeEnabled } = await import("./app-settings-BDMLWCWI.js");
|
|
3795
|
+
const deleteBranch = opts?.deleteBranch ?? deleteBranchOnPurgeEnabled();
|
|
3796
|
+
await removeWorktree(thread.repoPath, thread.worktreePath, {
|
|
3797
|
+
deleteBranch: deleteBranch ? thread.branchName : void 0
|
|
3798
|
+
});
|
|
3799
|
+
}
|
|
3800
|
+
deleteThreadRecord(thread.id);
|
|
3801
|
+
}
|
|
3802
|
+
async restore(threadRef) {
|
|
3803
|
+
const thread = this.requireThread(threadRef);
|
|
3804
|
+
if (thread.status !== "archived") {
|
|
3805
|
+
throw new Error("Thread is not archived");
|
|
3806
|
+
}
|
|
3807
|
+
if (isGlobalThread(thread)) {
|
|
3808
|
+
const { globalAgentCwd } = await import("./paths-VPH3ITBK.js");
|
|
3809
|
+
updateThread(thread.id, { worktreePath: globalAgentCwd() });
|
|
3810
|
+
return setStatus(thread.id, "idle");
|
|
3811
|
+
}
|
|
3812
|
+
if (!existsSync10(thread.worktreePath)) {
|
|
3813
|
+
const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-NGFDN3J4.js");
|
|
3814
|
+
const { execa: execa6 } = await import("execa");
|
|
3815
|
+
const slug = thread.worktreePath.split("/").pop();
|
|
3816
|
+
const dest = thread.worktreePath;
|
|
3817
|
+
await execa6("git", ["worktree", "add", dest, thread.branchName], {
|
|
3818
|
+
cwd: thread.repoPath
|
|
3819
|
+
});
|
|
3820
|
+
void createThreadWorktree2;
|
|
3821
|
+
void slug;
|
|
3822
|
+
}
|
|
3823
|
+
return setStatus(thread.id, "idle");
|
|
3824
|
+
}
|
|
3825
|
+
async attachCommand(threadRef) {
|
|
3826
|
+
const thread = this.requireThread(threadRef);
|
|
3827
|
+
const adapter = getAdapter(thread.agent);
|
|
3828
|
+
return adapter.buildAttach(thread);
|
|
3829
|
+
}
|
|
3830
|
+
setMaxConcurrent(n) {
|
|
3831
|
+
this.maxConcurrent = Math.max(1, Math.min(32, Math.floor(n)));
|
|
3832
|
+
}
|
|
3833
|
+
getRuntime() {
|
|
3834
|
+
const threads = listThreads();
|
|
3835
|
+
const count = (status) => threads.filter((t) => t.status === status).length;
|
|
3836
|
+
return {
|
|
3837
|
+
running: this.runningCount,
|
|
3838
|
+
maxConcurrent: this.maxConcurrent,
|
|
3839
|
+
queued: count("queued"),
|
|
3840
|
+
idle: count("idle"),
|
|
3841
|
+
error: count("error"),
|
|
3842
|
+
stopped: count("stopped"),
|
|
3843
|
+
broken: count("broken"),
|
|
3844
|
+
totalActive: threads.length
|
|
3845
|
+
};
|
|
3846
|
+
}
|
|
3847
|
+
requireThread(ref) {
|
|
3848
|
+
const thread = this.getThread(ref);
|
|
3849
|
+
if (!thread) throw new Error(`Thread not found: ${ref}`);
|
|
3850
|
+
return thread;
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
var singleton = null;
|
|
3854
|
+
function getOrchestrator() {
|
|
3855
|
+
if (!singleton) {
|
|
3856
|
+
singleton = new Orchestrator();
|
|
3857
|
+
}
|
|
3858
|
+
return singleton;
|
|
3859
|
+
}
|
|
3860
|
+
async function startOrchestration(opts) {
|
|
3861
|
+
const repoPath = opts.repoPath?.trim();
|
|
3862
|
+
if (!repoPath || isGlobalRepoPath(repoPath)) {
|
|
3863
|
+
return createGlobalChat({
|
|
3864
|
+
sourceRef: opts.goal,
|
|
3865
|
+
agent: opts.agent,
|
|
3866
|
+
autonomy: opts.autonomy,
|
|
3867
|
+
model: opts.model,
|
|
3868
|
+
fast: opts.fast,
|
|
3869
|
+
planMode: opts.planMode,
|
|
3870
|
+
attachments: opts.attachments
|
|
3871
|
+
});
|
|
3872
|
+
}
|
|
3873
|
+
const { titleFromPrompt } = await import("./title-4A2ATYNY.js");
|
|
3874
|
+
const title = titleFromPrompt(opts.goal) || "Orchestration";
|
|
3875
|
+
const createOpts = {
|
|
3876
|
+
agent: opts.agent,
|
|
3877
|
+
repoPath,
|
|
3878
|
+
title,
|
|
3879
|
+
autonomy: opts.autonomy,
|
|
3880
|
+
model: opts.model,
|
|
3881
|
+
fast: opts.fast,
|
|
3882
|
+
planMode: opts.planMode,
|
|
3883
|
+
attachments: opts.attachments
|
|
3884
|
+
};
|
|
3885
|
+
const thread = await createThread({
|
|
3886
|
+
sourceType: "branch",
|
|
3887
|
+
sourceRef: "default",
|
|
3888
|
+
...createOpts
|
|
3889
|
+
}).catch(async () => {
|
|
3890
|
+
const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-NGFDN3J4.js");
|
|
3891
|
+
const repo = await resolveRepoRoot2(repoPath);
|
|
3892
|
+
const def = await resolveDefaultBranch2(repo);
|
|
3893
|
+
return createThread({
|
|
3894
|
+
sourceType: "branch",
|
|
3895
|
+
sourceRef: def,
|
|
3896
|
+
...createOpts,
|
|
3897
|
+
repoPath: repo
|
|
3898
|
+
});
|
|
3899
|
+
});
|
|
3900
|
+
const { updateThread: upd } = await import("./thread-store-UNPZNIFW.js");
|
|
3901
|
+
return upd(thread.id, {
|
|
3902
|
+
sourceType: "orchestration",
|
|
3903
|
+
sourceRef: opts.goal
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
// src/mcp/server.ts
|
|
3908
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3909
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3910
|
+
import { z } from "zod";
|
|
3911
|
+
import { basename as basename3 } from "path";
|
|
3912
|
+
|
|
3913
|
+
// src/mcp/archive-guard.ts
|
|
3914
|
+
function mcpArchiveBlockedReason(thread) {
|
|
3915
|
+
if (isCloudCoordinatorThread(thread)) {
|
|
3916
|
+
return "Refusing to archive the cloud coordinator via MCP. Archive it from the Sideboard UI if needed.";
|
|
3917
|
+
}
|
|
3918
|
+
return null;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
// src/mcp/server.ts
|
|
3922
|
+
var MAX_ORCH_THREADS = 5;
|
|
3923
|
+
async function startMcpServer() {
|
|
3924
|
+
const orch = getOrchestrator();
|
|
3925
|
+
await orch.reconcile(void 0, { reclaimStaleTurns: false });
|
|
3926
|
+
const server = new McpServer({
|
|
3927
|
+
name: "sideboard",
|
|
3928
|
+
version: "0.1.0"
|
|
3929
|
+
});
|
|
3930
|
+
server.tool(
|
|
3931
|
+
"list_workspaces",
|
|
3932
|
+
"List registered Sideboard workspaces (repos). Each line is name, path, and github:owner/repo when resolvable \u2014 use path as repoPath for list_branches/list_prs/list_issues/create_thread.",
|
|
3933
|
+
{},
|
|
3934
|
+
async () => {
|
|
3935
|
+
const workspaces = orch.listWorkspaces();
|
|
3936
|
+
const lines = await Promise.all(
|
|
3937
|
+
workspaces.map(async (w) => {
|
|
3938
|
+
const slug = await resolveGithubRepoSlug(w.path).catch(() => null);
|
|
3939
|
+
return slug ? `${w.name} ${w.path} github:${slug}` : `${w.name} ${w.path}`;
|
|
3940
|
+
})
|
|
3941
|
+
);
|
|
3942
|
+
return {
|
|
3943
|
+
content: [
|
|
3944
|
+
{ type: "text", text: lines.join("\n") || "(no workspaces)" }
|
|
3945
|
+
]
|
|
3946
|
+
};
|
|
3947
|
+
}
|
|
3948
|
+
);
|
|
3949
|
+
server.tool(
|
|
3950
|
+
"list_threads",
|
|
3951
|
+
"List Sideboard threads across all workspaces (one summary line each \u2014 token-frugal). Each line ends with sideboard://thread/<id> \u2014 use that URL in markdown links so the UI can open the chat.",
|
|
3952
|
+
{},
|
|
3953
|
+
async () => {
|
|
3954
|
+
const threads = orch.getThreads(true);
|
|
3955
|
+
const lines = threads.map((t) => {
|
|
3956
|
+
const repo = t.repoPath === GLOBAL_WORKSPACE_ID ? "Orchestration" : basename3(t.repoPath) || t.repoPath;
|
|
3957
|
+
return `${t.id.slice(0, 8)} ${t.status.padEnd(9)} ${t.agent.padEnd(8)} ${repo} ${t.sourceType}:${t.sourceRef} ${t.title} sideboard://thread/${t.id}${t.devPort ? ` http://localhost:${t.devPort}` : ""}`;
|
|
3958
|
+
});
|
|
3959
|
+
return {
|
|
3960
|
+
content: [{ type: "text", text: lines.join("\n") || "(no threads)" }]
|
|
3961
|
+
};
|
|
3962
|
+
}
|
|
3963
|
+
);
|
|
3964
|
+
server.tool(
|
|
3965
|
+
"get_thread",
|
|
3966
|
+
"Get a compact thread summary by id/ref",
|
|
3967
|
+
{ ref: z.string() },
|
|
3968
|
+
async ({ ref }) => {
|
|
3969
|
+
const t = orch.getThread(ref);
|
|
3970
|
+
if (!t) {
|
|
3971
|
+
return { content: [{ type: "text", text: `Thread not found: ${ref}` }], isError: true };
|
|
3972
|
+
}
|
|
3973
|
+
const summary = {
|
|
3974
|
+
id: t.id,
|
|
3975
|
+
title: t.title,
|
|
3976
|
+
status: t.status,
|
|
3977
|
+
agent: t.agent,
|
|
3978
|
+
sourceType: t.sourceType,
|
|
3979
|
+
sourceRef: t.sourceRef,
|
|
3980
|
+
branchName: t.branchName,
|
|
3981
|
+
worktreePath: t.worktreePath,
|
|
3982
|
+
sessionId: t.sessionId,
|
|
3983
|
+
queueLength: t.queue.length,
|
|
3984
|
+
messageCount: t.messages.length,
|
|
3985
|
+
devPort: t.devPort,
|
|
3986
|
+
prUrl: t.prUrl,
|
|
3987
|
+
lastError: t.lastError ?? null
|
|
3988
|
+
};
|
|
3989
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
3990
|
+
}
|
|
3991
|
+
);
|
|
3992
|
+
server.tool(
|
|
3993
|
+
"create_thread",
|
|
3994
|
+
"Create a new worktree thread (chat) from branch, pr, or ticket. Pass repoPath from list_workspaces and parentThreadId when spawning from an orchestrator. Then use send_to_thread to chat.",
|
|
3995
|
+
{
|
|
3996
|
+
sourceType: z.enum(["branch", "pr", "ticket"]),
|
|
3997
|
+
sourceRef: z.string(),
|
|
3998
|
+
agent: z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
|
|
3999
|
+
repoPath: z.string(),
|
|
4000
|
+
title: z.string().optional(),
|
|
4001
|
+
parentThreadId: z.string().optional()
|
|
4002
|
+
},
|
|
4003
|
+
async (args) => {
|
|
4004
|
+
if (args.parentThreadId) {
|
|
4005
|
+
const children = orch.getThreads(true).filter((t) => t.parentThreadId === args.parentThreadId);
|
|
4006
|
+
if (children.length >= MAX_ORCH_THREADS) {
|
|
4007
|
+
return {
|
|
4008
|
+
content: [
|
|
4009
|
+
{
|
|
4010
|
+
type: "text",
|
|
4011
|
+
text: `Thread-creation cap (${MAX_ORCH_THREADS}) reached for this orchestration session`
|
|
4012
|
+
}
|
|
4013
|
+
],
|
|
4014
|
+
isError: true
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
const thread = await orch.createThread({
|
|
4019
|
+
sourceType: args.sourceType,
|
|
4020
|
+
sourceRef: args.sourceRef,
|
|
4021
|
+
agent: args.agent,
|
|
4022
|
+
repoPath: args.repoPath,
|
|
4023
|
+
title: args.title,
|
|
4024
|
+
parentThreadId: args.parentThreadId ?? null
|
|
4025
|
+
});
|
|
4026
|
+
return {
|
|
4027
|
+
content: [
|
|
4028
|
+
{
|
|
4029
|
+
type: "text",
|
|
4030
|
+
text: JSON.stringify({
|
|
4031
|
+
id: thread.id,
|
|
4032
|
+
title: thread.title,
|
|
4033
|
+
branchName: thread.branchName,
|
|
4034
|
+
worktreePath: thread.worktreePath,
|
|
4035
|
+
status: thread.status,
|
|
4036
|
+
link: `sideboard://thread/${thread.id}`
|
|
4037
|
+
})
|
|
4038
|
+
}
|
|
4039
|
+
]
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
);
|
|
4043
|
+
server.tool(
|
|
4044
|
+
"send_to_thread",
|
|
4045
|
+
"Queue a prompt on a worktree thread chat (runs under concurrency cap). Use after create_thread to start or continue a conversation. Set force_stop=true to interrupt an in-flight/queued turn (kill + clear queue) before queueing this prompt \u2014 use when the thread is mid-turn or has stale queued prompts you need to replace.",
|
|
4046
|
+
{
|
|
4047
|
+
ref: z.string(),
|
|
4048
|
+
prompt: z.string(),
|
|
4049
|
+
force_stop: z.boolean().optional()
|
|
4050
|
+
},
|
|
4051
|
+
async ({ ref, prompt, force_stop }) => {
|
|
4052
|
+
if (force_stop) {
|
|
4053
|
+
const existing = orch.getThread(ref);
|
|
4054
|
+
if (existing) {
|
|
4055
|
+
orch.stop(ref, { clearQueue: true });
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
const thread = await orch.send(ref, prompt);
|
|
4059
|
+
return {
|
|
4060
|
+
content: [
|
|
4061
|
+
{
|
|
4062
|
+
type: "text",
|
|
4063
|
+
text: JSON.stringify({
|
|
4064
|
+
id: thread.id,
|
|
4065
|
+
status: thread.status,
|
|
4066
|
+
queueLength: thread.queue.length,
|
|
4067
|
+
forceStopped: Boolean(force_stop)
|
|
4068
|
+
})
|
|
4069
|
+
}
|
|
4070
|
+
]
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
);
|
|
4074
|
+
server.tool(
|
|
4075
|
+
"wait_for_turn",
|
|
4076
|
+
"Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread to read the agent reply.",
|
|
4077
|
+
{
|
|
4078
|
+
ref: z.string(),
|
|
4079
|
+
timeoutMs: z.number().optional()
|
|
4080
|
+
},
|
|
4081
|
+
async ({ ref, timeoutMs }) => {
|
|
4082
|
+
const thread = await orch.waitForTurn(ref, timeoutMs ?? 6e5);
|
|
4083
|
+
const result = orch.getTurnResult(thread.id);
|
|
4084
|
+
return {
|
|
4085
|
+
content: [
|
|
4086
|
+
{
|
|
4087
|
+
type: "text",
|
|
4088
|
+
text: JSON.stringify({
|
|
4089
|
+
id: thread.id,
|
|
4090
|
+
status: result.status,
|
|
4091
|
+
text: result.text
|
|
4092
|
+
})
|
|
4093
|
+
}
|
|
4094
|
+
]
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
);
|
|
4098
|
+
server.tool(
|
|
4099
|
+
"get_turn_result",
|
|
4100
|
+
"Final assistant message only (not full transcript)",
|
|
4101
|
+
{ ref: z.string() },
|
|
4102
|
+
async ({ ref }) => {
|
|
4103
|
+
const result = orch.getTurnResult(ref);
|
|
4104
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
4105
|
+
}
|
|
4106
|
+
);
|
|
4107
|
+
server.tool(
|
|
4108
|
+
"stop_thread",
|
|
4109
|
+
"Force-stop a thread: kill any in-flight agent turn AND clear queued prompts so drainQueue cannot continue. Does not archive the worktree. Optional force defaults to true.",
|
|
4110
|
+
{
|
|
4111
|
+
ref: z.string(),
|
|
4112
|
+
force: z.boolean().optional()
|
|
4113
|
+
},
|
|
4114
|
+
async ({ ref, force }) => {
|
|
4115
|
+
const t = orch.getThread(ref);
|
|
4116
|
+
if (!t) {
|
|
4117
|
+
return {
|
|
4118
|
+
content: [{ type: "text", text: `Thread not found: ${ref}` }],
|
|
4119
|
+
isError: true
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
const clearQueue = force !== false;
|
|
4123
|
+
const hadQueued = t.queue.length > 0;
|
|
4124
|
+
const stopped = orch.stop(ref, { clearQueue });
|
|
4125
|
+
return {
|
|
4126
|
+
content: [
|
|
4127
|
+
{
|
|
4128
|
+
type: "text",
|
|
4129
|
+
text: JSON.stringify({
|
|
4130
|
+
id: stopped.id,
|
|
4131
|
+
status: stopped.status,
|
|
4132
|
+
clearedQueue: clearQueue && hadQueued
|
|
4133
|
+
})
|
|
4134
|
+
}
|
|
4135
|
+
]
|
|
4136
|
+
};
|
|
4137
|
+
}
|
|
4138
|
+
);
|
|
4139
|
+
server.tool(
|
|
4140
|
+
"archive_thread",
|
|
4141
|
+
"Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Cannot archive the cloud coordinator \u2014 use the Sideboard UI for that.",
|
|
4142
|
+
{ ref: z.string() },
|
|
4143
|
+
async ({ ref }) => {
|
|
4144
|
+
const t = orch.getThread(ref);
|
|
4145
|
+
if (!t) {
|
|
4146
|
+
return {
|
|
4147
|
+
content: [{ type: "text", text: `Thread not found: ${ref}` }],
|
|
4148
|
+
isError: true
|
|
4149
|
+
};
|
|
4150
|
+
}
|
|
4151
|
+
const blocked = mcpArchiveBlockedReason(t);
|
|
4152
|
+
if (blocked) {
|
|
4153
|
+
return {
|
|
4154
|
+
content: [{ type: "text", text: blocked }],
|
|
4155
|
+
isError: true
|
|
4156
|
+
};
|
|
4157
|
+
}
|
|
4158
|
+
const archived = await orch.archive(ref);
|
|
4159
|
+
return {
|
|
4160
|
+
content: [
|
|
4161
|
+
{
|
|
4162
|
+
type: "text",
|
|
4163
|
+
text: JSON.stringify({
|
|
4164
|
+
id: archived.id,
|
|
4165
|
+
status: archived.status
|
|
4166
|
+
})
|
|
4167
|
+
}
|
|
4168
|
+
]
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
);
|
|
4172
|
+
server.tool(
|
|
4173
|
+
"restore_thread",
|
|
4174
|
+
"Restore an archived thread (recreates worktree from branch when needed)",
|
|
4175
|
+
{ ref: z.string() },
|
|
4176
|
+
async ({ ref }) => {
|
|
4177
|
+
try {
|
|
4178
|
+
const restored = await orch.restore(ref);
|
|
4179
|
+
return {
|
|
4180
|
+
content: [
|
|
4181
|
+
{
|
|
4182
|
+
type: "text",
|
|
4183
|
+
text: JSON.stringify({
|
|
4184
|
+
id: restored.id,
|
|
4185
|
+
status: restored.status,
|
|
4186
|
+
worktreePath: restored.worktreePath
|
|
4187
|
+
})
|
|
4188
|
+
}
|
|
4189
|
+
]
|
|
4190
|
+
};
|
|
4191
|
+
} catch (err) {
|
|
4192
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4193
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
);
|
|
4197
|
+
server.tool(
|
|
4198
|
+
"get_diff",
|
|
4199
|
+
"Compact diff summary (capped hunks, paginated)",
|
|
4200
|
+
{
|
|
4201
|
+
ref: z.string(),
|
|
4202
|
+
maxFiles: z.number().optional()
|
|
4203
|
+
},
|
|
4204
|
+
async ({ ref, maxFiles }) => {
|
|
4205
|
+
const summary = await orch.diffSummary(ref);
|
|
4206
|
+
return {
|
|
4207
|
+
content: [
|
|
4208
|
+
{
|
|
4209
|
+
type: "text",
|
|
4210
|
+
text: JSON.stringify({
|
|
4211
|
+
...summary,
|
|
4212
|
+
files: summary.files.slice(0, maxFiles ?? 10)
|
|
4213
|
+
})
|
|
4214
|
+
}
|
|
4215
|
+
]
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
);
|
|
4219
|
+
server.tool(
|
|
4220
|
+
"run_dev_script",
|
|
4221
|
+
"Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",
|
|
4222
|
+
{
|
|
4223
|
+
ref: z.string(),
|
|
4224
|
+
name: z.string().optional()
|
|
4225
|
+
},
|
|
4226
|
+
async ({ ref, name }) => {
|
|
4227
|
+
const result = await orch.startDev(ref, name);
|
|
4228
|
+
return {
|
|
4229
|
+
content: [
|
|
4230
|
+
{
|
|
4231
|
+
type: "text",
|
|
4232
|
+
text: JSON.stringify({
|
|
4233
|
+
port: result.port,
|
|
4234
|
+
scriptName: result.scriptName,
|
|
4235
|
+
ports: result.ports,
|
|
4236
|
+
url: `http://localhost:${result.port}`
|
|
4237
|
+
})
|
|
4238
|
+
}
|
|
4239
|
+
]
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
);
|
|
4243
|
+
server.tool(
|
|
4244
|
+
"list_run_scripts",
|
|
4245
|
+
"List named run scripts available for a thread",
|
|
4246
|
+
{ ref: z.string() },
|
|
4247
|
+
async ({ ref }) => {
|
|
4248
|
+
const scripts = orch.listThreadRunScripts(ref);
|
|
4249
|
+
const active = orch.getActiveRuns(ref);
|
|
4250
|
+
return {
|
|
4251
|
+
content: [
|
|
4252
|
+
{
|
|
4253
|
+
type: "text",
|
|
4254
|
+
text: JSON.stringify({ scripts, active }, null, 2)
|
|
4255
|
+
}
|
|
4256
|
+
]
|
|
4257
|
+
};
|
|
4258
|
+
}
|
|
4259
|
+
);
|
|
4260
|
+
server.tool(
|
|
4261
|
+
"stop_dev_script",
|
|
4262
|
+
"Stop a running script for a thread (all scripts if name omitted)",
|
|
4263
|
+
{
|
|
4264
|
+
ref: z.string(),
|
|
4265
|
+
name: z.string().optional()
|
|
4266
|
+
},
|
|
4267
|
+
async ({ ref, name }) => {
|
|
4268
|
+
orch.stopDev(ref, name);
|
|
4269
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
4270
|
+
}
|
|
4271
|
+
);
|
|
4272
|
+
server.tool(
|
|
4273
|
+
"run_setup",
|
|
4274
|
+
"Re-run workspace setup (Sideboard/Conductor settings or .cursor/worktrees.json)",
|
|
4275
|
+
{ ref: z.string() },
|
|
4276
|
+
async ({ ref }) => {
|
|
4277
|
+
const result = await orch.runSetup(ref);
|
|
4278
|
+
return {
|
|
4279
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
);
|
|
4283
|
+
server.tool(
|
|
4284
|
+
"add_workspace",
|
|
4285
|
+
"Register a git repo as a Sideboard workspace",
|
|
4286
|
+
{ repoPath: z.string() },
|
|
4287
|
+
async ({ repoPath }) => {
|
|
4288
|
+
const ws = await orch.addWorkspace(repoPath);
|
|
4289
|
+
return { content: [{ type: "text", text: JSON.stringify(ws) }] };
|
|
4290
|
+
}
|
|
4291
|
+
);
|
|
4292
|
+
server.tool(
|
|
4293
|
+
"remove_workspace",
|
|
4294
|
+
"Unregister a Sideboard workspace (does not archive threads)",
|
|
4295
|
+
{ repoPath: z.string() },
|
|
4296
|
+
async ({ repoPath }) => {
|
|
4297
|
+
orch.removeWorkspace(repoPath);
|
|
4298
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
4299
|
+
}
|
|
4300
|
+
);
|
|
4301
|
+
server.tool(
|
|
4302
|
+
"fanout",
|
|
4303
|
+
"Best-of-n: create one thread per agent with the same prompt (parallel attempts)",
|
|
4304
|
+
{
|
|
4305
|
+
prompt: z.string(),
|
|
4306
|
+
agents: z.array(z.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
|
|
4307
|
+
repoPath: z.string(),
|
|
4308
|
+
sourceType: z.enum(["branch", "pr", "ticket"]).optional(),
|
|
4309
|
+
sourceRef: z.string().optional(),
|
|
4310
|
+
title: z.string().optional()
|
|
4311
|
+
},
|
|
4312
|
+
async (args) => {
|
|
4313
|
+
const threads = await orch.bestOfN(args);
|
|
4314
|
+
return {
|
|
4315
|
+
content: [
|
|
4316
|
+
{
|
|
4317
|
+
type: "text",
|
|
4318
|
+
text: JSON.stringify(
|
|
4319
|
+
threads.map((t) => ({
|
|
4320
|
+
id: t.id,
|
|
4321
|
+
agent: t.agent,
|
|
4322
|
+
branchName: t.branchName,
|
|
4323
|
+
worktreePath: t.worktreePath
|
|
4324
|
+
})),
|
|
4325
|
+
null,
|
|
4326
|
+
2
|
|
4327
|
+
)
|
|
4328
|
+
}
|
|
4329
|
+
]
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
);
|
|
4333
|
+
server.tool(
|
|
4334
|
+
"preview_land",
|
|
4335
|
+
"Preview push+PR land (does NOT push). Use before create_draft_pr.",
|
|
4336
|
+
{ ref: z.string() },
|
|
4337
|
+
async ({ ref }) => {
|
|
4338
|
+
const preview = await orch.previewLand(ref);
|
|
4339
|
+
return { content: [{ type: "text", text: JSON.stringify(preview, null, 2) }] };
|
|
4340
|
+
}
|
|
4341
|
+
);
|
|
4342
|
+
server.tool(
|
|
4343
|
+
"create_draft_pr",
|
|
4344
|
+
"Commit dirty changes if needed, push the thread branch, and create/update a DRAFT GitHub PR. Ready-for-review / non-draft land stays human-only (CLI/app confirm_land). Prefer this when the orchestrator should open a PR itself; alternatively send_to_thread asking the worktree agent to run `gh pr create --draft`.",
|
|
4345
|
+
{ ref: z.string() },
|
|
4346
|
+
async ({ ref }) => {
|
|
4347
|
+
try {
|
|
4348
|
+
const result = await orch.confirmLand(ref, { draft: true });
|
|
4349
|
+
return {
|
|
4350
|
+
content: [
|
|
4351
|
+
{
|
|
4352
|
+
type: "text",
|
|
4353
|
+
text: JSON.stringify(
|
|
4354
|
+
{
|
|
4355
|
+
prUrl: result.prUrl,
|
|
4356
|
+
pushed: result.pushed,
|
|
4357
|
+
committed: result.committed,
|
|
4358
|
+
draft: true
|
|
4359
|
+
},
|
|
4360
|
+
null,
|
|
4361
|
+
2
|
|
4362
|
+
)
|
|
4363
|
+
}
|
|
4364
|
+
]
|
|
4365
|
+
};
|
|
4366
|
+
} catch (err) {
|
|
4367
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4368
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
);
|
|
4372
|
+
server.tool(
|
|
4373
|
+
"list_branches",
|
|
4374
|
+
"List git branches in a registered workspace. Pass repoPath from list_workspaces (unmerged into the default branch by default \u2014 for create_thread sourceType=branch).",
|
|
4375
|
+
{
|
|
4376
|
+
repoPath: z.string(),
|
|
4377
|
+
unmergedOnly: z.boolean().optional()
|
|
4378
|
+
},
|
|
4379
|
+
async ({ repoPath, unmergedOnly }) => {
|
|
4380
|
+
const root = await resolveRepoRoot(repoPath);
|
|
4381
|
+
const branches = await listBranches(root, {
|
|
4382
|
+
unmergedOnly: unmergedOnly !== false
|
|
4383
|
+
});
|
|
4384
|
+
return {
|
|
4385
|
+
content: [
|
|
4386
|
+
{
|
|
4387
|
+
type: "text",
|
|
4388
|
+
text: branches.map((b) => `${b.current ? "*" : " "} ${b.name}`).join("\n")
|
|
4389
|
+
}
|
|
4390
|
+
]
|
|
4391
|
+
};
|
|
4392
|
+
}
|
|
4393
|
+
);
|
|
4394
|
+
server.tool(
|
|
4395
|
+
"list_prs",
|
|
4396
|
+
"List open GitHub PRs for a registered workspace. Pass repoPath from list_workspaces (uses gh against that repo remote). Then create_thread with sourceType=pr.",
|
|
4397
|
+
{ repoPath: z.string() },
|
|
4398
|
+
async ({ repoPath }) => {
|
|
4399
|
+
const root = await resolveRepoRoot(repoPath);
|
|
4400
|
+
const prs = await listPrs(root);
|
|
4401
|
+
return {
|
|
4402
|
+
content: [
|
|
4403
|
+
{
|
|
4404
|
+
type: "text",
|
|
4405
|
+
text: prs.map(
|
|
4406
|
+
(p) => `#${p.number} ${p.title} (${p.headRefName})${p.isCrossRepository ? " [fork]" : ""}`
|
|
4407
|
+
).join("\n")
|
|
4408
|
+
}
|
|
4409
|
+
]
|
|
4410
|
+
};
|
|
4411
|
+
}
|
|
4412
|
+
);
|
|
4413
|
+
server.tool(
|
|
4414
|
+
"list_issues",
|
|
4415
|
+
"List issues from Sideboard Account connections (Linear API or GitHub Issues; Linear\u2192GitHub fallback when Linear is not connected). Pass repoPath from list_workspaces \u2014 GitHub Issues are scoped to that repo. Then create_thread with sourceType=ticket.",
|
|
4416
|
+
{ repoPath: z.string() },
|
|
4417
|
+
async ({ repoPath }) => {
|
|
4418
|
+
const root = await resolveRepoRoot(repoPath);
|
|
4419
|
+
const result = await listIssues(root);
|
|
4420
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4421
|
+
}
|
|
4422
|
+
);
|
|
4423
|
+
server.tool(
|
|
4424
|
+
"list_linear_issues",
|
|
4425
|
+
"Deprecated: prefer list_issues. Lists assigned Linear issues via the chosen agent MCP connector",
|
|
4426
|
+
{
|
|
4427
|
+
agent: z.enum(["claude", "codex", "opencode"]),
|
|
4428
|
+
repoPath: z.string()
|
|
4429
|
+
},
|
|
4430
|
+
async ({ agent, repoPath }) => {
|
|
4431
|
+
const issues = await listLinearIssues(agent, repoPath);
|
|
4432
|
+
return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
|
|
4433
|
+
}
|
|
4434
|
+
);
|
|
4435
|
+
const transport = new StdioServerTransport();
|
|
4436
|
+
await server.connect(transport);
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
export {
|
|
4440
|
+
listLinearIssuesDirect,
|
|
4441
|
+
validateLinearApiKey,
|
|
4442
|
+
listGitHubIssues,
|
|
4443
|
+
listIssues,
|
|
4444
|
+
toolDetail,
|
|
4445
|
+
toolDescription,
|
|
4446
|
+
toolFilePath,
|
|
4447
|
+
applyAgentEvent,
|
|
4448
|
+
partsToAssistantText,
|
|
4449
|
+
stripBrightsyNdjsonNoise,
|
|
4450
|
+
isBrightsyNdjsonLine,
|
|
4451
|
+
finalizeParts,
|
|
4452
|
+
normalizeParseResult,
|
|
4453
|
+
mergeUsage,
|
|
4454
|
+
totalTokens,
|
|
4455
|
+
spawnAgentTurn,
|
|
4456
|
+
formatRenameBranchDirective,
|
|
4457
|
+
formatWorktreeDirective,
|
|
4458
|
+
loadAgentInstructions,
|
|
4459
|
+
formatAgentInstructions,
|
|
4460
|
+
withAgentInstructions,
|
|
4461
|
+
detectAgents,
|
|
4462
|
+
requireAgent,
|
|
4463
|
+
readWorktreeInclude,
|
|
4464
|
+
resolveFilesToCopy,
|
|
4465
|
+
copyConfiguredFiles,
|
|
4466
|
+
captureLoginEnv,
|
|
4467
|
+
buildWorkspaceScriptEnv,
|
|
4468
|
+
runSetupScript,
|
|
4469
|
+
runArchiveScript,
|
|
4470
|
+
listRunScripts,
|
|
4471
|
+
getDefaultRunScript,
|
|
4472
|
+
getRunScript,
|
|
4473
|
+
getRunMode,
|
|
4474
|
+
allocatePort,
|
|
4475
|
+
allocatePortRange,
|
|
4476
|
+
startDevServer,
|
|
4477
|
+
runCursorWorktreeSetup,
|
|
4478
|
+
hasCursorWorktreeSetup,
|
|
4479
|
+
inspectGitWorktree,
|
|
4480
|
+
initializeGitRepository,
|
|
4481
|
+
captureTurnBaseline,
|
|
4482
|
+
listBranchCommits,
|
|
4483
|
+
getDiff,
|
|
4484
|
+
listWorktreeFiles,
|
|
4485
|
+
readWorktreeFile,
|
|
4486
|
+
writeWorktreeFile,
|
|
4487
|
+
getDiffSummary,
|
|
4488
|
+
discoverSkills,
|
|
4489
|
+
readSkillBody,
|
|
4490
|
+
expandComposerPrompt,
|
|
4491
|
+
summarizeConversation,
|
|
4492
|
+
extractiveSummary,
|
|
4493
|
+
CONTEXT_COMPACT_CHARS,
|
|
4494
|
+
CONTEXT_KEEP_RECENT_CHARS,
|
|
4495
|
+
CONTEXT_KEEP_RECENT_MESSAGES,
|
|
4496
|
+
CONTEXT_MIN_MESSAGES,
|
|
4497
|
+
estimateMessageChars,
|
|
4498
|
+
estimateThreadChars,
|
|
4499
|
+
shouldCompactContext,
|
|
4500
|
+
splitForCompaction,
|
|
4501
|
+
formatMessagesAsTranscript,
|
|
4502
|
+
buildSessionSeed,
|
|
4503
|
+
applyCompaction,
|
|
4504
|
+
maybeCompactContext,
|
|
4505
|
+
previewLand,
|
|
4506
|
+
confirmLand,
|
|
4507
|
+
createThread,
|
|
4508
|
+
listLinearIssues,
|
|
4509
|
+
sameWorktreePath,
|
|
4510
|
+
takenTeamSlugsForChatTab,
|
|
4511
|
+
threadsSharingWorktree,
|
|
4512
|
+
formatTranscriptMarkdown,
|
|
4513
|
+
forkMessageSlice,
|
|
4514
|
+
buildForkTranscriptAttachment,
|
|
4515
|
+
createChatTab,
|
|
4516
|
+
forkChatTab,
|
|
4517
|
+
forkThreadWorktree,
|
|
4518
|
+
resolveConductorCursorAgentId,
|
|
4519
|
+
adoptThread,
|
|
4520
|
+
conductorDbPath,
|
|
4521
|
+
listConductorWorkspaces,
|
|
4522
|
+
importConductorWorkspace,
|
|
4523
|
+
importConductorWorkspaceAsync,
|
|
4524
|
+
findOrphanWorktrees,
|
|
4525
|
+
cleanupOrphanWorktrees,
|
|
4526
|
+
shouldRunWorktreeCleanup,
|
|
4527
|
+
worktreeCleanupSettings,
|
|
4528
|
+
applyThreadIntoMain,
|
|
4529
|
+
cloneRepoIntoSideboard,
|
|
4530
|
+
Orchestrator,
|
|
4531
|
+
getOrchestrator,
|
|
4532
|
+
startOrchestration,
|
|
4533
|
+
startMcpServer
|
|
4534
|
+
};
|