cortex-agents 2.2.0 → 2.3.1

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.
@@ -2,57 +2,88 @@ import { tool } from "@opencode-ai/plugin";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
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";
5
7
  const WORKTREE_ROOT = ".worktrees";
6
- export const create = tool({
7
- description: "Create a new git worktree for isolated development. Worktrees are created in .worktrees/ at the project root.",
8
- args: {
9
- name: tool.schema
10
- .string()
11
- .describe("Worktree name (e.g., 'auth-feature', 'login-bugfix')"),
12
- type: tool.schema
13
- .enum(["feature", "bugfix", "hotfix", "refactor", "spike", "docs", "test"])
14
- .describe("Type of work - determines branch prefix"),
15
- },
16
- async execute(args, context) {
17
- const { name, type } = args;
18
- const branchName = `${type}/${name}`;
19
- const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
20
- const absoluteWorktreePath = path.resolve(worktreePath);
21
- // Check if we're in a git repository
22
- try {
23
- const gitCheck = await Bun.$ `git rev-parse --git-dir`.cwd(context.worktree).text();
24
- if (!gitCheck.trim()) {
25
- return "✗ Error: Not in a git repository";
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");
26
31
  }
27
- }
28
- catch {
29
- return "✗ Error: Not in a git repository. Initialize git first.";
30
- }
31
- // Check if worktree already exists
32
- if (fs.existsSync(absoluteWorktreePath)) {
33
- 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}
34
38
 
35
39
  Use worktree_list to see existing worktrees.`;
36
- }
37
- // Create parent directory if needed
38
- const worktreeParent = path.dirname(absoluteWorktreePath);
39
- if (!fs.existsSync(worktreeParent)) {
40
- fs.mkdirSync(worktreeParent, { recursive: true });
41
- }
42
- // Create the worktree with a new branch
43
- try {
44
- await Bun.$ `git worktree add -b ${branchName} ${absoluteWorktreePath}`.cwd(context.worktree);
45
- }
46
- catch (error) {
47
- // 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
48
47
  try {
49
- await Bun.$ `git worktree add ${absoluteWorktreePath} ${branchName}`.cwd(context.worktree);
48
+ await git(context.worktree, "worktree", "add", "-b", branchName, absoluteWorktreePath);
50
49
  }
51
- catch (error2) {
52
- 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
+ }
53
71
  }
54
- }
55
- 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
56
87
 
57
88
  Branch: ${branchName}
58
89
  Path: ${absoluteWorktreePath}
@@ -61,18 +92,19 @@ To work in this worktree:
61
92
  cd ${absoluteWorktreePath}
62
93
 
63
94
  Or use worktree_open to get a command to open a new terminal there.`;
64
- },
65
- });
95
+ },
96
+ });
97
+ }
66
98
  export const list = tool({
67
99
  description: "List all git worktrees for this project",
68
100
  args: {},
69
101
  async execute(args, context) {
70
102
  try {
71
- const result = await Bun.$ `git worktree list`.cwd(context.worktree).text();
72
- if (!result.trim()) {
103
+ const { stdout } = await git(context.worktree, "worktree", "list");
104
+ if (!stdout.trim()) {
73
105
  return "No worktrees found.";
74
106
  }
75
- const lines = result.trim().split("\n");
107
+ const lines = stdout.trim().split("\n");
76
108
  let output = "Git Worktrees:\n\n";
77
109
  for (const line of lines) {
78
110
  const parts = line.split(/\s+/);
@@ -92,64 +124,141 @@ export const list = tool({
92
124
  }
93
125
  },
94
126
  });
95
- export const remove = tool({
96
- description: "Remove a git worktree (after merging). Optionally deletes the branch.",
97
- args: {
98
- name: tool.schema.string().describe("Worktree name to remove"),
99
- deleteBranch: tool.schema
100
- .boolean()
101
- .optional()
102
- .describe("Also delete the associated branch (default: false)"),
103
- },
104
- async execute(args, context) {
105
- const { name, deleteBranch = false } = args;
106
- const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
107
- const absoluteWorktreePath = path.resolve(worktreePath);
108
- // Check if worktree exists
109
- if (!fs.existsSync(absoluteWorktreePath)) {
110
- 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}
111
148
 
112
149
  Use worktree_list to see existing worktrees.`;
113
- }
114
- // Get branch name before removing
115
- let branchName = "";
116
- try {
117
- branchName = await Bun.$ `git -C ${absoluteWorktreePath} branch --show-current`.text();
118
- branchName = branchName.trim();
119
- }
120
- catch {
121
- // Ignore error, branch detection is optional
122
- }
123
- // Remove the worktree
124
- try {
125
- await Bun.$ `git worktree remove ${absoluteWorktreePath}`.cwd(context.worktree);
126
- }
127
- catch (error) {
128
- // Try force remove if there are changes
150
+ }
151
+ // Get branch name before removing
152
+ let branchName = "";
129
153
  try {
130
- await Bun.$ `git worktree remove --force ${absoluteWorktreePath}`.cwd(context.worktree);
154
+ const { stdout } = await git(absoluteWorktreePath, "branch", "--show-current");
155
+ branchName = stdout.trim();
156
+ }
157
+ catch {
158
+ // Ignore error, branch detection is optional
131
159
  }
132
- catch (error2) {
133
- return `✗ Error removing worktree: ${error2.message || error2}
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 ─────────────────────────────────────
199
+ try {
200
+ await git(context.worktree, "worktree", "remove", absoluteWorktreePath);
201
+ }
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}
134
222
 
135
223
  The worktree may have uncommitted changes. Commit or stash them first.`;
224
+ }
136
225
  }
137
- }
138
- let output = `✓ Removed worktree at ${absoluteWorktreePath}`;
139
- // Delete branch if requested
140
- 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
141
242
  try {
142
- await Bun.$ `git branch -d ${branchName}`.cwd(context.worktree);
143
- 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
+ });
144
254
  }
145
- catch (error) {
146
- output += `\n⚠ Could not delete branch ${branchName}: ${error.message || error}`;
147
- output += "\n (Branch may not be fully merged. Use git branch -D to force delete.)";
255
+ catch {
256
+ // Toast failure is non-fatal
148
257
  }
149
- }
150
- return output;
151
- },
152
- });
258
+ return output;
259
+ },
260
+ });
261
+ }
153
262
  export const open = tool({
154
263
  description: "Get the command to open a new terminal window in a worktree directory",
155
264
  args: {
@@ -197,50 +306,7 @@ ${instructions}
197
306
  Worktree path: ${absoluteWorktreePath}`;
198
307
  },
199
308
  });
200
- // ─── Terminal Detection ──────────────────────────────────────────────────────
201
- /**
202
- * Detect the user's terminal emulator on macOS.
203
- * Returns "iterm2", "terminal", or "unknown".
204
- */
205
- function detectMacTerminal() {
206
- // iTerm2 sets ITERM_SESSION_ID and TERM_PROGRAM
207
- if (process.env.ITERM_SESSION_ID || process.env.TERM_PROGRAM === "iTerm.app") {
208
- return "iterm2";
209
- }
210
- if (process.env.TERM_PROGRAM === "Apple_Terminal") {
211
- return "terminal";
212
- }
213
- // Check __CFBundleIdentifier for the running app
214
- const bundleId = process.env.__CFBundleIdentifier;
215
- if (bundleId?.includes("iterm2") || bundleId?.includes("iTerm")) {
216
- return "iterm2";
217
- }
218
- if (bundleId?.includes("Terminal") || bundleId?.includes("apple.Terminal")) {
219
- return "terminal";
220
- }
221
- return "unknown";
222
- }
223
- /**
224
- * Detect the user's terminal emulator on Linux.
225
- * Returns the terminal name or "unknown".
226
- */
227
- function detectLinuxTerminal() {
228
- const termProgram = process.env.TERM_PROGRAM;
229
- if (termProgram)
230
- return termProgram.toLowerCase();
231
- // Check common environment hints
232
- if (process.env.KITTY_WINDOW_ID)
233
- return "kitty";
234
- if (process.env.ALACRITTY_SOCKET)
235
- return "alacritty";
236
- if (process.env.WEZTERM_PANE)
237
- return "wezterm";
238
- if (process.env.GNOME_TERMINAL_SERVICE)
239
- return "gnome-terminal";
240
- if (process.env.KONSOLE_VERSION)
241
- return "konsole";
242
- return "unknown";
243
- }
309
+ // ─── Terminal detection and tab management now in src/utils/terminal.ts ──────
244
310
  /**
245
311
  * Find the opencode binary path, checking common locations.
246
312
  */
@@ -250,16 +316,10 @@ async function findOpencodeBinary() {
250
316
  const wellKnown = path.join(homeDir, ".opencode", "bin", "opencode");
251
317
  if (fs.existsSync(wellKnown))
252
318
  return wellKnown;
253
- // Try which/where
254
- try {
255
- const result = await Bun.$ `which opencode`.quiet().text();
256
- const bin = result.trim();
257
- if (bin && fs.existsSync(bin))
258
- return bin;
259
- }
260
- catch {
261
- // Not in PATH
262
- }
319
+ // Try which
320
+ const bin = await which("opencode");
321
+ if (bin && fs.existsSync(bin))
322
+ return bin;
263
323
  return null;
264
324
  }
265
325
  /**
@@ -273,99 +333,59 @@ function buildLaunchPrompt(planFilename, customPrompt) {
273
333
  }
274
334
  return "Check for plans in .cortex/plans/ and begin implementation. If no plan exists, analyze the codebase and suggest next steps.";
275
335
  }
276
- // ─── Mode A: New Terminal Tab ────────────────────────────────────────────────
277
- async function launchTerminalTab(worktreePath, opencodeBin, agent, prompt) {
278
- const platform = process.platform;
279
- // Build the command to run inside the new terminal
280
- const innerCmd = `cd "${worktreePath}" && "${opencodeBin}" --agent ${agent} --prompt "${prompt.replace(/"/g, '\\"')}"`;
281
- if (platform === "darwin") {
282
- const terminal = detectMacTerminal();
283
- if (terminal === "iterm2") {
284
- const script = `tell application "iTerm2"
285
- tell current window
286
- create tab with default profile
287
- tell current session of current tab
288
- write text "cd \\"${worktreePath}\\" && \\"${opencodeBin}\\" --agent ${agent}"
289
- end tell
290
- end tell
291
- end tell`;
292
- try {
293
- await Bun.$ `osascript -e ${script}`;
294
- return `✓ Opened new iTerm2 tab in worktree`;
295
- }
296
- catch {
297
- // Fall back to generic open
298
- }
299
- }
300
- if (terminal === "terminal" || terminal === "unknown") {
301
- // Terminal.app: `do script` opens in a new window by default
302
- // Using "do script in window 1" would reuse, so we use a plain `do script`
303
- const script = `tell application "Terminal"
304
- activate
305
- do script "cd \\"${worktreePath}\\" && \\"${opencodeBin}\\" --agent ${agent}"
306
- end tell`;
307
- try {
308
- await Bun.$ `osascript -e ${script}`;
309
- return `✓ Opened new Terminal.app window in worktree`;
310
- }
311
- catch (err) {
312
- // Last resort: use open -a
313
- try {
314
- await Bun.$ `open -a Terminal "${worktreePath}"`;
315
- return `✓ Opened Terminal.app in worktree directory (run opencode manually)`;
316
- }
317
- catch {
318
- return `✗ Could not open terminal. Manual command:\n ${innerCmd}`;
319
- }
320
- }
321
- }
322
- }
323
- if (platform === "linux") {
324
- const terminal = detectLinuxTerminal();
325
- const launchers = {
326
- "kitty": ["kitty", "--directory", worktreePath, "--", "bash", "-c", innerCmd],
327
- "alacritty": ["alacritty", "--working-directory", worktreePath, "-e", "bash", "-c", innerCmd],
328
- "wezterm": ["wezterm", "start", "--cwd", worktreePath, "--", "bash", "-c", innerCmd],
329
- "gnome-terminal": ["gnome-terminal", "--working-directory", worktreePath, "--", "bash", "-c", innerCmd],
330
- "konsole": ["konsole", "--workdir", worktreePath, "-e", "bash", "-c", innerCmd],
331
- };
332
- const args = launchers[terminal];
333
- if (args) {
334
- try {
335
- Bun.spawn(args, { cwd: worktreePath, stdout: "ignore", stderr: "ignore" });
336
- return `✓ Opened ${terminal} in worktree`;
337
- }
338
- catch {
339
- // Fall through to generic attempt
340
- }
341
- }
342
- // Generic fallback: try common terminals in order
343
- for (const [name, cmdArgs] of Object.entries(launchers)) {
344
- try {
345
- Bun.spawn(cmdArgs, { cwd: worktreePath, stdout: "ignore", stderr: "ignore" });
346
- return `✓ Opened ${name} in worktree`;
347
- }
348
- catch {
349
- continue;
350
- }
351
- }
352
- return `✗ Could not detect terminal emulator. Manual command:\n ${innerCmd}`;
353
- }
354
- if (platform === "win32") {
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") => {
355
340
  try {
356
- await Bun.$ `start cmd /k "cd /d ${worktreePath} && ${opencodeBin} --agent ${agent}"`;
357
- return `✓ Opened new cmd window in worktree`;
341
+ await client.tui.showToast({
342
+ body: {
343
+ title: `Terminal: ${branchName}`,
344
+ message,
345
+ variant,
346
+ duration: variant === "error" ? 8000 : 4000,
347
+ },
348
+ });
358
349
  }
359
350
  catch {
360
- return `✗ Could not open terminal. Manual command:\n ${innerCmd}`;
351
+ // Toast failure is non-fatal
361
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}`;
362
383
  }
363
- return `✗ Unsupported platform: ${platform}. Manual command:\n ${innerCmd}`;
364
384
  }
365
385
  // ─── Mode B: In-App PTY ─────────────────────────────────────────────────────
366
386
  async function launchPty(client, worktreePath, branchName, opencodeBin, agent, prompt) {
367
387
  try {
368
- await client.pty.create({
388
+ const response = await client.pty.create({
369
389
  body: {
370
390
  command: opencodeBin,
371
391
  args: ["--agent", agent, "--prompt", prompt],
@@ -373,6 +393,35 @@ async function launchPty(client, worktreePath, branchName, opencodeBin, agent, p
373
393
  title: `Worktree: ${branchName}`,
374
394
  },
375
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
+ }
376
425
  return `✓ Created in-app PTY session for worktree
377
426
 
378
427
  Branch: ${branchName}
@@ -382,6 +431,19 @@ The PTY is running OpenCode with agent '${agent}' in the worktree.
382
431
  Switch to it using OpenCode's terminal panel.`;
383
432
  }
384
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
+ }
385
447
  return `✗ Failed to create PTY session: ${error.message || error}
386
448
 
387
449
  Falling back to manual command:
@@ -391,18 +453,16 @@ Falling back to manual command:
391
453
  // ─── Mode C: Background Session ─────────────────────────────────────────────
392
454
  async function launchBackground(client, worktreePath, branchName, opencodeBin, agent, prompt) {
393
455
  // Spawn opencode run as a detached background process
394
- const proc = Bun.spawn([opencodeBin, "run", "--agent", agent, prompt], {
456
+ const proc = shellSpawn(opencodeBin, ["run", "--agent", agent, prompt], {
395
457
  cwd: worktreePath,
396
- stdout: "pipe",
397
- stderr: "pipe",
458
+ stdio: "pipe",
398
459
  env: {
399
460
  ...process.env,
400
- // Ensure the background instance knows its directory
401
461
  HOME: process.env.HOME || "",
402
462
  PATH: process.env.PATH || "",
403
463
  },
404
464
  });
405
- // Save PID for tracking
465
+ // Save PID for tracking (legacy format)
406
466
  const cortexDir = path.join(worktreePath, ".cortex");
407
467
  if (!fs.existsSync(cortexDir)) {
408
468
  fs.mkdirSync(cortexDir, { recursive: true });
@@ -413,6 +473,17 @@ async function launchBackground(client, worktreePath, branchName, opencodeBin, a
413
473
  agent,
414
474
  startedAt: new Date().toISOString(),
415
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
+ });
416
487
  // Show initial toast
417
488
  try {
418
489
  await client.tui.showToast({
@@ -451,8 +522,11 @@ To check worktree status later:
451
522
  */
452
523
  async function monitorBackgroundProcess(proc, client, branchName, worktreePath) {
453
524
  try {
454
- // Wait for the process to exit (non-blocking from the tool's perspective)
455
- const exitCode = await proc.exited;
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
+ });
456
530
  // Clean up PID file
457
531
  const pidFile = path.join(worktreePath, ".cortex", ".background-pid");
458
532
  if (fs.existsSync(pidFile)) {
@@ -554,9 +628,9 @@ Install OpenCode or ensure it's in your PATH.`;
554
628
  // ── Detect branch name ─────────────────────────────────────
555
629
  let branchName = name;
556
630
  try {
557
- const branch = await Bun.$ `git -C ${absoluteWorktreePath} branch --show-current`.quiet().text();
558
- if (branch.trim())
559
- branchName = branch.trim();
631
+ const { stdout } = await git(absoluteWorktreePath, "branch", "--show-current");
632
+ if (stdout.trim())
633
+ branchName = stdout.trim();
560
634
  }
561
635
  catch {
562
636
  // Use worktree name as fallback
@@ -582,7 +656,7 @@ Install OpenCode or ensure it's in your PATH.`;
582
656
  let launchResult;
583
657
  switch (mode) {
584
658
  case "terminal":
585
- launchResult = await launchTerminalTab(absoluteWorktreePath, opencodeBin, agent, launchPrompt);
659
+ launchResult = await launchTerminalTab(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
586
660
  break;
587
661
  case "pty":
588
662
  launchResult = await launchPty(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);