cortex-agents 2.3.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.opencode/agents/{plan.md → architect.md} +104 -58
  2. package/.opencode/agents/audit.md +183 -0
  3. package/.opencode/agents/{fullstack.md → coder.md} +10 -54
  4. package/.opencode/agents/debug.md +76 -201
  5. package/.opencode/agents/devops.md +16 -123
  6. package/.opencode/agents/docs-writer.md +195 -0
  7. package/.opencode/agents/fix.md +207 -0
  8. package/.opencode/agents/implement.md +433 -0
  9. package/.opencode/agents/perf.md +151 -0
  10. package/.opencode/agents/refactor.md +163 -0
  11. package/.opencode/agents/security.md +20 -85
  12. package/.opencode/agents/testing.md +1 -151
  13. package/.opencode/skills/data-engineering/SKILL.md +221 -0
  14. package/.opencode/skills/monitoring-observability/SKILL.md +251 -0
  15. package/README.md +315 -224
  16. package/dist/cli.js +85 -17
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +60 -22
  19. package/dist/registry.d.ts +8 -3
  20. package/dist/registry.d.ts.map +1 -1
  21. package/dist/registry.js +16 -2
  22. package/dist/tools/branch.d.ts +2 -2
  23. package/dist/tools/cortex.d.ts +2 -2
  24. package/dist/tools/cortex.js +7 -7
  25. package/dist/tools/docs.d.ts +2 -2
  26. package/dist/tools/environment.d.ts +31 -0
  27. package/dist/tools/environment.d.ts.map +1 -0
  28. package/dist/tools/environment.js +93 -0
  29. package/dist/tools/github.d.ts +42 -0
  30. package/dist/tools/github.d.ts.map +1 -0
  31. package/dist/tools/github.js +200 -0
  32. package/dist/tools/plan.d.ts +28 -4
  33. package/dist/tools/plan.d.ts.map +1 -1
  34. package/dist/tools/plan.js +232 -4
  35. package/dist/tools/quality-gate.d.ts +28 -0
  36. package/dist/tools/quality-gate.d.ts.map +1 -0
  37. package/dist/tools/quality-gate.js +233 -0
  38. package/dist/tools/repl.d.ts +55 -0
  39. package/dist/tools/repl.d.ts.map +1 -0
  40. package/dist/tools/repl.js +291 -0
  41. package/dist/tools/task.d.ts +2 -0
  42. package/dist/tools/task.d.ts.map +1 -1
  43. package/dist/tools/task.js +25 -30
  44. package/dist/tools/worktree.d.ts +5 -32
  45. package/dist/tools/worktree.d.ts.map +1 -1
  46. package/dist/tools/worktree.js +75 -447
  47. package/dist/utils/change-scope.d.ts +33 -0
  48. package/dist/utils/change-scope.d.ts.map +1 -0
  49. package/dist/utils/change-scope.js +198 -0
  50. package/dist/utils/github.d.ts +104 -0
  51. package/dist/utils/github.d.ts.map +1 -0
  52. package/dist/utils/github.js +243 -0
  53. package/dist/utils/ide.d.ts +76 -0
  54. package/dist/utils/ide.d.ts.map +1 -0
  55. package/dist/utils/ide.js +307 -0
  56. package/dist/utils/plan-extract.d.ts +28 -0
  57. package/dist/utils/plan-extract.d.ts.map +1 -1
  58. package/dist/utils/plan-extract.js +90 -1
  59. package/dist/utils/repl.d.ts +145 -0
  60. package/dist/utils/repl.d.ts.map +1 -0
  61. package/dist/utils/repl.js +547 -0
  62. package/dist/utils/terminal.d.ts +53 -1
  63. package/dist/utils/terminal.d.ts.map +1 -1
  64. package/dist/utils/terminal.js +642 -5
  65. package/package.json +1 -1
  66. package/.opencode/agents/build.md +0 -294
  67. package/.opencode/agents/review.md +0 -314
  68. package/dist/plugin.d.ts +0 -1
  69. package/dist/plugin.d.ts.map +0 -1
  70. package/dist/plugin.js +0 -4
@@ -36,7 +36,11 @@ export function readSession(worktreePath) {
36
36
  }
37
37
  // ─── Helper: build the shell command for the new tab ─────────────────────────
