gru-ai 0.1.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 (143) hide show
  1. package/.claude/skills/brainstorm/SKILL.md +340 -0
  2. package/.claude/skills/code-review-excellence/SKILL.md +198 -0
  3. package/.claude/skills/directive/SKILL.md +121 -0
  4. package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
  5. package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
  6. package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
  7. package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
  8. package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
  9. package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
  10. package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
  11. package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
  12. package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
  13. package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
  14. package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
  15. package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
  16. package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
  17. package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
  18. package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
  19. package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
  20. package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
  21. package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
  22. package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
  23. package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
  24. package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
  25. package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
  26. package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
  27. package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
  28. package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
  29. package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
  30. package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
  31. package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
  32. package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
  33. package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
  34. package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
  35. package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
  36. package/.claude/skills/frontend-design/SKILL.md +42 -0
  37. package/.claude/skills/gruai-agents/SKILL.md +161 -0
  38. package/.claude/skills/gruai-config/SKILL.md +61 -0
  39. package/.claude/skills/healthcheck/SKILL.md +216 -0
  40. package/.claude/skills/report/SKILL.md +380 -0
  41. package/.claude/skills/scout/SKILL.md +452 -0
  42. package/.claude/skills/seo-audit/SKILL.md +107 -0
  43. package/.claude/skills/walkthrough/SKILL.md +274 -0
  44. package/.claude/skills/webapp-testing/SKILL.md +96 -0
  45. package/LICENSE +21 -0
  46. package/README.md +206 -0
  47. package/cli/templates/CLAUDE.md.template +57 -0
  48. package/cli/templates/agent-roles/backend.md +47 -0
  49. package/cli/templates/agent-roles/cmo.md +52 -0
  50. package/cli/templates/agent-roles/content.md +48 -0
  51. package/cli/templates/agent-roles/coo.md +66 -0
  52. package/cli/templates/agent-roles/cpo.md +52 -0
  53. package/cli/templates/agent-roles/cto.md +63 -0
  54. package/cli/templates/agent-roles/data.md +46 -0
  55. package/cli/templates/agent-roles/design.md +46 -0
  56. package/cli/templates/agent-roles/frontend.md +47 -0
  57. package/cli/templates/agent-roles/fullstack.md +47 -0
  58. package/cli/templates/agent-roles/qa.md +46 -0
  59. package/cli/templates/backlog.json.template +3 -0
  60. package/cli/templates/directive.json.template +9 -0
  61. package/cli/templates/directive.md.template +23 -0
  62. package/cli/templates/goals-index.md +21 -0
  63. package/cli/templates/gruai.config.json.template +12 -0
  64. package/cli/templates/lessons.md +16 -0
  65. package/cli/templates/vision.md +35 -0
  66. package/cli/templates/welcome-directive/directive.json +9 -0
  67. package/cli/templates/welcome-directive/directive.md +53 -0
  68. package/dist/assets/GamePage-C5XQQOQH.js +49 -0
  69. package/dist/assets/README.md +17 -0
  70. package/dist/assets/characters/char_0.png +0 -0
  71. package/dist/assets/characters/char_1.png +0 -0
  72. package/dist/assets/characters/char_10.png +0 -0
  73. package/dist/assets/characters/char_11.png +0 -0
  74. package/dist/assets/characters/char_2.png +0 -0
  75. package/dist/assets/characters/char_3.png +0 -0
  76. package/dist/assets/characters/char_4.png +0 -0
  77. package/dist/assets/characters/char_5.png +0 -0
  78. package/dist/assets/characters/char_6.png +0 -0
  79. package/dist/assets/characters/char_7.png +0 -0
  80. package/dist/assets/characters/char_8.png +0 -0
  81. package/dist/assets/characters/char_9.png +0 -0
  82. package/dist/assets/index-CnTPDqpP.js +12 -0
  83. package/dist/assets/index-gR5q7ikB.css +1 -0
  84. package/dist/assets/office/furniture.png +0 -0
  85. package/dist/assets/office/room-builder.png +0 -0
  86. package/dist/index.html +16 -0
  87. package/dist-server/scripts/intelligence-trends.d.ts +100 -0
  88. package/dist-server/scripts/intelligence-trends.js +365 -0
  89. package/dist-server/server/actions/cleanup.d.ts +4 -0
  90. package/dist-server/server/actions/cleanup.js +30 -0
  91. package/dist-server/server/actions/send-input.d.ts +6 -0
  92. package/dist-server/server/actions/send-input.js +147 -0
  93. package/dist-server/server/actions/terminal.d.ts +4 -0
  94. package/dist-server/server/actions/terminal.js +427 -0
  95. package/dist-server/server/config.d.ts +9 -0
  96. package/dist-server/server/config.js +217 -0
  97. package/dist-server/server/db.d.ts +7 -0
  98. package/dist-server/server/db.js +79 -0
  99. package/dist-server/server/hooks/event-receiver.d.ts +11 -0
  100. package/dist-server/server/hooks/event-receiver.js +36 -0
  101. package/dist-server/server/index.d.ts +1 -0
  102. package/dist-server/server/index.js +552 -0
  103. package/dist-server/server/notifications/macos.d.ts +5 -0
  104. package/dist-server/server/notifications/macos.js +22 -0
  105. package/dist-server/server/notifications/notifier.d.ts +17 -0
  106. package/dist-server/server/notifications/notifier.js +110 -0
  107. package/dist-server/server/parsers/process-discovery.d.ts +39 -0
  108. package/dist-server/server/parsers/process-discovery.js +776 -0
  109. package/dist-server/server/parsers/session-scanner.d.ts +56 -0
  110. package/dist-server/server/parsers/session-scanner.js +390 -0
  111. package/dist-server/server/parsers/session-state.d.ts +68 -0
  112. package/dist-server/server/parsers/session-state.js +696 -0
  113. package/dist-server/server/parsers/session-state.test.d.ts +1 -0
  114. package/dist-server/server/parsers/session-state.test.js +950 -0
  115. package/dist-server/server/parsers/task-parser.d.ts +10 -0
  116. package/dist-server/server/parsers/task-parser.js +97 -0
  117. package/dist-server/server/parsers/team-parser.d.ts +3 -0
  118. package/dist-server/server/parsers/team-parser.js +67 -0
  119. package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
  120. package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
  121. package/dist-server/server/platform/claude-code.d.ts +34 -0
  122. package/dist-server/server/platform/claude-code.js +94 -0
  123. package/dist-server/server/platform/index.d.ts +5 -0
  124. package/dist-server/server/platform/index.js +1 -0
  125. package/dist-server/server/platform/types.d.ts +190 -0
  126. package/dist-server/server/platform/types.js +9 -0
  127. package/dist-server/server/state/aggregator.d.ts +42 -0
  128. package/dist-server/server/state/aggregator.js +1080 -0
  129. package/dist-server/server/state/work-item-types.d.ts +555 -0
  130. package/dist-server/server/state/work-item-types.js +168 -0
  131. package/dist-server/server/types.d.ts +237 -0
  132. package/dist-server/server/types.js +1 -0
  133. package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
  134. package/dist-server/server/watchers/claude-watcher.js +130 -0
  135. package/dist-server/server/watchers/context-watcher.d.ts +22 -0
  136. package/dist-server/server/watchers/context-watcher.js +125 -0
  137. package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
  138. package/dist-server/server/watchers/directive-watcher.js +497 -0
  139. package/dist-server/server/watchers/session-watcher.d.ts +18 -0
  140. package/dist-server/server/watchers/session-watcher.js +126 -0
  141. package/dist-server/server/watchers/state-watcher.d.ts +36 -0
  142. package/dist-server/server/watchers/state-watcher.js +369 -0
  143. package/package.json +68 -0
