cortex-agents 2.2.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.
- package/.opencode/agents/build.md +123 -20
- package/.opencode/agents/debug.md +97 -11
- package/.opencode/agents/devops.md +75 -7
- package/.opencode/agents/fullstack.md +89 -1
- package/.opencode/agents/plan.md +75 -5
- package/.opencode/agents/security.md +60 -1
- package/.opencode/agents/testing.md +45 -1
- package/README.md +82 -30
- package/dist/cli.js +207 -48
- package/dist/index.js +6 -6
- package/dist/tools/branch.d.ts +7 -1
- package/dist/tools/branch.d.ts.map +1 -1
- package/dist/tools/branch.js +88 -53
- package/dist/tools/cortex.d.ts +19 -0
- package/dist/tools/cortex.d.ts.map +1 -1
- package/dist/tools/cortex.js +109 -0
- package/dist/tools/session.d.ts.map +1 -1
- package/dist/tools/session.js +3 -1
- package/dist/tools/task.d.ts.map +1 -1
- package/dist/tools/task.js +65 -57
- package/dist/tools/worktree.d.ts +10 -2
- package/dist/tools/worktree.d.ts.map +1 -1
- package/dist/tools/worktree.js +320 -246
- package/dist/utils/shell.d.ts +53 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +118 -0
- package/dist/utils/terminal.d.ts +66 -0
- package/dist/utils/terminal.d.ts.map +1 -0
- package/dist/utils/terminal.js +627 -0
- package/dist/utils/worktree-detect.d.ts.map +1 -1
- package/dist/utils/worktree-detect.js +5 -4
- package/package.json +5 -4
package/dist/tools/worktree.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
48
|
+
await git(context.worktree, "worktree", "add", "-b", branchName, absoluteWorktreePath);
|
|
50
49
|
}
|
|
51
|
-
catch
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
if (!
|
|
103
|
+
const { stdout } = await git(context.worktree, "worktree", "list");
|
|
104
|
+
if (!stdout.trim()) {
|
|
73
105
|
return "No worktrees found.";
|
|
74
106
|
}
|
|
75
|
-
const lines =
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
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 =
|
|
456
|
+
const proc = shellSpawn(opencodeBin, ["run", "--agent", agent, prompt], {
|
|
395
457
|
cwd: worktreePath,
|
|
396
|
-
|
|
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
|
|
455
|
-
const exitCode = await
|
|
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
|
|
558
|
-
if (
|
|
559
|
-
branchName =
|
|
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);
|