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.
- package/.opencode/agents/build.md +179 -21
- 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 +83 -6
- package/.opencode/agents/security.md +60 -1
- package/.opencode/agents/testing.md +45 -1
- package/README.md +292 -356
- package/dist/cli.js +230 -65
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- 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 +110 -1
- package/dist/tools/session.d.ts.map +1 -1
- package/dist/tools/session.js +3 -1
- package/dist/tools/task.d.ts +20 -0
- package/dist/tools/task.d.ts.map +1 -0
- package/dist/tools/task.js +310 -0
- package/dist/tools/worktree.d.ts +42 -2
- package/dist/tools/worktree.d.ts.map +1 -1
- package/dist/tools/worktree.js +573 -98
- package/dist/utils/plan-extract.d.ts +37 -0
- package/dist/utils/plan-extract.d.ts.map +1 -0
- package/dist/utils/plan-extract.js +137 -0
- package/dist/utils/propagate.d.ts +22 -0
- package/dist/utils/propagate.d.ts.map +1 -0
- package/dist/utils/propagate.js +64 -0
- 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 +20 -0
- package/dist/utils/worktree-detect.d.ts.map +1 -0
- package/dist/utils/worktree-detect.js +43 -0
- package/package.json +13 -9
package/dist/tools/worktree.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
48
|
+
await git(context.worktree, "worktree", "add", "-b", branchName, absoluteWorktreePath);
|
|
49
49
|
}
|
|
50
|
-
catch
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
if (!
|
|
103
|
+
const { stdout } = await git(context.worktree, "worktree", "list");
|
|
104
|
+
if (!stdout.trim()) {
|
|
72
105
|
return "No worktrees found.";
|
|
73
106
|
}
|
|
74
|
-
const lines =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
200
|
+
await git(context.worktree, "worktree", "remove", absoluteWorktreePath);
|
|
130
201
|
}
|
|
131
|
-
catch
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
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
|
+
}
|