cortex-agents 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.opencode/agents/build.md +179 -21
  2. package/.opencode/agents/debug.md +97 -11
  3. package/.opencode/agents/devops.md +75 -7
  4. package/.opencode/agents/fullstack.md +89 -1
  5. package/.opencode/agents/plan.md +83 -6
  6. package/.opencode/agents/security.md +60 -1
  7. package/.opencode/agents/testing.md +45 -1
  8. package/README.md +292 -356
  9. package/dist/cli.js +230 -65
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +10 -5
  12. package/dist/tools/branch.d.ts +7 -1
  13. package/dist/tools/branch.d.ts.map +1 -1
  14. package/dist/tools/branch.js +88 -53
  15. package/dist/tools/cortex.d.ts +19 -0
  16. package/dist/tools/cortex.d.ts.map +1 -1
  17. package/dist/tools/cortex.js +110 -1
  18. package/dist/tools/session.d.ts.map +1 -1
  19. package/dist/tools/session.js +3 -1
  20. package/dist/tools/task.d.ts +20 -0
  21. package/dist/tools/task.d.ts.map +1 -0
  22. package/dist/tools/task.js +310 -0
  23. package/dist/tools/worktree.d.ts +42 -2
  24. package/dist/tools/worktree.d.ts.map +1 -1
  25. package/dist/tools/worktree.js +573 -98
  26. package/dist/utils/plan-extract.d.ts +37 -0
  27. package/dist/utils/plan-extract.d.ts.map +1 -0
  28. package/dist/utils/plan-extract.js +137 -0
  29. package/dist/utils/propagate.d.ts +22 -0
  30. package/dist/utils/propagate.d.ts.map +1 -0
  31. package/dist/utils/propagate.js +64 -0
  32. package/dist/utils/shell.d.ts +53 -0
  33. package/dist/utils/shell.d.ts.map +1 -0
  34. package/dist/utils/shell.js +118 -0
  35. package/dist/utils/terminal.d.ts +66 -0
  36. package/dist/utils/terminal.d.ts.map +1 -0
  37. package/dist/utils/terminal.js +627 -0
  38. package/dist/utils/worktree-detect.d.ts +20 -0
  39. package/dist/utils/worktree-detect.d.ts.map +1 -0
  40. package/dist/utils/worktree-detect.js +43 -0
  41. package/package.json +13 -9