38
38
  function buildTabCommand(opts) {
39
- return `cd "${opts.worktreePath}" && "${opts.opencodeBin}" --agent ${opts.agent}`;
39
+ // Escape all user-controlled inputs to prevent command injection
40
+ const safePath = shellEscape(opts.worktreePath);
41
+ const safeBin = shellEscape(opts.opencodeBin);
42
+ const safeAgent = shellEscape(opts.agent);
43
+ return `cd "${safePath}" && "${safeBin}" --agent "${safeAgent}"`;
40
44
  }
41
45
  // ─── Helper: safe process kill ───────────────────────────────────────────────
42
46
  function killPid(pid) {
@@ -51,7 +55,165 @@ function killPid(pid) {
51
55
  // ═════════════════════════════════════════════════════════════════════════════
52
56
  // Driver Implementations
53
57
  // ═════════════════════════════════════════════════════════════════════════════
54
- // ─── tmux (multiplexerhighest priority) ───────────────────────────────────
58
+ // ─── IDE Drivers (highest priority prefer integrated terminals) ────────────
59
+ /**
60
+ * Base class for IDE drivers that use CLI commands to open windows.
61
+ * IDEs don't support programmatic closing, so closeTab returns false.
62
+ */
63
+ class IDEDriver {
64
+ async openTab(opts) {
65
+ const cliAvailable = await which(this.cliBinary);
66
+ if (!cliAvailable) {
67
+ throw new Error(`${this.name} CLI not found. Install the \`${this.cliBinary}\` command.`);
68
+ }
69
+ // Open the worktree in a new IDE window
70
+ // The integrated terminal will automatically be available
71
+ try {
72
+ await exec(this.cliBinary, ["--new-window", opts.worktreePath]);
73
+ return {
74
+ sessionId: `${this.name}-${opts.branchName}`,
75
+ };
76
+ }
77
+ catch {
78
+ // Some IDEs don't support --new-window, try without
79
+ try {
80
+ await exec(this.cliBinary, [opts.worktreePath]);
81
+ return {
82
+ sessionId: `${this.name}-${opts.branchName}`,
83
+ };
84
+ }
85
+ catch {
86
+ throw new Error(`Failed to open ${this.name} window`);
87
+ }
88
+ }
89
+ }
90
+ async closeTab(session) {
91
+ // IDEs don't support programmatic tab/window closing via CLI
92
+ // Return false to indicate we couldn't close it
93
+ return false;
94
+ }
95
+ }
96
+ // ─── VS Code ─────────────────────────────────────────────────────────────────
97
+ class VSCodeDriver extends IDEDriver {
98
+ name = "vscode";
99
+ cliBinary = "code";
100
+ envVars = ["VSCODE_PID", "VSCODE_CWD"];
101
+ detect() {
102
+ return !!process.env.VSCODE_PID ||
103
+ !!process.env.VSCODE_CWD ||
104
+ process.env.TERM_PROGRAM === "vscode";
105
+ }
106
+ async openTab(opts) {
107
+ const cliAvailable = await which(this.cliBinary);
108
+ if (!cliAvailable) {
109
+ throw new Error("VS Code CLI not found. Install the `code` command via VS Code's Command Palette (Cmd+Shift+P → 'Shell Command: Install code command in PATH')");
110
+ }
111
+ try {
112
+ // Open in new window with the worktree folder
113
+ await exec(this.cliBinary, ["--new-window", opts.worktreePath]);
114
+ return {
115
+ sessionId: `vscode-${opts.branchName}`,
116
+ };
117
+ }
118
+ catch {
119
+ throw new Error("Failed to open VS Code window");
120
+ }
121
+ }
122
+ }
123
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
124
+ class CursorDriver extends IDEDriver {
125
+ name = "cursor";
126
+ cliBinary = "cursor";
127
+ envVars = ["CURSOR_TRACE_ID", "CURSOR_SHELL_VERSION"];
128
+ detect() {
129
+ return !!process.env.CURSOR_TRACE_ID ||
130
+ !!process.env.CURSOR_SHELL_VERSION;
131
+ }
132
+ }
133
+ // ─── Windsurf ────────────────────────────────────────────────────────────────
134
+ class WindsurfDriver extends IDEDriver {
135
+ name = "windsurf";
136
+ cliBinary = "windsurf";
137
+ envVars = ["WINDSURF_PARENT_PROCESS", "WINDSURF_EDITOR"];
138
+ detect() {
139
+ return !!process.env.WINDSURF_PARENT_PROCESS ||
140
+ !!process.env.WINDSURF_EDITOR;
141
+ }
142
+ async openTab(opts) {
143
+ const cliAvailable = await which(this.cliBinary);
144
+ if (!cliAvailable) {
145
+ throw new Error("Windsurf CLI not found. Ensure Windsurf is installed and the `windsurf` command is in PATH.");
146
+ }
147
+ // Windsurf may not support --new-window, try direct path
148
+ try {
149
+ await exec(this.cliBinary, [opts.worktreePath]);
150
+ return {
151
+ sessionId: `windsurf-${opts.branchName}`,
152
+ };
153
+ }
154
+ catch {
155
+ throw new Error("Failed to open Windsurf window");
156
+ }
157
+ }
158
+ }
159
+ // ─── Zed ─────────────────────────────────────────────────────────────────────
160
+ class ZedDriver extends IDEDriver {
161
+ name = "zed";
162
+ cliBinary = "zed";
163
+ envVars = ["ZED_TERM"];
164
+ detect() {
165
+ return !!process.env.ZED_TERM || process.env.TERM_PROGRAM === "zed";
166
+ }
167
+ async openTab(opts) {
168
+ const cliAvailable = await which(this.cliBinary);
169
+ if (!cliAvailable) {
170
+ throw new Error("Zed CLI not found. Ensure Zed is installed and the `zed` command is in PATH.");
171
+ }
172
+ try {
173
+ await exec(this.cliBinary, [opts.worktreePath]);
174
+ return {
175
+ sessionId: `zed-${opts.branchName}`,
176
+ };
177
+ }
178
+ catch {
179
+ throw new Error("Failed to open Zed window");
180
+ }
181
+ }
182
+ }
183
+ // ─── JetBrains IDEs ──────────────────────────────────────────────────────────
184
+ class JetBrainsDriver {
185
+ name = "jetbrains";
186
+ detect() {
187
+ const env = process.env.TERMINAL_EMULATOR || "";
188
+ return env.includes("JetBrains") || !!process.env.JETBRAINS_IDE;
189
+ }
190
+ async openTab(opts) {
191
+ // JetBrains IDEs don't have a universal CLI for opening folders
192
+ // We'll try common JetBrains CLI tools
193
+ const jetbrainsClis = ["idea", "webstorm", "pycharm", "goland", "clion", "rustrover"];
194
+ for (const cli of jetbrainsClis) {
195
+ const cliAvailable = await which(cli);
196
+ if (cliAvailable) {
197
+ try {
198
+ await exec(cli, [opts.worktreePath]);
199
+ return {
200
+ sessionId: `jetbrains-${opts.branchName}`,
201
+ };
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ }
207
+ }
208
+ throw new Error("JetBrains IDE CLI not found. Open the worktree manually in your JetBrains IDE:\n" +
209
+ ` File → Open → ${opts.worktreePath}`);
210
+ }
211
+ async closeTab(session) {
212
+ // JetBrains IDEs don't support programmatic closing
213
+ return false;
214
+ }
215
+ }
216
+ // ─── tmux (multiplexer — highest priority among terminals) ───────────────────
55
217
  class TmuxDriver {
56
218
  name = "tmux";
57
219
  detect() {
@@ -494,6 +656,159 @@ class GnomeTerminalDriver {
494
656
  return false;
495
657
  }
496
658
  }
659
+ // ─── Ghostty (macOS/Linux) ───────────────────────────────────────────────────
660
+ class GhosttyDriver {
661
+ name = "ghostty";
662
+ detect() {
663
+ return !!process.env.GHOSTTY_RESOURCES_DIR ||
664
+ process.env.TERM_PROGRAM === "ghostty";
665
+ }
666
+ async openTab(opts) {
667
+ const safePath = shellEscape(opts.worktreePath);
668
+ const safeBin = shellEscape(opts.opencodeBin);
669
+ const safeAgent = shellEscape(opts.agent);
670
+ // macOS: use AppleScript to create a new tab in the running Ghostty instance
671
+ if (process.platform === "darwin") {
672
+ const script = `tell application "Ghostty"
673
+ activate
674
+ end tell
675
+ tell application "System Events"
676
+ tell process "Ghostty"
677
+ keystroke "t" using command down
678
+ delay 0.3
679
+ keystroke "cd \\"${safePath}\\" && \\"${safeBin}\\" --agent ${safeAgent}"
680
+ key code 36
681
+ end tell
682
+ end tell`;
683
+ try {
684
+ await exec("osascript", ["-e", script]);
685
+ return {};
686
+ }
687
+ catch {
688
+ // Fall through to spawn
689
+ }
690
+ }
691
+ // Linux / fallback: spawn a new ghostty window
692
+ const cmd = buildTabCommand(opts);
693
+ const ghosttyBin = await which("ghostty");
694
+ if (ghosttyBin) {
695
+ const child = spawn("ghostty", [
696
+ "-e",
697
+ "bash",
698
+ "-c",
699
+ cmd,
700
+ ], { cwd: opts.worktreePath });
701
+ return { pid: child.pid ?? undefined };
702
+ }
703
+ throw new Error("Failed to open Ghostty tab");
704
+ }
705
+ async closeTab(session) {
706
+ if (session.pid)
707
+ return killPid(session.pid);
708
+ return false;
709
+ }
710
+ }
711
+ // ─── Alacritty (Linux/macOS) ─────────────────────────────────────────────────
712
+ class AlacrittyDriver {
713
+ name = "alacritty";
714
+ detect() {
715
+ return !!process.env.ALACRITTY_WINDOW_ID ||
716
+ !!process.env.ALACRITTY_LOG ||
717
+ process.env.TERM_PROGRAM === "alacritty";
718
+ }
719
+ async openTab(opts) {
720
+ const cmd = buildTabCommand(opts);
721
+ // Alacritty doesn't support tabs natively — open a new window
722
+ const child = spawn("alacritty", [
723
+ "--working-directory",
724
+ opts.worktreePath,
725
+ "--title",
726
+ `Worktree: ${opts.branchName}`,
727
+ "-e",
728
+ "bash",
729
+ "-c",
730
+ cmd,
731
+ ], { cwd: opts.worktreePath });
732
+ return { pid: child.pid ?? undefined };
733
+ }
734
+ async closeTab(session) {
735
+ if (session.pid)
736
+ return killPid(session.pid);
737
+ return false;
738
+ }
739
+ }
740
+ // ─── Hyper (Electron-based) ──────────────────────────────────────────────────
741
+ class HyperDriver {
742
+ name = "hyper";
743
+ detect() {
744
+ return process.env.TERM_PROGRAM === "Hyper";
745
+ }
746
+ async openTab(opts) {
747
+ const safePath = shellEscape(opts.worktreePath);
748
+ const safeBin = shellEscape(opts.opencodeBin);
749
+ const safeAgent = shellEscape(opts.agent);
750
+ // macOS: use AppleScript to open a new tab in Hyper
751
+ if (process.platform === "darwin") {
752
+ const script = `tell application "Hyper"
753
+ activate
754
+ end tell
755
+ tell application "System Events"
756
+ tell process "Hyper"
757
+ keystroke "t" using command down
758
+ delay 0.3
759
+ keystroke "cd \\"${safePath}\\" && \\"${safeBin}\\" --agent ${safeAgent}"
760
+ key code 36
761
+ end tell
762
+ end tell`;
763
+ try {
764
+ await exec("osascript", ["-e", script]);
765
+ return {};
766
+ }
767
+ catch {
768
+ // Fall through to spawn
769
+ }
770
+ }
771
+ // Fallback: spawn via hyper CLI
772
+ const hyperBin = await which("hyper");
773
+ if (hyperBin) {
774
+ const child = spawn("hyper", [opts.worktreePath], { cwd: opts.worktreePath });
775
+ return { pid: child.pid ?? undefined };
776
+ }
777
+ throw new Error("Failed to open Hyper tab");
778
+ }
779
+ async closeTab(session) {
780
+ if (session.pid)
781
+ return killPid(session.pid);
782
+ return false;
783
+ }
784
+ }
785
+ // ─── Rio (Rust-based terminal) ───────────────────────────────────────────────
786
+ class RioDriver {
787
+ name = "rio";
788
+ detect() {
789
+ return process.env.TERM_PROGRAM === "rio";
790
+ }
791
+ async openTab(opts) {
792
+ const cmd = buildTabCommand(opts);
793
+ // Rio supports tabs but has no IPC — spawn a new window
794
+ const rioBin = await which("rio");
795
+ if (rioBin) {
796
+ const child = spawn("rio", [
797
+ "-e",
798
+ "bash",
799
+ "-c",
800
+ cmd,
801
+ ], { cwd: opts.worktreePath });
802
+ return { pid: child.pid ?? undefined };
803
+ }
804
+ throw new Error("Failed to open Rio terminal");
805
+ }
806
+ async closeTab(session) {
807
+ if (session.pid)
808
+ return killPid(session.pid);
809
+ return false;
810
+ }
811
+ }
497
812
  // ─── Fallback (PID-based, always matches) ────────────────────────────────────
498
813
  class FallbackDriver {
499
814
  name = "fallback";
@@ -571,17 +886,32 @@ class FallbackDriver {
571
886
  /**
572
887
  * Ordered list of terminal drivers. Detection runs first-to-last.
573
888
  *
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.
889
+ * Priority: IDEs first (vscode, cursor, windsurf), then multiplexers (tmux),
890
+ * then terminal emulators, then fallback.
891
+ * This ensures that if the user is in VS Code's terminal, we offer to open
892
+ * in VS Code rather than a standalone terminal.
576
893
  */
577
894
  const DRIVERS = [
895
+ // IDE drivers - highest priority (prefer integrated terminals)
896
+ new VSCodeDriver(),
897
+ new CursorDriver(),
898
+ new WindsurfDriver(),
899
+ new ZedDriver(),
900
+ new JetBrainsDriver(),
901
+ // Multiplexer
578
902
  new TmuxDriver(),
903
+ // Terminal emulators
579
904
  new ITerm2Driver(),
580
905
  new TerminalAppDriver(),
581
906
  new KittyDriver(),
582
907
  new WeztermDriver(),
908
+ new GhosttyDriver(),
909
+ new AlacrittyDriver(),
910
+ new HyperDriver(),
911
+ new RioDriver(),
583
912
  new KonsoleDriver(),
584
913
  new GnomeTerminalDriver(),
914
+ // Fallback
585
915
  new FallbackDriver(),
586
916
  ];
587
917
  /** Map of driver name → driver instance for reverse lookup. */
@@ -612,7 +942,7 @@ export function getDriverByName(name) {
612
942
  * This is the main entry point for worktree_remove cleanup.
613
943
  */
614
944
  export async function closeSession(session) {
615
- if (session.mode === "terminal") {
945
+ if (session.mode === "terminal" || session.mode === "ide") {
616
946
  const driver = getDriverByName(session.terminal);
617
947
  if (driver) {
618
948
  const closed = await driver.closeTab(session);
@@ -625,3 +955,310 @@ export async function closeSession(session) {
625
955
  return killPid(session.pid);
626
956
  return false;
627
957
  }
958
+ // ─── IDE Detection Helpers ───────────────────────────────────────────────────
959
+ /**
960
+ * Check if a given driver is an IDE driver (VS Code, Cursor, Windsurf, Zed, JetBrains).
961
+ * Used to determine whether to attempt a fallback when the IDE CLI is unavailable.
962
+ */
963
+ export function isIDEDriver(driver) {
964
+ return driver instanceof IDEDriver || driver instanceof JetBrainsDriver;
965
+ }
966
+ /**
967
+ * Detect the first non-IDE terminal driver that matches the environment.
968
+ * Used as a fallback when the IDE CLI is not available (e.g., `code` not in PATH).
969
+ *
970
+ * Skips IDE drivers and JetBrains, tries tmux, iTerm2, Terminal.app, kitty, etc.
971
+ * Returns null if no terminal driver matches (only fallback driver left).
972
+ */
973
+ export function detectFallbackDriver() {
974
+ for (const driver of DRIVERS) {
975
+ // Skip IDE drivers — we want a real terminal emulator
976
+ if (driver instanceof IDEDriver || driver instanceof JetBrainsDriver)
977
+ continue;
978
+ // Skip the generic fallback — prefer a specific driver
979
+ if (driver instanceof FallbackDriver)
980
+ continue;
981
+ if (driver.detect())
982
+ return driver;
983
+ }
984
+ // If no specific terminal detected, use the fallback driver
985
+ return new FallbackDriver();
986
+ }
987
+ /**
988
+ * Check if we're currently inside an IDE's integrated terminal.
989
+ * Returns the IDE driver if detected, null otherwise.
990
+ */
991
+ export function detectIDE() {
992
+ for (const driver of DRIVERS) {
993
+ // IDE drivers are VSCodeDriver, CursorDriver, WindsurfDriver, etc.
994
+ // They come before TmuxDriver in the array
995
+ if (driver instanceof IDEDriver && driver.detect()) {
996
+ return driver;
997
+ }
998
+ // Also check JetBrains separately (not an IDEDriver subclass)
999
+ if (driver instanceof JetBrainsDriver && driver.detect()) {
1000
+ return driver;
1001
+ }
1002
+ }
1003
+ return null;
1004
+ }
1005
+ /**
1006
+ * Check if a specific IDE's CLI is available on the system.
1007
+ */
1008
+ export async function isIDECliAvailable(ideName) {
1009
+ const cliMap = {
1010
+ vscode: "code",
1011
+ cursor: "cursor",
1012
+ windsurf: "windsurf",
1013
+ zed: "zed",
1014
+ jetbrains: "idea", // or webstorm, pycharm, etc.
1015
+ };
1016
+ const cli = cliMap[ideName];
1017
+ if (!cli)
1018
+ return false;
1019
+ return !!(await which(cli));
1020
+ }
1021
+ /**
1022
+ * Get a list of all available IDE CLIs on the system.
1023
+ * Useful for offering launch options.
1024
+ */
1025
+ export async function getAvailableIDEs() {
1026
+ const ides = ["vscode", "cursor", "windsurf", "zed"];
1027
+ const available = [];
1028
+ for (const ide of ides) {
1029
+ if (await isIDECliAvailable(ide)) {
1030
+ available.push(ide);
1031
+ }
1032
+ }
1033
+ return available;
1034
+ }
1035
+ // ─── Strategy 1: Environment variable detection (skip IDE drivers) ───────────
1036
+ function detectTerminalFromEnv() {
1037
+ for (const driver of DRIVERS) {
1038
+ if (isIDEDriver(driver))
1039
+ continue;
1040
+ if (driver instanceof FallbackDriver)
1041
+ continue;
1042
+ if (driver.detect())
1043
+ return driver;
1044
+ }
1045
+ return null;
1046
+ }
1047
+ // ─── Strategy 2: Process-tree detection ──────────────────────────────────────
1048
+ /**
1049
+ * Map of known process names (lowercase) to terminal driver names.
1050
+ * Used by process-tree detection to match parent processes.
1051
+ */
1052
+ const PROCESS_NAME_MAP = {
1053
+ // macOS process names (from ps -o comm=)
1054
+ "iterm2": "iterm2",
1055
+ "terminal": "terminal.app",
1056
+ "kitty": "kitty",
1057
+ "ghostty": "ghostty",
1058
+ "alacritty": "alacritty",
1059
+ "wezterm-gui": "wezterm",
1060
+ "wezterm": "wezterm",
1061
+ "hyper": "hyper",
1062
+ "rio": "rio",
1063
+ // Linux process names
1064
+ "gnome-terminal-server": "gnome-terminal",
1065
+ "gnome-terminal-": "gnome-terminal",
1066
+ "konsole": "konsole",
1067
+ // Multiplexers
1068
+ "tmux: server": "tmux",
1069
+ "tmux": "tmux",
1070
+ };
1071
+ /**
1072
+ * Walk up the process parent chain to find a known terminal emulator.
1073
+ * Works on macOS (ps) and Linux (/proc). Stops after 15 ancestors or PID <= 1.
1074
+ */
1075
+ async function detectFromProcessTree() {
1076
+ const MAX_DEPTH = 15;
1077
+ let pid = process.ppid || process.pid;
1078
+ const visited = new Set();
1079
+ if (process.platform === "darwin" || process.platform === "linux") {
1080
+ for (let depth = 0; depth < MAX_DEPTH && pid > 1 && !visited.has(pid); depth++) {
1081
+ visited.add(pid);
1082
+ try {
1083
+ let comm = "";
1084
+ let ppid = 0;
1085
+ if (process.platform === "linux") {
1086
+ // Linux: read /proc/<pid>/comm and /proc/<pid>/stat
1087
+ try {
1088
+ const commData = fs.readFileSync(`/proc/${pid}/comm`, "utf-8").trim();
1089
+ comm = commData;
1090
+ const statData = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
1091
+ // Format: pid (comm) state ppid ...
1092
+ const match = statData.match(/\)\s+\S+\s+(\d+)/);
1093
+ ppid = match ? parseInt(match[1], 10) : 0;
1094
+ }
1095
+ catch {
1096
+ break; // Can't read /proc — permission denied or process gone
1097
+ }
1098
+ }
1099
+ else {
1100
+ // macOS: use ps to get parent PID and command name
1101
+ const result = await exec("ps", ["-o", "ppid=", "-o", "comm=", "-p", String(pid)], {
1102
+ timeout: 2000,
1103
+ nothrow: true,
1104
+ });
1105
+ const output = result.stdout.trim();
1106
+ if (!output)
1107
+ break;
1108
+ // Parse: " 1234 /Applications/Ghostty.app/Contents/MacOS/ghostty"
1109
+ const spaceIdx = output.trimStart().indexOf(" ");
1110
+ if (spaceIdx === -1)
1111
+ break;
1112
+ const trimmed = output.trimStart();
1113
+ ppid = parseInt(trimmed.substring(0, spaceIdx), 10);
1114
+ const fullPath = trimmed.substring(spaceIdx).trim();
1115
+ // Extract just the binary name from the full path
1116
+ comm = fullPath.split("/").pop() || fullPath;
1117
+ }
1118
+ if (!comm)
1119
+ break;
1120
+ // Match against known process names (case-insensitive)
1121
+ const commLower = comm.toLowerCase();
1122
+ for (const [processName, driverName] of Object.entries(PROCESS_NAME_MAP)) {
1123
+ if (commLower === processName || commLower.startsWith(processName)) {
1124
+ const driver = getDriverByName(driverName);
1125
+ if (driver) {
1126
+ return { driver, detail: `process "${comm}" at PID ${pid}` };
1127
+ }
1128
+ }
1129
+ }
1130
+ pid = ppid;
1131
+ }
1132
+ catch {
1133
+ break; // ps failed or process tree unavailable
1134
+ }
1135
+ }
1136
+ }
1137
+ return null;
1138
+ }
1139
+ // ─── Strategy 3: Frontmost app detection (macOS only) ────────────────────────
1140
+ /**
1141
+ * Map of macOS bundle identifiers to terminal driver names.
1142
+ */
1143
+ const BUNDLE_ID_MAP = {
1144
+ "com.googlecode.iterm2": "iterm2",
1145
+ "com.apple.terminal": "terminal.app",
1146
+ "net.kovidgoyal.kitty": "kitty",
1147
+ "com.mitchellh.ghostty": "ghostty",
1148
+ "org.alacritty": "alacritty",
1149
+ "com.github.wez.wezterm": "wezterm",
1150
+ "co.zeit.hyper": "hyper",
1151
+ "com.raphaelamorim.rio": "rio",
1152
+ };
1153
+ /**
1154
+ * Detect the frontmost application on macOS and match to a terminal driver.
1155
+ * Uses AppleScript to query System Events for the frontmost process's bundle ID.
1156
+ *
1157
+ * This catches cases where the process tree doesn't contain the terminal
1158
+ * (e.g., launched via Spotlight or a launcher).
1159
+ */
1160
+ async function detectFromFrontmostApp() {
1161
+ if (process.platform !== "darwin")
1162
+ return null;
1163
+ try {
1164
+ const script = 'tell application "System Events" to get bundle identifier of first process whose frontmost is true';
1165
+ const result = await exec("osascript", ["-e", script], { timeout: 3000, nothrow: true });
1166
+ const bundleId = result.stdout.trim().toLowerCase();
1167
+ if (!bundleId)
1168
+ return null;
1169
+ // Direct lookup
1170
+ const driverName = BUNDLE_ID_MAP[bundleId];
1171
+ if (driverName) {
1172
+ const driver = getDriverByName(driverName);
1173
+ if (driver) {
1174
+ return { driver, detail: `frontmost app bundle "${bundleId}"` };
1175
+ }
1176
+ }
1177
+ // Partial match (e.g., "com.apple.Terminal" vs "com.apple.terminal")
1178
+ for (const [knownBundle, driverName] of Object.entries(BUNDLE_ID_MAP)) {
1179
+ if (bundleId.includes(knownBundle) || knownBundle.includes(bundleId)) {
1180
+ const driver = getDriverByName(driverName);
1181
+ if (driver) {
1182
+ return { driver, detail: `frontmost app bundle "${bundleId}" (partial match)` };
1183
+ }
1184
+ }
1185
+ }
1186
+ }
1187
+ catch {
1188
+ // AppleScript failed — accessibility permissions may be missing
1189
+ }
1190
+ return null;
1191
+ }
1192
+ // ─── Strategy 4: User preference from .cortex/config.json ───────────────────
1193
+ /**
1194
+ * Read the user's preferred terminal from .cortex/config.json.
1195
+ * Format: { "preferredTerminal": "ghostty" }
1196
+ *
1197
+ * Accepts any driver name registered in the DRIVERS array.
1198
+ * This is the escape hatch for unusual setups where auto-detection fails.
1199
+ */
1200
+ function getPreferredTerminal(projectRoot) {
1201
+ const roots = [
1202
+ projectRoot,
1203
+ process.cwd(),
1204
+ process.env.HOME,
1205
+ ].filter(Boolean);
1206
+ for (const root of roots) {
1207
+ const configPath = path.join(root, ".cortex", "config.json");
1208
+ if (!fs.existsSync(configPath))
1209
+ continue;
1210
+ try {
1211
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1212
+ if (config.preferredTerminal && typeof config.preferredTerminal === "string") {
1213
+ const driver = getDriverByName(config.preferredTerminal);
1214
+ if (driver && !isIDEDriver(driver)) {
1215
+ return driver;
1216
+ }
1217
+ }
1218
+ }
1219
+ catch {
1220
+ // Invalid JSON — skip
1221
+ }
1222
+ }
1223
+ return null;
1224
+ }
1225
+ // ─── Main entry point: detectTerminalDriver() ───────────────────────────────
1226
+ /**
1227
+ * Async multi-strategy detection of the user's terminal emulator.
1228
+ *
1229
+ * This is the PRIMARY function for "Open in terminal tab" — it NEVER returns
1230
+ * an IDE driver. Use `detectDriver()` for IDE-first detection (e.g., "Open in IDE").
1231
+ *
1232
+ * Strategy chain:
1233
+ * 1. Environment variables (fast, synchronous)
1234
+ * 2. Process-tree walk (macOS: ps, Linux: /proc)
1235
+ * 3. Frontmost application (macOS: AppleScript)
1236
+ * 4. User preference (.cortex/config.json)
1237
+ * 5. FallbackDriver (last resort)
1238
+ *
1239
+ * @param projectRoot — optional project root for reading .cortex/config.json
1240
+ */
1241
+ export async function detectTerminalDriver(projectRoot) {
1242
+ // Strategy 1: Environment variables (fastest — no subprocess needed)
1243
+ const envDriver = detectTerminalFromEnv();
1244
+ if (envDriver) {
1245
+ return { driver: envDriver, strategy: "env", detail: `env match: ${envDriver.name}` };
1246
+ }
1247
+ // Strategy 2: Process-tree walk
1248
+ const treeResult = await detectFromProcessTree();
1249
+ if (treeResult) {
1250
+ return { driver: treeResult.driver, strategy: "process-tree", detail: treeResult.detail };
1251
+ }
1252
+ // Strategy 3: Frontmost app (macOS only)
1253
+ const frontmostResult = await detectFromFrontmostApp();
1254
+ if (frontmostResult) {
1255
+ return { driver: frontmostResult.driver, strategy: "frontmost-app", detail: frontmostResult.detail };
1256
+ }
1257
+ // Strategy 4: User preference
1258
+ const preferred = getPreferredTerminal(projectRoot);
1259
+ if (preferred) {
1260
+ return { driver: preferred, strategy: "user-config", detail: `config: ${preferred.name}` };
1261
+ }
1262
+ // Strategy 5: Fallback
1263
+ return { driver: new FallbackDriver(), strategy: "fallback", detail: "no terminal detected" };
1264
+ }