@@ -0,0 +1,147 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const TMUX_PANE_ID_REGEX = /^%\d+$/;
5
+ function escapeAppleScript(str) {
6
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
7
+ }
8
+ function validateOwnership(paneId, aggregator) {
9
+ const state = aggregator.getState();
10
+ const ownedBySession = state.sessions.some((s) => s.paneId === paneId);
11
+ const ownedByMember = state.teams.some((team) => team.members.some((member) => member.tmuxPaneId === paneId));
12
+ return ownedBySession || ownedByMember;
13
+ }
14
+ async function sendInputTmux(paneId, input, type) {
15
+ // Verify pane exists in tmux
16
+ try {
17
+ const { stdout: allPanes } = await execFileAsync('tmux', [
18
+ 'list-panes', '-a', '-F', '#{pane_id}',
19
+ ]);
20
+ const paneIds = allPanes.trim().split('\n');
21
+ if (!paneIds.includes(paneId)) {
22
+ return { ok: false, error: 'Pane not found' };
23
+ }
24
+ }
25
+ catch {
26
+ return { ok: false, error: 'Pane not found' };
27
+ }
28
+ try {
29
+ switch (type) {
30
+ case 'approve':
31
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', 'y']);
32
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
33
+ break;
34
+ case 'reject':
35
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', 'n']);
36
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
37
+ break;
38
+ case 'abort':
39
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, 'C-c']);
40
+ break;
41
+ case 'text':
42
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', input]);
43
+ await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
44
+ break;
45
+ }
46
+ return { ok: true };
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ return { ok: false, error: message };
51
+ }
52
+ }
53
+ async function sendInputIterm(paneId, input, type) {
54
+ const itermId = paneId.slice('iterm:'.length);
55
+ // Sanitize: iTerm2 unique IDs are alphanumeric with hyphens/dots
56
+ const safeId = itermId.replace(/[^a-zA-Z0-9\-_.]/g, '');
57
+ if (!safeId || safeId !== itermId) {
58
+ return { ok: false, error: `Invalid iTerm session ID: ${itermId}` };
59
+ }
60
+ try {
61
+ if (type === 'abort') {
62
+ // Send Ctrl-C (ASCII character 3) without newline
63
+ const script = `
64
+ tell application "iTerm2"
65
+ repeat with w in windows
66
+ repeat with t in tabs of w
67
+ repeat with s in sessions of t
68
+ if unique ID of s is "${safeId}" then
69
+ tell s to write text (ASCII character 3) without newline
70
+ return "ok"
71
+ end if
72
+ end repeat
73
+ end repeat
74
+ end repeat
75
+ return "not_found"
76
+ end tell
77
+ `;
78
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
79
+ if (stdout.trim() === 'not_found') {
80
+ return { ok: false, error: 'iTerm2 session not found' };
81
+ }
82
+ return { ok: true };
83
+ }
84
+ // For approve/reject/text: write text sends with newline by default
85
+ let textToSend;
86
+ switch (type) {
87
+ case 'approve':
88
+ textToSend = 'y';
89
+ break;
90
+ case 'reject':
91
+ textToSend = 'n';
92
+ break;
93
+ case 'text':
94
+ textToSend = input;
95
+ break;
96
+ default:
97
+ return { ok: false, error: `Unknown input type: ${type}` };
98
+ }
99
+ const escaped = escapeAppleScript(textToSend);
100
+ const script = `
101
+ tell application "iTerm2"
102
+ repeat with w in windows
103
+ repeat with t in tabs of w
104
+ repeat with s in sessions of t
105
+ if unique ID of s is "${safeId}" then
106
+ tell s to write text "${escaped}"
107
+ return "ok"
108
+ end if
109
+ end repeat
110
+ end repeat
111
+ end repeat
112
+ return "not_found"
113
+ end tell
114
+ `;
115
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
116
+ if (stdout.trim() === 'not_found') {
117
+ return { ok: false, error: 'iTerm2 session not found' };
118
+ }
119
+ return { ok: true };
120
+ }
121
+ catch (err) {
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ return { ok: false, error: message };
124
+ }
125
+ }
126
+ export async function sendInput(request, aggregator) {
127
+ const { paneId, input, type } = request;
128
+ // Validate ownership
129
+ if (!validateOwnership(paneId, aggregator)) {
130
+ return { ok: false, error: 'Pane not owned by any known session' };
131
+ }
132
+ // Validate text input length
133
+ if (type === 'text' && input.length > 500) {
134
+ return { ok: false, error: 'Input too long' };
135
+ }
136
+ // Route to the appropriate handler
137
+ if (paneId.startsWith('iterm:')) {
138
+ return sendInputIterm(paneId, input, type);
139
+ }
140
+ if (TMUX_PANE_ID_REGEX.test(paneId)) {
141
+ return sendInputTmux(paneId, input, type);
142
+ }
143
+ if (paneId.startsWith('warp:') || paneId.startsWith('terminal:')) {
144
+ return { ok: false, error: 'Send-input not supported for this terminal (use tmux for full support)' };
145
+ }
146
+ return { ok: false, error: 'Invalid pane ID format' };
147
+ }
@@ -0,0 +1,4 @@
1
+ export declare function focusPane(paneId: string): Promise<{
2
+ ok: boolean;
3
+ error?: string;
4
+ }>;
@@ -0,0 +1,427 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const PANE_ID_REGEX = /^%\d+$/;
5
+ /**
6
+ * Extract the numeric suffix from a tty path like "/dev/ttys060" → 60.
7
+ */
8
+ function ttyNumber(tty) {
9
+ const m = tty.match(/ttys(\d+)$/);
10
+ return m ? parseInt(m[1], 10) : null;
11
+ }
12
+ /**
13
+ * Detect which terminal application hosts a given tty by walking the process tree.
14
+ */
15
+ async function detectTerminalForTty(tty) {
16
+ try {
17
+ // Get the full process tree in one call
18
+ const { stdout: allProcs } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,comm=']);
19
+ const procs = new Map();
20
+ for (const line of allProcs.trim().split('\n')) {
21
+ const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
22
+ if (match) {
23
+ procs.set(parseInt(match[1]), { ppid: parseInt(match[2]), comm: match[3].trim() });
24
+ }
25
+ }
26
+ // Find processes on this tty
27
+ const ttyShort = tty.replace('/dev/', '');
28
+ const { stdout: ttyOutput } = await execFileAsync('ps', ['-t', ttyShort, '-o', 'pid=']);
29
+ const ttyPids = ttyOutput.trim().split('\n')
30
+ .map(s => parseInt(s.trim()))
31
+ .filter(n => !isNaN(n));
32
+ // Walk up the process tree from each tty process
33
+ for (const startPid of ttyPids) {
34
+ let current = startPid;
35
+ const visited = new Set();
36
+ while (current > 1 && !visited.has(current)) {
37
+ visited.add(current);
38
+ const proc = procs.get(current);
39
+ if (!proc)
40
+ break;
41
+ const comm = proc.comm;
42
+ if (/iTerm/i.test(comm))
43
+ return 'iterm2';
44
+ if (/[Ww]arp/.test(comm))
45
+ return 'warp';
46
+ if (/^Terminal$/.test(comm))
47
+ return 'terminal';
48
+ current = proc.ppid;
49
+ }
50
+ }
51
+ return 'unknown';
52
+ }
53
+ catch {
54
+ return 'unknown';
55
+ }
56
+ }
57
+ /**
58
+ * Bring iTerm2 to foreground, bypassing macOS focus-stealing prevention.
59
+ */
60
+ async function bringITermToFront() {
61
+ try {
62
+ await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
63
+ ObjC.import('AppKit');
64
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.googlecode.iterm2');
65
+ if (apps.count > 0) {
66
+ apps.objectAtIndex(0).activateWithOptions(3);
67
+ 'ok';
68
+ }
69
+ `]);
70
+ }
71
+ catch {
72
+ // Best effort
73
+ }
74
+ }
75
+ /**
76
+ * Activate iTerm2 and focus the specific tab containing a tmux client tty.
77
+ *
78
+ * iTerm2 tab ttys differ from tmux client ttys by 1 (the tmux client is a
79
+ * child process of the shell in the iTerm2 tab). So we enumerate all iTerm2
80
+ * tabs, find the one whose tty number is 1 less than the tmux client tty,
81
+ * and select that tab.
82
+ */
83
+ async function activateITermTab(tmuxClientTty) {
84
+ const targetNum = ttyNumber(tmuxClientTty);
85
+ if (targetNum !== null) {
86
+ // Enumerate iTerm2 windows/tabs/ttys and select the matching one
87
+ const script = `
88
+ tell application "iTerm2"
89
+ repeat with w in windows
90
+ set tabIdx to 0
91
+ repeat with t in tabs of w
92
+ set tabIdx to tabIdx + 1
93
+ repeat with s in sessions of t
94
+ set sessionTty to tty of s
95
+ if sessionTty contains "ttys" then
96
+ set ttyNum to text ((offset of "ttys" in sessionTty) + 4) thru -1 of sessionTty
97
+ try
98
+ set ttyNum to ttyNum as integer
99
+ if ttyNum = ${targetNum - 1} then
100
+ select t
101
+ set index of w to 1
102
+ return "found"
103
+ end if
104
+ end try
105
+ end if
106
+ end repeat
107
+ end repeat
108
+ end repeat
109
+ return "not_found"
110
+ end tell
111
+ `;
112
+ try {
113
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
114
+ if (stdout.trim() === 'found') {
115
+ await bringITermToFront();
116
+ return;
117
+ }
118
+ }
119
+ catch {
120
+ // Fall through to fallback
121
+ }
122
+ }
123
+ // Fallback: try exact tty match (in case the off-by-1 assumption doesn't hold)
124
+ const fallbackScript = `
125
+ tell application "iTerm2"
126
+ repeat with w in windows
127
+ repeat with t in tabs of w
128
+ repeat with s in sessions of t
129
+ if tty of s = "${tmuxClientTty}" then
130
+ select t
131
+ set index of w to 1
132
+ return "found"
133
+ end if
134
+ end repeat
135
+ end repeat
136
+ end repeat
137
+ end tell
138
+ `;
139
+ try {
140
+ await execFileAsync('osascript', ['-e', fallbackScript]);
141
+ }
142
+ catch {
143
+ // Best effort
144
+ }
145
+ await bringITermToFront();
146
+ }
147
+ /**
148
+ * Bring Warp to foreground and optionally switch to a specific tab.
149
+ *
150
+ * Warp has no AppleScript dictionary, so we use CGEvents to send Cmd+N keystrokes.
151
+ * Tab order is determined by sorting each tab's login shell PID start time.
152
+ */
153
+ async function activateWarp(tabIndex) {
154
+ // macOS keycodes for digits 1-9
155
+ const DIGIT_KEYCODES = {
156
+ 1: 18, 2: 19, 3: 20, 4: 21, 5: 23, 6: 22, 7: 26, 8: 28, 9: 25,
157
+ };
158
+ const keycode = tabIndex ? DIGIT_KEYCODES[tabIndex] : undefined;
159
+ try {
160
+ if (keycode) {
161
+ // Activate Warp + send Cmd+N keystroke to switch tab
162
+ await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
163
+ ObjC.import('AppKit');
164
+ ObjC.import('CoreGraphics');
165
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('dev.warp.Warp-Stable');
166
+ if (apps.count > 0) {
167
+ apps.objectAtIndex(0).activateWithOptions(3);
168
+ }
169
+ delay(0.3);
170
+ var src = $.CGEventSourceCreate($.kCGEventSourceStateHIDSystemState);
171
+ var keyDown = $.CGEventCreateKeyboardEvent(src, ${keycode}, true);
172
+ $.CGEventSetFlags(keyDown, $.kCGEventFlagMaskCommand);
173
+ var keyUp = $.CGEventCreateKeyboardEvent(src, ${keycode}, false);
174
+ $.CGEventSetFlags(keyUp, $.kCGEventFlagMaskCommand);
175
+ $.CGEventPost($.kCGHIDEventTap, keyDown);
176
+ $.CGEventPost($.kCGHIDEventTap, keyUp);
177
+ 'ok';
178
+ `], { timeout: 5000 });
179
+ }
180
+ else {
181
+ // Just bring Warp to front without tab switching
182
+ await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
183
+ ObjC.import('AppKit');
184
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('dev.warp.Warp-Stable');
185
+ if (apps.count > 0) {
186
+ apps.objectAtIndex(0).activateWithOptions(3);
187
+ 'ok';
188
+ }
189
+ `]);
190
+ }
191
+ }
192
+ catch {
193
+ // Best effort
194
+ }
195
+ }
196
+ /**
197
+ * Find which Warp tab a given PID belongs to by mapping TTYs to tab order.
198
+ *
199
+ * Returns a 1-based tab index (1-9), or undefined if we can't determine it.
200
+ */
201
+ async function findWarpTabIndex(targetPid) {
202
+ try {
203
+ // 1. Get the target process's TTY
204
+ const { stdout: ttyOut } = await execFileAsync('ps', ['-o', 'tty=', '-p', String(targetPid)]);
205
+ const targetTty = ttyOut.trim();
206
+ if (!targetTty || targetTty === '??')
207
+ return undefined;
208
+ // 2. Build process tree to find all TTYs owned by Warp
209
+ const { stdout: allProcs } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,tty=,lstart=,comm=']);
210
+ const procs = new Map();
211
+ for (const line of allProcs.trim().split('\n')) {
212
+ // Format: PID PPID TTY LSTART(5 fields) COMM
213
+ const m = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w+\s+\d+\s+\w+\s+[\d:]+\s+\d+)\s+(.+)$/);
214
+ if (m) {
215
+ procs.set(parseInt(m[1]), {
216
+ ppid: parseInt(m[2]),
217
+ tty: m[3],
218
+ lstart: m[4],
219
+ comm: m[5].trim(),
220
+ });
221
+ }
222
+ }
223
+ // 3. Find Warp's main PID (the process named "stable" under Warp.app)
224
+ let warpPid;
225
+ for (const [pid, info] of procs) {
226
+ if (info.comm.includes('Warp.app') && info.comm.includes('/stable')) {
227
+ // Pick the one whose parent is NOT another Warp process
228
+ const parent = procs.get(info.ppid);
229
+ if (!parent || !parent.comm.includes('Warp.app')) {
230
+ warpPid = pid;
231
+ break;
232
+ }
233
+ }
234
+ }
235
+ if (!warpPid)
236
+ return undefined;
237
+ // 4. Find all login shells descended from Warp — these are one per tab
238
+ // Walk ancestors of each process with a real TTY to check if Warp is in the chain
239
+ const ttyToStartTime = new Map();
240
+ for (const [pid, info] of procs) {
241
+ if (info.tty === '??' || ttyToStartTime.has(info.tty))
242
+ continue;
243
+ // Only consider login shells (the first process in each tab)
244
+ if (!(info.comm === '-zsh' || info.comm === '-bash' || info.comm === '-fish' || info.comm === 'login'))
245
+ continue;
246
+ // Check if this process descends from Warp
247
+ let current = info.ppid;
248
+ const visited = new Set();
249
+ let isWarpChild = false;
250
+ while (current > 1 && !visited.has(current)) {
251
+ visited.add(current);
252
+ if (current === warpPid) {
253
+ isWarpChild = true;
254
+ break;
255
+ }
256
+ const p = procs.get(current);
257
+ if (!p)
258
+ break;
259
+ current = p.ppid;
260
+ }
261
+ if (isWarpChild) {
262
+ ttyToStartTime.set(info.tty, pid); // Use PID as proxy for creation order (lower PID = earlier)
263
+ }
264
+ }
265
+ if (!ttyToStartTime.has(targetTty))
266
+ return undefined;
267
+ // 5. Sort TTYs by their login shell PID (ascending = creation order = tab order)
268
+ const sortedTtys = [...ttyToStartTime.entries()]
269
+ .sort((a, b) => a[1] - b[1])
270
+ .map(e => e[0]);
271
+ const tabIndex = sortedTtys.indexOf(targetTty) + 1; // 1-based
272
+ return (tabIndex >= 1 && tabIndex <= 9) ? tabIndex : undefined;
273
+ }
274
+ catch {
275
+ return undefined;
276
+ }
277
+ }
278
+ /**
279
+ * Bring Terminal.app to foreground.
280
+ */
281
+ async function activateTerminalApp() {
282
+ try {
283
+ await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
284
+ ObjC.import('AppKit');
285
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.Terminal');
286
+ if (apps.count > 0) {
287
+ apps.objectAtIndex(0).activateWithOptions(3);
288
+ 'ok';
289
+ }
290
+ `]);
291
+ }
292
+ catch {
293
+ // Best effort
294
+ }
295
+ }
296
+ /**
297
+ * Focus an iTerm2 session directly by its unique ID (non-tmux).
298
+ */
299
+ async function focusItermSession(itermId) {
300
+ // Sanitize: iTerm2 unique IDs are alphanumeric with hyphens/dots
301
+ const safeId = itermId.replace(/[^a-zA-Z0-9\-_.]/g, '');
302
+ if (!safeId || safeId !== itermId) {
303
+ return { ok: false, error: `Invalid iTerm session ID: ${itermId}` };
304
+ }
305
+ const script = `
306
+ tell application "iTerm2"
307
+ repeat with w in windows
308
+ repeat with t in tabs of w
309
+ repeat with s in sessions of t
310
+ if unique ID of s is "${safeId}" then
311
+ select t
312
+ set index of w to 1
313
+ return "found"
314
+ end if
315
+ end repeat
316
+ end repeat
317
+ end repeat
318
+ return "not_found"
319
+ end tell
320
+ `;
321
+ try {
322
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
323
+ if (stdout.trim() === 'found') {
324
+ await bringITermToFront();
325
+ return { ok: true };
326
+ }
327
+ return { ok: false, error: 'iTerm2 session not found' };
328
+ }
329
+ catch (err) {
330
+ const message = err instanceof Error ? err.message : String(err);
331
+ return { ok: false, error: message };
332
+ }
333
+ }
334
+ export async function focusPane(paneId) {
335
+ // Route iTerm2 native sessions (non-tmux)
336
+ if (paneId.startsWith('iterm:')) {
337
+ const itermId = paneId.slice('iterm:'.length);
338
+ return focusItermSession(itermId);
339
+ }
340
+ // Route Warp native sessions — activate and switch to the correct tab
341
+ if (paneId.startsWith('warp:')) {
342
+ const pid = parseInt(paneId.slice('warp:'.length), 10);
343
+ const tabIndex = isNaN(pid) ? undefined : await findWarpTabIndex(pid);
344
+ await activateWarp(tabIndex);
345
+ return { ok: true };
346
+ }
347
+ // Route Terminal.app native sessions — best-effort bring to front
348
+ if (paneId.startsWith('terminal:')) {
349
+ await activateTerminalApp();
350
+ return { ok: true };
351
+ }
352
+ if (!PANE_ID_REGEX.test(paneId)) {
353
+ return { ok: false, error: `Invalid pane ID format: ${paneId}` };
354
+ }
355
+ try {
356
+ // Check that the pane exists and get its session + window info
357
+ const { stdout: paneInfo } = await execFileAsync('tmux', [
358
+ 'display-message', '-p', '-t', paneId,
359
+ '#{session_name}:#{window_id}\t#{pane_id}',
360
+ ]);
361
+ if (!paneInfo.trim().includes(paneId)) {
362
+ return { ok: false, error: `Pane ${paneId} not found` };
363
+ }
364
+ // Get the window ID for tmux switching
365
+ const { stdout: windowId } = await execFileAsync('tmux', [
366
+ 'display-message', '-p', '-t', paneId, '#{window_id}',
367
+ ]);
368
+ const trimmedWindowId = windowId.trim();
369
+ // Get the tmux session name
370
+ const { stdout: sessionName } = await execFileAsync('tmux', [
371
+ 'display-message', '-p', '-t', paneId, '#{session_name}',
372
+ ]);
373
+ // Find the client tty attached to this tmux session
374
+ const { stdout: clients } = await execFileAsync('tmux', [
375
+ 'list-clients', '-F', '#{client_tty}\t#{client_session}',
376
+ ]);
377
+ let clientTty;
378
+ for (const line of clients.trim().split('\n')) {
379
+ const [tty, sess] = line.split('\t');
380
+ if (sess === sessionName.trim()) {
381
+ clientTty = tty;
382
+ break;
383
+ }
384
+ }
385
+ // Select the tmux window and pane
386
+ await execFileAsync('tmux', ['select-window', '-t', trimmedWindowId]);
387
+ await execFileAsync('tmux', ['select-pane', '-t', paneId]);
388
+ // Detect which terminal hosts the tmux client and activate it
389
+ if (clientTty) {
390
+ const terminal = await detectTerminalForTty(clientTty);
391
+ switch (terminal) {
392
+ case 'iterm2':
393
+ await activateITermTab(clientTty);
394
+ break;
395
+ case 'warp': {
396
+ // For tmux+Warp, find the tab by looking up processes on the client TTY
397
+ const { stdout: clientPidOut } = await execFileAsync('ps', ['-t', clientTty.replace('/dev/', ''), '-o', 'pid=']);
398
+ const clientPids = clientPidOut.trim().split('\n').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
399
+ let warpTabIdx;
400
+ for (const cp of clientPids) {
401
+ warpTabIdx = await findWarpTabIndex(cp);
402
+ if (warpTabIdx)
403
+ break;
404
+ }
405
+ await activateWarp(warpTabIdx);
406
+ break;
407
+ }
408
+ case 'terminal':
409
+ await activateTerminalApp();
410
+ break;
411
+ default:
412
+ // Detection failed — try iTerm2 tab matching first (most common), then generic
413
+ await activateITermTab(clientTty);
414
+ break;
415
+ }
416
+ }
417
+ else {
418
+ // No attached client — best-effort bring any terminal to front
419
+ await bringITermToFront();
420
+ }
421
+ return { ok: true };
422
+ }
423
+ catch (err) {
424
+ const message = err instanceof Error ? err.message : String(err);
425
+ return { ok: false, error: message };
426
+ }
427
+ }
@@ -0,0 +1,9 @@
1
+ import type { ConductorConfig, ProjectConfig } from './types.js';
2
+ /**
3
+ * Discover conductor-enabled projects from ~/.claude/projects/.
4
+ * Scans subdirectories, decodes their names to real paths, and checks
5
+ * for `.context/` to identify conductor-enabled repos.
6
+ */
7
+ export declare function discoverProjects(claudeHome: string): ProjectConfig[];
8
+ export declare function loadConfig(): ConductorConfig;
9
+ export declare function saveConfig(config: ConductorConfig): void;