@@ -1,57 +1,89 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
- const WORKTREE_ROOT = "../.worktrees";
5
- export const create = tool({
6
- description: "Create a new git worktree for isolated development. Worktrees are created in ../.worktrees/",
7
- args: {
8
- name: tool.schema
9
- .string()
10
- .describe("Worktree name (e.g., 'auth-feature', 'login-bugfix')"),
11
- type: tool.schema
12
- .enum(["feature", "bugfix", "hotfix", "refactor", "spike", "docs", "test"])
13
- .describe("Type of work - determines branch prefix"),
14
- },
15
- async execute(args, context) {
16
- const { name, type } = args;
17
- const branchName = `${type}/${name}`;
18
- const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
19
- const absoluteWorktreePath = path.resolve(worktreePath);
20
- // Check if we're in a git repository
21
- try {
22
- const gitCheck = await Bun.$ `git rev-parse --git-dir`.cwd(context.worktree).text();
23
- if (!gitCheck.trim()) {
24
- return "✗ Error: Not in a git repository";
4
+ import { propagatePlan } from "../utils/propagate.js";
5
+ import { git, which, kill, spawn as shellSpawn } from "../utils/shell.js";
6
+ import { detectDriver, closeSession, writeSession, readSession, } from "../utils/terminal.js";
7
+ const WORKTREE_ROOT = ".worktrees";
8
+ /**
9
+ * Factory function that creates the worktree_create tool with access
10
+ * to the OpenCode client for toast notifications.
11
+ */
12
+ export function createCreate(client) {
13
+ return tool({
14
+ description: "Create a new git worktree for isolated development. Worktrees are created in .worktrees/ at the project root.",
15
+ args: {
16
+ name: tool.schema
17
+ .string()
18
+ .describe("Worktree name (e.g., 'auth-feature', 'login-bugfix')"),
19
+ type: tool.schema
20
+ .enum(["feature", "bugfix", "hotfix", "refactor", "spike", "docs", "test"])
21
+ .describe("Type of work - determines branch prefix"),
22
+ },
23
+ async execute(args, context) {
24
+ const { name, type } = args;
25
+ const branchName = `${type}/${name}`;
26
+ const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
27
+ const absoluteWorktreePath = path.resolve(worktreePath);
28
+ // Check if we're in a git repository
29
+ try {
30
+ await git(context.worktree, "rev-parse", "--git-dir");
25
31
  }
26
- }
27
- catch {
28
- return "✗ Error: Not in a git repository. Initialize git first.";
29
- }
30
- // Check if worktree already exists
31
- if (fs.existsSync(absoluteWorktreePath)) {
32
- return `✗ Error: Worktree already exists at ${absoluteWorktreePath}
32
+ catch {
33
+ return "✗ Error: Not in a git repository. Initialize git first.";
34
+ }
35
+ // Check if worktree already exists
36
+ if (fs.existsSync(absoluteWorktreePath)) {
37
+ return `✗ Error: Worktree already exists at ${absoluteWorktreePath}
33
38
 
34
39
  Use worktree_list to see existing worktrees.`;
35
- }
36
- // Create parent directory if needed
37
- const worktreeParent = path.dirname(absoluteWorktreePath);
38
- if (!fs.existsSync(worktreeParent)) {
39
- fs.mkdirSync(worktreeParent, { recursive: true });
40
- }
41
- // Create the worktree with a new branch
42
- try {
43
- await Bun.$ `git worktree add -b ${branchName} ${absoluteWorktreePath}`.cwd(context.worktree);
44
- }
45
- catch (error) {
46
- // Branch might already exist, try without -b
40
+ }
41
+ // Create parent directory if needed
42
+ const worktreeParent = path.dirname(absoluteWorktreePath);
43
+ if (!fs.existsSync(worktreeParent)) {
44
+ fs.mkdirSync(worktreeParent, { recursive: true });
45
+ }
46
+ // Create the worktree with a new branch
47
47
  try {
48
- await Bun.$ `git worktree add ${absoluteWorktreePath} ${branchName}`.cwd(context.worktree);
48
+ await git(context.worktree, "worktree", "add", "-b", branchName, absoluteWorktreePath);
49
49
  }
50
- catch (error2) {
51
- return `✗ Error creating worktree: ${error2.message || error2}`;
50
+ catch {
51
+ // Branch might already exist, try without -b
52
+ try {
53
+ await git(context.worktree, "worktree", "add", absoluteWorktreePath, branchName);
54
+ }
55
+ catch (error2) {
56
+ try {
57
+ await client.tui.showToast({
58
+ body: {
59
+ title: `Worktree: ${name}`,
60
+ message: `Failed to create: ${error2.message || error2}`,
61
+ variant: "error",
62
+ duration: 8000,
63
+ },
64
+ });
65
+ }
66
+ catch {
67
+ // Toast failure is non-fatal
68
+ }
69
+ return `✗ Error creating worktree: ${error2.message || error2}`;
70
+ }
52
71
  }
53
- }
54
- return `✓ Created worktree successfully
72
+ // Notify via toast
73
+ try {
74
+ await client.tui.showToast({
75
+ body: {
76
+ title: `Worktree: ${name}`,
77
+ message: `Created on branch ${branchName}`,
78
+ variant: "success",
79
+ duration: 4000,
80
+ },
81
+ });
82
+ }
83
+ catch {
84
+ // Toast failure is non-fatal
85
+ }
86
+ return `✓ Created worktree successfully
55
87
 
56
88
  Branch: ${branchName}
57
89
  Path: ${absoluteWorktreePath}
@@ -60,18 +92,19 @@ To work in this worktree:
60
92
  cd ${absoluteWorktreePath}
61
93
 
62
94
  Or use worktree_open to get a command to open a new terminal there.`;
63
- },
64
- });
95
+ },
96
+ });
97
+ }
65
98
  export const list = tool({
66
99
  description: "List all git worktrees for this project",
67
100
  args: {},
68
101
  async execute(args, context) {
69
102
  try {
70
- const result = await Bun.$ `git worktree list`.cwd(context.worktree).text();
71
- if (!result.trim()) {
103
+ const { stdout } = await git(context.worktree, "worktree", "list");
104
+ if (!stdout.trim()) {
72
105
  return "No worktrees found.";
73
106
  }
74
- const lines = result.trim().split("\n");
107
+ const lines = stdout.trim().split("\n");
75
108
  let output = "Git Worktrees:\n\n";
76
109
  for (const line of lines) {
77
110
  const parts = line.split(/\s+/);
@@ -91,64 +124,141 @@ export const list = tool({
91
124
  }
92
125
  },
93
126
  });
94
- export const remove = tool({
95
- description: "Remove a git worktree (after merging). Optionally deletes the branch.",
96
- args: {
97
- name: tool.schema.string().describe("Worktree name to remove"),
98
- deleteBranch: tool.schema
99
- .boolean()
100
- .optional()
101
- .describe("Also delete the associated branch (default: false)"),
102
- },
103
- async execute(args, context) {
104
- const { name, deleteBranch = false } = args;
105
- const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
106
- const absoluteWorktreePath = path.resolve(worktreePath);
107
- // Check if worktree exists
108
- if (!fs.existsSync(absoluteWorktreePath)) {
109
- return `✗ Error: Worktree not found at ${absoluteWorktreePath}
127
+ /**
128
+ * Factory function that creates the worktree_remove tool with access
129
+ * to the OpenCode client for toast notifications and PTY cleanup.
130
+ */
131
+ export function createRemove(client) {
132
+ return tool({
133
+ description: "Remove a git worktree (after merging). Optionally deletes the branch.",
134
+ args: {
135
+ name: tool.schema.string().describe("Worktree name to remove"),
136
+ deleteBranch: tool.schema
137
+ .boolean()
138
+ .optional()
139
+ .describe("Also delete the associated branch (default: false)"),
140
+ },
141
+ async execute(args, context) {
142
+ const { name, deleteBranch = false } = args;
143
+ const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
144
+ const absoluteWorktreePath = path.resolve(worktreePath);
145
+ // Check if worktree exists
146
+ if (!fs.existsSync(absoluteWorktreePath)) {
147
+ return `✗ Error: Worktree not found at ${absoluteWorktreePath}
110
148
 
111
149
  Use worktree_list to see existing worktrees.`;
112
- }
113
- // Get branch name before removing
114
- let branchName = "";
115
- try {
116
- branchName = await Bun.$ `git -C ${absoluteWorktreePath} branch --show-current`.text();
117
- branchName = branchName.trim();
118
- }
119
- catch {
120
- // Ignore error, branch detection is optional
121
- }
122
- // Remove the worktree
123
- try {
124
- await Bun.$ `git worktree remove ${absoluteWorktreePath}`.cwd(context.worktree);
125
- }
126
- catch (error) {
127
- // Try force remove if there are changes
150
+ }
151
+ // Get branch name before removing
152
+ let branchName = "";
153
+ try {
154
+ const { stdout } = await git(absoluteWorktreePath, "branch", "--show-current");
155
+ branchName = stdout.trim();
156
+ }
157
+ catch {
158
+ // Ignore error, branch detection is optional
159
+ }
160
+ // ── Close terminal session BEFORE git removes the directory ──
161
+ let closedSession = false;
162
+ const session = readSession(absoluteWorktreePath);
163
+ if (session) {
164
+ if (session.mode === "pty" && session.ptyId) {
165
+ // Close PTY session via OpenCode SDK
166
+ try {
167
+ await client.pty.remove({ path: { id: session.ptyId } });
168
+ closedSession = true;
169
+ }
170
+ catch {
171
+ // PTY may already be closed
172
+ }
173
+ }
174
+ else if (session.mode === "terminal") {
175
+ // Close terminal tab via driver
176
+ closedSession = await closeSession(session);
177
+ }
178
+ else if (session.mode === "background" && session.pid) {
179
+ closedSession = kill(session.pid);
180
+ }
181
+ // Fallback: kill PID if driver close failed
182
+ if (!closedSession && session.pid) {
183
+ kill(session.pid);
184
+ }
185
+ }
186
+ // Also clean up legacy .background-pid file
187
+ const bgPidFile = path.join(absoluteWorktreePath, ".cortex", ".background-pid");
188
+ if (fs.existsSync(bgPidFile)) {
189
+ try {
190
+ const bgData = JSON.parse(fs.readFileSync(bgPidFile, "utf-8"));
191
+ if (bgData.pid)
192
+ kill(bgData.pid);
193
+ }
194
+ catch {
195
+ // Ignore parse errors
196
+ }
197
+ }
198
+ // ── Remove the worktree ─────────────────────────────────────
128
199
  try {
129
- await Bun.$ `git worktree remove --force ${absoluteWorktreePath}`.cwd(context.worktree);
200
+ await git(context.worktree, "worktree", "remove", absoluteWorktreePath);
130
201
  }
131
- catch (error2) {
132
- return `✗ Error removing worktree: ${error2.message || error2}
202
+ catch {
203
+ // Try force remove if there are changes
204
+ try {
205
+ await git(context.worktree, "worktree", "remove", "--force", absoluteWorktreePath);
206
+ }
207
+ catch (error2) {
208
+ try {
209
+ await client.tui.showToast({
210
+ body: {
211
+ title: `Worktree: ${name}`,
212
+ message: `Failed to remove: ${error2.message || error2}`,
213
+ variant: "error",
214
+ duration: 8000,
215
+ },
216
+ });
217
+ }
218
+ catch {
219
+ // Toast failure is non-fatal
220
+ }
221
+ return `✗ Error removing worktree: ${error2.message || error2}
133
222
 
134
223
  The worktree may have uncommitted changes. Commit or stash them first.`;
224
+ }
135
225
  }
136
- }
137
- let output = `✓ Removed worktree at ${absoluteWorktreePath}`;
138
- // Delete branch if requested
139
- if (deleteBranch && branchName) {
226
+ let output = `✓ Removed worktree at ${absoluteWorktreePath}`;
227
+ if (closedSession && session) {
228
+ output += `\n✓ Closed ${session.mode === "pty" ? "PTY session" : `${session.terminal} tab`}`;
229
+ }
230
+ // Delete branch if requested
231
+ if (deleteBranch && branchName) {
232
+ try {
233
+ await git(context.worktree, "branch", "-d", branchName);
234
+ output += `\n✓ Deleted branch ${branchName}`;
235
+ }
236
+ catch (error) {
237
+ output += `\n⚠ Could not delete branch ${branchName}: ${error.message || error}`;
238
+ output += "\n (Branch may not be fully merged. Use git branch -D to force delete.)";
239
+ }
240
+ }
241
+ // Notify via toast
140
242
  try {
141
- await Bun.$ `git branch -d ${branchName}`.cwd(context.worktree);
142
- output += `\n✓ Deleted branch ${branchName}`;
243
+ const closedInfo = closedSession ? " + closed tab" : "";
244
+ await client.tui.showToast({
245
+ body: {
246
+ title: `Worktree: ${name}`,
247
+ message: branchName
248
+ ? `Removed worktree${closedInfo} (branch ${branchName} ${deleteBranch ? "deleted" : "kept"})`
249
+ : `Removed worktree${closedInfo}`,
250
+ variant: "success",
251
+ duration: 4000,
252
+ },
253
+ });
143
254
  }
144
- catch (error) {
145
- output += `\n⚠ Could not delete branch ${branchName}: ${error.message || error}`;
146
- output += "\n (Branch may not be fully merged. Use git branch -D to force delete.)";
255
+ catch {
256
+ // Toast failure is non-fatal
147
257
  }
148
- }
149
- return output;
150
- },
151
- });
258
+ return output;
259
+ },
260
+ });
261
+ }
152
262
  export const open = tool({
153
263
  description: "Get the command to open a new terminal window in a worktree directory",
154
264
  args: {
@@ -196,3 +306,368 @@ ${instructions}
196
306
  Worktree path: ${absoluteWorktreePath}`;
197
307
  },
198
308
  });
309
+ // ─── Terminal detection and tab management now in src/utils/terminal.ts ──────
310
+ /**
311
+ * Find the opencode binary path, checking common locations.
312
+ */
313
+ async function findOpencodeBinary() {
314
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
315
+ // Check well-known path first
316
+ const wellKnown = path.join(homeDir, ".opencode", "bin", "opencode");
317
+ if (fs.existsSync(wellKnown))
318
+ return wellKnown;
319
+ // Try which
320
+ const bin = await which("opencode");
321
+ if (bin && fs.existsSync(bin))
322
+ return bin;
323
+ return null;
324
+ }
325
+ /**
326
+ * Build the prompt string for the new OpenCode session.
327
+ */
328
+ function buildLaunchPrompt(planFilename, customPrompt) {
329
+ if (customPrompt)
330
+ return customPrompt;
331
+ if (planFilename) {
332
+ return `Load the plan at .cortex/plans/${planFilename} and implement all tasks listed in it. Follow the plan's technical approach and phases.`;
333
+ }
334
+ return "Check for plans in .cortex/plans/ and begin implementation. If no plan exists, analyze the codebase and suggest next steps.";
335
+ }
336
+ // ─── Mode A: New Terminal Tab (via driver system) ────────────────────────────
337
+ async function launchTerminalTab(client, worktreePath, branchName, opencodeBin, agent, prompt) {
338
+ /** Fire a toast notification for terminal launch results. */
339
+ const notify = async (message, variant = "success") => {
340
+ try {
341
+ await client.tui.showToast({
342
+ body: {
343
+ title: `Terminal: ${branchName}`,
344
+ message,
345
+ variant,
346
+ duration: variant === "error" ? 8000 : 4000,
347
+ },
348
+ });
349
+ }
350
+ catch {
351
+ // Toast failure is non-fatal
352
+ }
353
+ };
354
+ const driver = detectDriver();
355
+ const opts = {
356
+ worktreePath,
357
+ opencodeBin,
358
+ agent,
359
+ prompt,
360
+ branchName,
361
+ };
362
+ try {
363
+ const result = await driver.openTab(opts);
364
+ // Persist session for later cleanup (e.g., worktree_remove)
365
+ const session = {
366
+ terminal: driver.name,
367
+ platform: process.platform,
368
+ mode: "terminal",
369
+ branch: branchName,
370
+ agent,
371
+ worktreePath,
372
+ startedAt: new Date().toISOString(),
373
+ ...result,
374
+ };
375
+ writeSession(worktreePath, session);
376
+ await notify(`Opened ${driver.name} tab with agent '${agent}'`);
377
+ return `✓ Opened ${driver.name} tab in worktree\n\nBranch: ${branchName}\nTerminal: ${driver.name}`;
378
+ }
379
+ catch (error) {
380
+ await notify(`Could not open terminal: ${error.message || error}`, "error");
381
+ const innerCmd = `cd "${worktreePath}" && "${opencodeBin}" --agent ${agent}`;
382
+ return `✗ Could not open terminal (${driver.name}). Manual command:\n ${innerCmd}`;
383
+ }
384
+ }
385
+ // ─── Mode B: In-App PTY ─────────────────────────────────────────────────────
386
+ async function launchPty(client, worktreePath, branchName, opencodeBin, agent, prompt) {
387
+ try {
388
+ const response = await client.pty.create({
389
+ body: {
390
+ command: opencodeBin,
391
+ args: ["--agent", agent, "--prompt", prompt],
392
+ cwd: worktreePath,
393
+ title: `Worktree: ${branchName}`,
394
+ },
395
+ });
396
+ // Capture PTY ID and PID from response for later cleanup
397
+ const ptyId = response.data?.id;
398
+ const ptyPid = response.data?.pid;
399
+ // Persist session for cleanup on worktree_remove
400
+ writeSession(worktreePath, {
401
+ terminal: "pty",
402
+ platform: process.platform,
403
+ mode: "pty",
404
+ ptyId: ptyId ?? undefined,
405
+ pid: ptyPid ?? undefined,
406
+ branch: branchName,
407
+ agent,
408
+ worktreePath,
409
+ startedAt: new Date().toISOString(),
410
+ });
411
+ // Show toast for PTY launch
412
+ try {
413
+ await client.tui.showToast({
414
+ body: {
415
+ title: `PTY: ${branchName}`,
416
+ message: `Created in-app session with agent '${agent}'`,
417
+ variant: "success",
418
+ duration: 4000,
419
+ },
420
+ });
421
+ }
422
+ catch {
423
+ // Toast failure is non-fatal
424
+ }
425
+ return `✓ Created in-app PTY session for worktree
426
+
427
+ Branch: ${branchName}
428
+ Title: "Worktree: ${branchName}"
429
+
430
+ The PTY is running OpenCode with agent '${agent}' in the worktree.
431
+ Switch to it using OpenCode's terminal panel.`;
432
+ }
433
+ catch (error) {
434
+ try {
435
+ await client.tui.showToast({
436
+ body: {
437
+ title: `PTY: ${branchName}`,
438
+ message: `Failed to create session: ${error.message || error}`,
439
+ variant: "error",
440
+ duration: 8000,
441
+ },
442
+ });
443
+ }
444
+ catch {
445
+ // Toast failure is non-fatal
446
+ }
447
+ return `✗ Failed to create PTY session: ${error.message || error}
448
+
449
+ Falling back to manual command:
450
+ cd "${worktreePath}" && "${opencodeBin}" --agent ${agent}`;
451
+ }
452
+ }
453
+ // ─── Mode C: Background Session ─────────────────────────────────────────────
454
+ async function launchBackground(client, worktreePath, branchName, opencodeBin, agent, prompt) {
455
+ // Spawn opencode run as a detached background process
456
+ const proc = shellSpawn(opencodeBin, ["run", "--agent", agent, prompt], {
457
+ cwd: worktreePath,
458
+ stdio: "pipe",
459
+ env: {
460
+ ...process.env,
461
+ HOME: process.env.HOME || "",
462
+ PATH: process.env.PATH || "",
463
+ },
464
+ });
465
+ // Save PID for tracking (legacy format)
466
+ const cortexDir = path.join(worktreePath, ".cortex");
467
+ if (!fs.existsSync(cortexDir)) {
468
+ fs.mkdirSync(cortexDir, { recursive: true });
469
+ }
470
+ fs.writeFileSync(path.join(cortexDir, ".background-pid"), JSON.stringify({
471
+ pid: proc.pid,
472
+ branch: branchName,
473
+ agent,
474
+ startedAt: new Date().toISOString(),
475
+ }));
476
+ // Also write unified terminal session for cleanup on worktree_remove
477
+ writeSession(worktreePath, {
478
+ terminal: "background",
479
+ platform: process.platform,
480
+ mode: "background",
481
+ pid: proc.pid ?? undefined,
482
+ branch: branchName,
483
+ agent,
484
+ worktreePath,
485
+ startedAt: new Date().toISOString(),
486
+ });
487
+ // Show initial toast
488
+ try {
489
+ await client.tui.showToast({
490
+ body: {
491
+ title: `Background: ${branchName}`,
492
+ message: `Started background implementation with agent '${agent}'`,
493
+ variant: "info",
494
+ duration: 5000,
495
+ },
496
+ });
497
+ }
498
+ catch {
499
+ // Toast failure is non-fatal
500
+ }
501
+ // Monitor completion in background (fire-and-forget)
502
+ monitorBackgroundProcess(proc, client, branchName, worktreePath);
503
+ return `✓ Launched background implementation
504
+
505
+ Branch: ${branchName}
506
+ PID: ${proc.pid}
507
+ Agent: ${agent}
508
+ Working in: ${worktreePath}
509
+
510
+ The AI is implementing in the background. You'll get a toast notification
511
+ when it completes or fails.
512
+
513
+ PID tracking file: ${path.join(cortexDir, ".background-pid")}
514
+
515
+ To check worktree status later:
516
+ git -C "${worktreePath}" status
517
+ git -C "${worktreePath}" log --oneline -5`;
518
+ }
519
+ /**
520
+ * Monitor a background process and notify via toast on completion.
521
+ * This runs asynchronously — does not block the tool response.
522
+ */
523
+ async function monitorBackgroundProcess(proc, client, branchName, worktreePath) {
524
+ try {
525
+ // Wait for the process to exit
526
+ const exitCode = await new Promise((resolve) => {
527
+ proc.on("exit", (code) => resolve(code ?? 1));
528
+ proc.on("error", () => resolve(1));
529
+ });
530
+ // Clean up PID file
531
+ const pidFile = path.join(worktreePath, ".cortex", ".background-pid");
532
+ if (fs.existsSync(pidFile)) {
533
+ fs.unlinkSync(pidFile);
534
+ }
535
+ if (exitCode === 0) {
536
+ await client.tui.showToast({
537
+ body: {
538
+ title: `Background: ${branchName}`,
539
+ message: "Implementation complete! Check the worktree for changes.",
540
+ variant: "success",
541
+ duration: 10000,
542
+ },
543
+ });
544
+ }
545
+ else {
546
+ await client.tui.showToast({
547
+ body: {
548
+ title: `Background: ${branchName}`,
549
+ message: `Process exited with code ${exitCode}. Check the worktree.`,
550
+ variant: "warning",
551
+ duration: 10000,
552
+ },
553
+ });
554
+ }
555
+ }
556
+ catch (error) {
557
+ try {
558
+ await client.tui.showToast({
559
+ body: {
560
+ title: `Background: ${branchName}`,
561
+ message: `Error: ${error.message || "Process monitoring failed"}`,
562
+ variant: "error",
563
+ duration: 10000,
564
+ },
565
+ });
566
+ }
567
+ catch {
568
+ // If toast fails too, nothing we can do
569
+ }
570
+ }
571
+ }
572
+ // ─── worktree_launch Factory ─────────────────────────────────────────────────
573
+ /**
574
+ * Factory function that creates the worktree_launch tool with access
575
+ * to the OpenCode client (for PTY and toast) and shell.
576
+ *
577
+ * This uses a closure to capture `client` and `shell` since ToolContext
578
+ * does not provide access to the OpenCode client API.
579
+ */
580
+ export function createLaunch(client, shell) {
581
+ return tool({
582
+ description: "Launch an OpenCode session in an existing worktree. Supports three modes: " +
583
+ "'terminal' opens a new terminal tab, 'pty' creates an in-app PTY session, " +
584
+ "'background' runs implementation headlessly with progress notifications.",
585
+ args: {
586
+ name: tool.schema
587
+ .string()
588
+ .describe("Worktree name (must already exist — use worktree_create first)"),
589
+ mode: tool.schema
590
+ .enum(["terminal", "pty", "background"])
591
+ .describe("Launch mode: 'terminal' = new terminal tab, 'pty' = in-app PTY session, " +
592
+ "'background' = headless execution with toast notifications"),
593
+ plan: tool.schema
594
+ .string()
595
+ .optional()
596
+ .describe("Plan filename to propagate into the worktree (e.g., '2026-02-22-feature-auth.md')"),
597
+ agent: tool.schema
598
+ .string()
599
+ .optional()
600
+ .describe("Agent to use in the new session (default: 'build')"),
601
+ prompt: tool.schema
602
+ .string()
603
+ .optional()
604
+ .describe("Custom prompt for the new session (auto-generated from plan if omitted)"),
605
+ },
606
+ async execute(args, context) {
607
+ const { name, mode, plan: planFilename, agent = "build", prompt: customPrompt, } = args;
608
+ const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
609
+ const absoluteWorktreePath = path.resolve(worktreePath);
610
+ // ── Validate worktree exists ───────────────────────────────
611
+ if (!fs.existsSync(absoluteWorktreePath)) {
612
+ return `✗ Error: Worktree not found at ${absoluteWorktreePath}
613
+
614
+ Use worktree_create to create it first, then worktree_launch to start working in it.
615
+ Use worktree_list to see existing worktrees.`;
616
+ }
617
+ // ── Find opencode binary ───────────────────────────────────
618
+ const opencodeBin = await findOpencodeBinary();
619
+ if (!opencodeBin) {
620
+ return `✗ Error: Could not find the 'opencode' binary.
621
+
622
+ Checked:
623
+ - ~/.opencode/bin/opencode
624
+ - $PATH (via 'which opencode')
625
+
626
+ Install OpenCode or ensure it's in your PATH.`;
627
+ }
628
+ // ── Detect branch name ─────────────────────────────────────
629
+ let branchName = name;
630
+ try {
631
+ const { stdout } = await git(absoluteWorktreePath, "branch", "--show-current");
632
+ if (stdout.trim())
633
+ branchName = stdout.trim();
634
+ }
635
+ catch {
636
+ // Use worktree name as fallback
637
+ }
638
+ // ── Propagate plan to worktree ─────────────────────────────
639
+ let planInfo = "";
640
+ if (planFilename || fs.existsSync(path.join(context.worktree, ".cortex", "plans"))) {
641
+ const result = propagatePlan({
642
+ sourceWorktree: context.worktree,
643
+ targetWorktree: absoluteWorktreePath,
644
+ planFilename,
645
+ });
646
+ if (result.copied.length > 0) {
647
+ planInfo = `\nPlans propagated: ${result.copied.join(", ")}`;
648
+ if (result.initialized) {
649
+ planInfo += " (.cortex initialized in worktree)";
650
+ }
651
+ }
652
+ }
653
+ // ── Build prompt ───────────────────────────────────────────
654
+ const launchPrompt = buildLaunchPrompt(planFilename, customPrompt);
655
+ // ── Launch based on mode ───────────────────────────────────
656
+ let launchResult;
657
+ switch (mode) {
658
+ case "terminal":
659
+ launchResult = await launchTerminalTab(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
660
+ break;
661
+ case "pty":
662
+ launchResult = await launchPty(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
663
+ break;
664
+ case "background":
665
+ launchResult = await launchBackground(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
666
+ break;
667
+ default:
668
+ launchResult = `✗ Unknown mode: ${mode}`;
669
+ }
670
+ return `${launchResult}${planInfo}`;
671
+ },
672
+ });
673
+ }