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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Driver System — Strategy pattern for cross-platform terminal tab management.
|
|
3
|
+
*
|
|
4
|
+
* Each supported terminal emulator implements the TerminalDriver interface for:
|
|
5
|
+
* - detect() — check if this is the active terminal
|
|
6
|
+
* - openTab() — open a new tab and return identifiers
|
|
7
|
+
* - closeTab() — close a tab by its identifiers (idempotent, never throws)
|
|
8
|
+
*
|
|
9
|
+
* Session data is persisted to `.cortex/.terminal-session` inside each worktree
|
|
10
|
+
* so tabs can be closed when the worktree is removed.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { exec, shellEscape, spawn, which } from "./shell.js";
|
|
15
|
+
// ─── Session I/O ─────────────────────────────────────────────────────────────
|
|
16
|
+
const SESSION_FILE = ".terminal-session";
|
|
17
|
+
/** Persist terminal session data to the worktree's .cortex directory. */
|
|
18
|
+
export function writeSession(worktreePath, session) {
|
|
19
|
+
const cortexDir = path.join(worktreePath, ".cortex");
|
|
20
|
+
if (!fs.existsSync(cortexDir)) {
|
|
21
|
+
fs.mkdirSync(cortexDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
fs.writeFileSync(path.join(cortexDir, SESSION_FILE), JSON.stringify(session, null, 2));
|
|
24
|
+
}
|
|
25
|
+
/** Read terminal session data from the worktree's .cortex directory. */
|
|
26
|
+
export function readSession(worktreePath) {
|
|
27
|
+
const sessionPath = path.join(worktreePath, ".cortex", SESSION_FILE);
|
|
28
|
+
if (!fs.existsSync(sessionPath))
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ─── Helper: build the shell command for the new tab ─────────────────────────
|
|
38
|
+
function buildTabCommand(opts) {
|
|
39
|
+
return `cd "${opts.worktreePath}" && "${opts.opencodeBin}" --agent ${opts.agent}`;
|
|
40
|
+
}
|
|
41
|
+
// ─── Helper: safe process kill ───────────────────────────────────────────────
|
|
42
|
+
function killPid(pid) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, "SIGTERM");
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false; // Process already dead or permission denied
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
52
|
+
// Driver Implementations
|
|
53
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
// ─── tmux (multiplexer — highest priority) ───────────────────────────────────
|
|
55
|
+
class TmuxDriver {
|
|
56
|
+
name = "tmux";
|
|
57
|
+
detect() {
|
|
58
|
+
return !!process.env.TMUX;
|
|
59
|
+
}
|
|
60
|
+
async openTab(opts) {
|
|
61
|
+
const cmd = buildTabCommand(opts);
|
|
62
|
+
try {
|
|
63
|
+
// -P prints info about the new window, -F formats it
|
|
64
|
+
const result = await exec("tmux", [
|
|
65
|
+
"new-window",
|
|
66
|
+
"-P",
|
|
67
|
+
"-F",
|
|
68
|
+
"#{pane_id}",
|
|
69
|
+
"-c",
|
|
70
|
+
opts.worktreePath,
|
|
71
|
+
cmd,
|
|
72
|
+
]);
|
|
73
|
+
const paneId = result.stdout.trim();
|
|
74
|
+
return { paneId: paneId || undefined };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Fallback: try without -P (older tmux)
|
|
78
|
+
try {
|
|
79
|
+
await exec("tmux", ["new-window", "-c", opts.worktreePath, cmd]);
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error("Failed to open tmux window");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async closeTab(session) {
|
|
88
|
+
if (session.paneId) {
|
|
89
|
+
try {
|
|
90
|
+
await exec("tmux", ["kill-pane", "-t", session.paneId], { nothrow: true });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Pane already closed
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Fallback: kill PID
|
|
98
|
+
if (session.pid)
|
|
99
|
+
return killPid(session.pid);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ─── iTerm2 (macOS) ──────────────────────────────────────────────────────────
|
|
104
|
+
class ITerm2Driver {
|
|
105
|
+
name = "iterm2";
|
|
106
|
+
detect() {
|
|
107
|
+
if (process.platform !== "darwin")
|
|
108
|
+
return false;
|
|
109
|
+
if (process.env.ITERM_SESSION_ID)
|
|
110
|
+
return true;
|
|
111
|
+
if (process.env.TERM_PROGRAM === "iTerm.app")
|
|
112
|
+
return true;
|
|
113
|
+
const bundleId = process.env.__CFBundleIdentifier;
|
|
114
|
+
if (bundleId?.includes("iterm2") || bundleId?.includes("iTerm"))
|
|
115
|
+
return true;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async openTab(opts) {
|
|
119
|
+
const safePath = shellEscape(opts.worktreePath);
|
|
120
|
+
const safeBin = shellEscape(opts.opencodeBin);
|
|
121
|
+
const safeAgent = shellEscape(opts.agent);
|
|
122
|
+
// Create tab, write command, capture session ID
|
|
123
|
+
const script = `tell application "iTerm2"
|
|
124
|
+
tell current window
|
|
125
|
+
create tab with default profile
|
|
126
|
+
tell current session of current tab
|
|
127
|
+
write text "cd \\"${safePath}\\" && \\"${safeBin}\\" --agent ${safeAgent}"
|
|
128
|
+
return id
|
|
129
|
+
end tell
|
|
130
|
+
end tell
|
|
131
|
+
end tell`;
|
|
132
|
+
try {
|
|
133
|
+
const result = await exec("osascript", ["-e", script]);
|
|
134
|
+
const sessionId = result.stdout.trim();
|
|
135
|
+
return { sessionId: sessionId || undefined };
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Fallback: try without capturing ID
|
|
139
|
+
const fallbackScript = `tell application "iTerm2"
|
|
140
|
+
tell current window
|
|
141
|
+
create tab with default profile
|
|
142
|
+
tell current session of current tab
|
|
143
|
+
write text "cd \\"${safePath}\\" && \\"${safeBin}\\" --agent ${safeAgent}"
|
|
144
|
+
end tell
|
|
145
|
+
end tell
|
|
146
|
+
end tell`;
|
|
147
|
+
try {
|
|
148
|
+
await exec("osascript", ["-e", fallbackScript]);
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error("Failed to open iTerm2 tab");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async closeTab(session) {
|
|
157
|
+
if (!session.sessionId) {
|
|
158
|
+
if (session.pid)
|
|
159
|
+
return killPid(session.pid);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const script = `tell application "iTerm2"
|
|
163
|
+
repeat with w in windows
|
|
164
|
+
repeat with t in tabs of w
|
|
165
|
+
repeat with s in sessions of t
|
|
166
|
+
if id of s is "${shellEscape(session.sessionId)}" then
|
|
167
|
+
close s
|
|
168
|
+
return "closed"
|
|
169
|
+
end if
|
|
170
|
+
end repeat
|
|
171
|
+
end repeat
|
|
172
|
+
end repeat
|
|
173
|
+
return "not_found"
|
|
174
|
+
end tell`;
|
|
175
|
+
try {
|
|
176
|
+
const result = await exec("osascript", ["-e", script], { nothrow: true });
|
|
177
|
+
return result.stdout.trim() === "closed";
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ─── Terminal.app (macOS) ────────────────────────────────────────────────────
|
|
185
|
+
class TerminalAppDriver {
|
|
186
|
+
name = "terminal.app";
|
|
187
|
+
detect() {
|
|
188
|
+
if (process.platform !== "darwin")
|
|
189
|
+
return false;
|
|
190
|
+
if (process.env.TERM_PROGRAM === "Apple_Terminal")
|
|
191
|
+
return true;
|
|
192
|
+
const bundleId = process.env.__CFBundleIdentifier;
|
|
193
|
+
if (bundleId?.includes("Terminal") || bundleId?.includes("apple.Terminal"))
|
|
194
|
+
return true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
async openTab(opts) {
|
|
198
|
+
const safePath = shellEscape(opts.worktreePath);
|
|
199
|
+
const safeBin = shellEscape(opts.opencodeBin);
|
|
200
|
+
const safeAgent = shellEscape(opts.agent);
|
|
201
|
+
// do script returns tab reference; we capture the window ID
|
|
202
|
+
const script = `tell application "Terminal"
|
|
203
|
+
activate
|
|
204
|
+
set newTab to do script "cd \\"${safePath}\\" && \\"${safeBin}\\" --agent ${safeAgent}"
|
|
205
|
+
return id of window of newTab
|
|
206
|
+
end tell`;
|
|
207
|
+
try {
|
|
208
|
+
const result = await exec("osascript", ["-e", script]);
|
|
209
|
+
const windowId = result.stdout.trim();
|
|
210
|
+
return { windowId: windowId || undefined };
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Fallback: basic open without capturing ID
|
|
214
|
+
try {
|
|
215
|
+
await exec("open", ["-a", "Terminal", opts.worktreePath]);
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new Error("Failed to open Terminal.app");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async closeTab(session) {
|
|
224
|
+
if (!session.windowId) {
|
|
225
|
+
if (session.pid)
|
|
226
|
+
return killPid(session.pid);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const script = `tell application "Terminal"
|
|
230
|
+
try
|
|
231
|
+
close window id ${session.windowId}
|
|
232
|
+
return "closed"
|
|
233
|
+
on error
|
|
234
|
+
return "not_found"
|
|
235
|
+
end try
|
|
236
|
+
end tell`;
|
|
237
|
+
try {
|
|
238
|
+
const result = await exec("osascript", ["-e", script], { nothrow: true });
|
|
239
|
+
return result.stdout.trim() === "closed";
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ─── kitty (Linux/macOS) ─────────────────────────────────────────────────────
|
|
247
|
+
class KittyDriver {
|
|
248
|
+
name = "kitty";
|
|
249
|
+
detect() {
|
|
250
|
+
return !!process.env.KITTY_WINDOW_ID || process.env.TERM_PROGRAM === "kitty";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Check if kitty remote control is available.
|
|
254
|
+
* Requires `allow_remote_control yes` in kitty.conf.
|
|
255
|
+
*/
|
|
256
|
+
async hasRemoteControl() {
|
|
257
|
+
try {
|
|
258
|
+
await exec("kitty", ["@", "ls"], { timeout: 3000, nothrow: true });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async openTab(opts) {
|
|
266
|
+
const cmd = buildTabCommand(opts);
|
|
267
|
+
// Prefer IPC tab creation if remote control is enabled
|
|
268
|
+
if (await this.hasRemoteControl()) {
|
|
269
|
+
try {
|
|
270
|
+
const result = await exec("kitty", [
|
|
271
|
+
"@",
|
|
272
|
+
"launch",
|
|
273
|
+
"--type=tab",
|
|
274
|
+
`--cwd=${opts.worktreePath}`,
|
|
275
|
+
`--title=Worktree: ${opts.branchName}`,
|
|
276
|
+
"bash",
|
|
277
|
+
"-c",
|
|
278
|
+
cmd,
|
|
279
|
+
]);
|
|
280
|
+
const tabId = result.stdout.trim();
|
|
281
|
+
return { tabId: tabId || undefined };
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Fall through to new-window approach
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Fallback: open a new kitty window (captures PID)
|
|
288
|
+
const child = spawn("kitty", [
|
|
289
|
+
"--directory",
|
|
290
|
+
opts.worktreePath,
|
|
291
|
+
"--title",
|
|
292
|
+
`Worktree: ${opts.branchName}`,
|
|
293
|
+
"--",
|
|
294
|
+
"bash",
|
|
295
|
+
"-c",
|
|
296
|
+
cmd,
|
|
297
|
+
], { cwd: opts.worktreePath });
|
|
298
|
+
return { pid: child.pid ?? undefined };
|
|
299
|
+
}
|
|
300
|
+
async closeTab(session) {
|
|
301
|
+
// Try IPC close by tab ID
|
|
302
|
+
if (session.tabId) {
|
|
303
|
+
try {
|
|
304
|
+
await exec("kitty", ["@", "close-tab", `--match=id:${session.tabId}`], { nothrow: true });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Tab may not exist
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Try IPC close by PID
|
|
312
|
+
if (session.pid) {
|
|
313
|
+
try {
|
|
314
|
+
await exec("kitty", ["@", "close-window", `--match=pid:${session.pid}`], { nothrow: true });
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Fall through to kill
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Fallback: kill PID
|
|
322
|
+
if (session.pid)
|
|
323
|
+
return killPid(session.pid);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// ─── wezterm ─────────────────────────────────────────────────────────────────
|
|
328
|
+
class WeztermDriver {
|
|
329
|
+
name = "wezterm";
|
|
330
|
+
detect() {
|
|
331
|
+
return !!process.env.WEZTERM_PANE || process.env.TERM_PROGRAM === "WezTerm";
|
|
332
|
+
}
|
|
333
|
+
async openTab(opts) {
|
|
334
|
+
const cmd = buildTabCommand(opts);
|
|
335
|
+
// wezterm cli spawn opens a tab in the current window
|
|
336
|
+
try {
|
|
337
|
+
const result = await exec("wezterm", [
|
|
338
|
+
"cli",
|
|
339
|
+
"spawn",
|
|
340
|
+
"--cwd",
|
|
341
|
+
opts.worktreePath,
|
|
342
|
+
"--",
|
|
343
|
+
"bash",
|
|
344
|
+
"-c",
|
|
345
|
+
cmd,
|
|
346
|
+
]);
|
|
347
|
+
const paneId = result.stdout.trim();
|
|
348
|
+
return { paneId: paneId || undefined };
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Fallback: wezterm start opens a new window
|
|
352
|
+
const child = spawn("wezterm", [
|
|
353
|
+
"start",
|
|
354
|
+
"--cwd",
|
|
355
|
+
opts.worktreePath,
|
|
356
|
+
"--",
|
|
357
|
+
"bash",
|
|
358
|
+
"-c",
|
|
359
|
+
cmd,
|
|
360
|
+
], { cwd: opts.worktreePath });
|
|
361
|
+
return { pid: child.pid ?? undefined };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async closeTab(session) {
|
|
365
|
+
if (session.paneId) {
|
|
366
|
+
try {
|
|
367
|
+
await exec("wezterm", ["cli", "kill-pane", "--pane-id", session.paneId], { nothrow: true });
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Pane may not exist
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (session.pid)
|
|
375
|
+
return killPid(session.pid);
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// ─── Konsole (KDE) ───────────────────────────────────────────────────────────
|
|
380
|
+
class KonsoleDriver {
|
|
381
|
+
name = "konsole";
|
|
382
|
+
detect() {
|
|
383
|
+
return !!process.env.KONSOLE_VERSION;
|
|
384
|
+
}
|
|
385
|
+
/** Find the qdbus binary (qdbus or qdbus6 on newer KDE). */
|
|
386
|
+
async findQdbus() {
|
|
387
|
+
const bin = await which("qdbus");
|
|
388
|
+
if (bin)
|
|
389
|
+
return "qdbus";
|
|
390
|
+
const bin6 = await which("qdbus6");
|
|
391
|
+
if (bin6)
|
|
392
|
+
return "qdbus6";
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
async openTab(opts) {
|
|
396
|
+
const cmd = buildTabCommand(opts);
|
|
397
|
+
const qdbus = await this.findQdbus();
|
|
398
|
+
const service = process.env.KONSOLE_DBUS_SERVICE
|
|
399
|
+
? `org.kde.konsole-${process.env.KONSOLE_DBUS_SERVICE}`
|
|
400
|
+
: "org.kde.konsole";
|
|
401
|
+
// Try D-Bus new session
|
|
402
|
+
if (qdbus) {
|
|
403
|
+
try {
|
|
404
|
+
const result = await exec(qdbus, [
|
|
405
|
+
service,
|
|
406
|
+
"/Windows/1",
|
|
407
|
+
"newSession",
|
|
408
|
+
]);
|
|
409
|
+
const sessionNum = result.stdout.trim();
|
|
410
|
+
const dbusPath = `/Sessions/${sessionNum}`;
|
|
411
|
+
// Set working directory and run command
|
|
412
|
+
await exec(qdbus, [service, dbusPath, "setProfile", "Default"]);
|
|
413
|
+
await exec(qdbus, [service, dbusPath, "runCommand", cmd]);
|
|
414
|
+
return { dbusPath };
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// Fall through
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Fallback: launch konsole with --new-tab
|
|
421
|
+
try {
|
|
422
|
+
const child = spawn("konsole", [
|
|
423
|
+
"--new-tab",
|
|
424
|
+
"--workdir",
|
|
425
|
+
opts.worktreePath,
|
|
426
|
+
"-e",
|
|
427
|
+
"bash",
|
|
428
|
+
"-c",
|
|
429
|
+
cmd,
|
|
430
|
+
], { cwd: opts.worktreePath });
|
|
431
|
+
return { pid: child.pid ?? undefined };
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
throw new Error("Failed to open Konsole tab");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async closeTab(session) {
|
|
438
|
+
if (session.dbusPath) {
|
|
439
|
+
const qdbus = await this.findQdbus();
|
|
440
|
+
const service = process.env.KONSOLE_DBUS_SERVICE
|
|
441
|
+
? `org.kde.konsole-${process.env.KONSOLE_DBUS_SERVICE}`
|
|
442
|
+
: "org.kde.konsole";
|
|
443
|
+
if (qdbus) {
|
|
444
|
+
try {
|
|
445
|
+
await exec(qdbus, [service, session.dbusPath, "close"], { nothrow: true });
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Session may not exist
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (session.pid)
|
|
454
|
+
return killPid(session.pid);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ─── GNOME Terminal ──────────────────────────────────────────────────────────
|
|
459
|
+
class GnomeTerminalDriver {
|
|
460
|
+
name = "gnome-terminal";
|
|
461
|
+
detect() {
|
|
462
|
+
return !!process.env.GNOME_TERMINAL_SERVICE;
|
|
463
|
+
}
|
|
464
|
+
async openTab(opts) {
|
|
465
|
+
const cmd = buildTabCommand(opts);
|
|
466
|
+
// gnome-terminal --tab opens in the current window
|
|
467
|
+
try {
|
|
468
|
+
const child = spawn("gnome-terminal", [
|
|
469
|
+
"--tab",
|
|
470
|
+
`--working-directory=${opts.worktreePath}`,
|
|
471
|
+
"--",
|
|
472
|
+
"bash",
|
|
473
|
+
"-c",
|
|
474
|
+
cmd,
|
|
475
|
+
], { cwd: opts.worktreePath });
|
|
476
|
+
return { pid: child.pid ?? undefined };
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Fallback: open a new window
|
|
480
|
+
const child = spawn("gnome-terminal", [
|
|
481
|
+
`--working-directory=${opts.worktreePath}`,
|
|
482
|
+
"--",
|
|
483
|
+
"bash",
|
|
484
|
+
"-c",
|
|
485
|
+
cmd,
|
|
486
|
+
], { cwd: opts.worktreePath });
|
|
487
|
+
return { pid: child.pid ?? undefined };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async closeTab(session) {
|
|
491
|
+
// GNOME Terminal has no reliable tab-close API — kill PID
|
|
492
|
+
if (session.pid)
|
|
493
|
+
return killPid(session.pid);
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// ─── Fallback (PID-based, always matches) ────────────────────────────────────
|
|
498
|
+
class FallbackDriver {
|
|
499
|
+
name = "fallback";
|
|
500
|
+
detect() {
|
|
501
|
+
return true; // Always matches — catch-all
|
|
502
|
+
}
|
|
503
|
+
async openTab(opts) {
|
|
504
|
+
const cmd = buildTabCommand(opts);
|
|
505
|
+
const platform = process.platform;
|
|
506
|
+
// macOS: try generic open
|
|
507
|
+
if (platform === "darwin") {
|
|
508
|
+
try {
|
|
509
|
+
await exec("open", ["-a", "Terminal", opts.worktreePath]);
|
|
510
|
+
return {};
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Fall through
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Linux: try common terminals in order
|
|
517
|
+
if (platform === "linux") {
|
|
518
|
+
const terminals = [
|
|
519
|
+
{ name: "xterm", args: ["-e", "bash", "-c", cmd] },
|
|
520
|
+
{ name: "x-terminal-emulator", args: ["-e", "bash", "-c", cmd] },
|
|
521
|
+
];
|
|
522
|
+
for (const t of terminals) {
|
|
523
|
+
try {
|
|
524
|
+
const child = spawn(t.name, t.args, { cwd: opts.worktreePath });
|
|
525
|
+
return { pid: child.pid ?? undefined };
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Windows: try Windows Terminal, then cmd
|
|
533
|
+
if (platform === "win32") {
|
|
534
|
+
// Try Windows Terminal first
|
|
535
|
+
const wt = await which("wt.exe");
|
|
536
|
+
if (wt) {
|
|
537
|
+
try {
|
|
538
|
+
const child = spawn("wt.exe", [
|
|
539
|
+
"new-tab",
|
|
540
|
+
"--startingDirectory",
|
|
541
|
+
opts.worktreePath,
|
|
542
|
+
"cmd",
|
|
543
|
+
"/k",
|
|
544
|
+
cmd,
|
|
545
|
+
], { cwd: opts.worktreePath });
|
|
546
|
+
return { pid: child.pid ?? undefined };
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// Fall through to cmd
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
await exec("cmd", ["/k", cmd], { cwd: opts.worktreePath });
|
|
554
|
+
return {};
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// Nothing worked
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
throw new Error(`Could not open terminal on ${platform}`);
|
|
561
|
+
}
|
|
562
|
+
async closeTab(session) {
|
|
563
|
+
if (session.pid)
|
|
564
|
+
return killPid(session.pid);
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
569
|
+
// Detection Registry
|
|
570
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
571
|
+
/**
|
|
572
|
+
* Ordered list of terminal drivers. Detection runs first-to-last.
|
|
573
|
+
*
|
|
574
|
+
* Priority: multiplexers first (tmux), then terminal emulators, then fallback.
|
|
575
|
+
* This ensures that if the user is in tmux inside iTerm2, we open a tmux window.
|
|
576
|
+
*/
|
|
577
|
+
const DRIVERS = [
|
|
578
|
+
new TmuxDriver(),
|
|
579
|
+
new ITerm2Driver(),
|
|
580
|
+
new TerminalAppDriver(),
|
|
581
|
+
new KittyDriver(),
|
|
582
|
+
new WeztermDriver(),
|
|
583
|
+
new KonsoleDriver(),
|
|
584
|
+
new GnomeTerminalDriver(),
|
|
585
|
+
new FallbackDriver(),
|
|
586
|
+
];
|
|
587
|
+
/** Map of driver name → driver instance for reverse lookup. */
|
|
588
|
+
const DRIVER_MAP = new Map(DRIVERS.map((d) => [d.name, d]));
|
|
589
|
+
/**
|
|
590
|
+
* Detect the active terminal emulator and return the matching driver.
|
|
591
|
+
* Falls back to FallbackDriver if no specific terminal is detected.
|
|
592
|
+
*/
|
|
593
|
+
export function detectDriver() {
|
|
594
|
+
for (const driver of DRIVERS) {
|
|
595
|
+
if (driver.detect())
|
|
596
|
+
return driver;
|
|
597
|
+
}
|
|
598
|
+
// Should never reach here (FallbackDriver always matches)
|
|
599
|
+
return new FallbackDriver();
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Get a driver by name (used when closing a tab from a persisted session).
|
|
603
|
+
* Returns null if the driver name is unknown.
|
|
604
|
+
*/
|
|
605
|
+
export function getDriverByName(name) {
|
|
606
|
+
return DRIVER_MAP.get(name) ?? null;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Close a terminal session using the appropriate driver.
|
|
610
|
+
* Handles all modes (terminal, pty, background) with PID fallback.
|
|
611
|
+
*
|
|
612
|
+
* This is the main entry point for worktree_remove cleanup.
|
|
613
|
+
*/
|
|
614
|
+
export async function closeSession(session) {
|
|
615
|
+
if (session.mode === "terminal") {
|
|
616
|
+
const driver = getDriverByName(session.terminal);
|
|
617
|
+
if (driver) {
|
|
618
|
+
const closed = await driver.closeTab(session);
|
|
619
|
+
if (closed)
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Universal fallback: kill PID
|
|
624
|
+
if (session.pid)
|
|
625
|
+
return killPid(session.pid);
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktree-detect.d.ts","sourceRoot":"","sources":["../../src/utils/worktree-detect.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"worktree-detect.d.ts","sourceRoot":"","sources":["../../src/utils/worktree-detect.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,qFAAqF;IACrF,UAAU,EAAE,OAAO,CAAC;IACpB,8BAA8B;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAqC3E"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
|
+
import { git } from "./shell.js";
|
|
2
3
|
/**
|
|
3
4
|
* Detect whether the current git directory is a linked worktree.
|
|
4
5
|
*
|
|
@@ -14,16 +15,16 @@ export async function detectWorktreeInfo(cwd) {
|
|
|
14
15
|
};
|
|
15
16
|
// Get current branch
|
|
16
17
|
try {
|
|
17
|
-
const
|
|
18
|
-
result.currentBranch =
|
|
18
|
+
const { stdout } = await git(cwd, "branch", "--show-current");
|
|
19
|
+
result.currentBranch = stdout.trim();
|
|
19
20
|
}
|
|
20
21
|
catch {
|
|
21
22
|
result.currentBranch = "(unknown)";
|
|
22
23
|
}
|
|
23
24
|
// Compare git-dir and git-common-dir
|
|
24
25
|
try {
|
|
25
|
-
const gitDirRaw = await
|
|
26
|
-
const commonDirRaw = await
|
|
26
|
+
const { stdout: gitDirRaw } = await git(cwd, "rev-parse", "--git-dir");
|
|
27
|
+
const { stdout: commonDirRaw } = await git(cwd, "rev-parse", "--git-common-dir");
|
|
27
28
|
// Resolve both to absolute paths for reliable comparison
|
|
28
29
|
const absGitDir = path.resolve(cwd, gitDirRaw.trim());
|
|
29
30
|
const absCommonDir = path.resolve(cwd, commonDirRaw.trim());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortex-agents",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Supercharge OpenCode with structured workflows, intelligent agents, and automated development practices",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
17
19
|
"prepare": "npm run build",
|
|
18
20
|
"postinstall": "echo 'Run: npx cortex-agents install && npx cortex-agents configure'"
|
|
19
21
|
},
|
|
@@ -53,11 +55,10 @@
|
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@opencode-ai/plugin": "^1.0.0",
|
|
56
|
-
"@types/bun": "^1.3.9",
|
|
57
58
|
"@types/node": "^20.0.0",
|
|
58
59
|
"@types/prompts": "^2.4.9",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
60
|
+
"typescript": "^5.0.0",
|
|
61
|
+
"vitest": "^3.0.0"
|
|
61
62
|
},
|
|
62
63
|
"dependencies": {
|
|
63
64
|
"prompts": "^2.4.2"
|