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,776 @@
1
+ import { exec, execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const execAsync = promisify(exec);
8
+ /**
9
+ * Discover all running claude processes and map them to tmux panes.
10
+ *
11
+ * Strategy:
12
+ * 1. Get all tmux pane PIDs
13
+ * 2. Find all `claude` processes via `pgrep`
14
+ * 3. Walk each claude process's parent chain to find its tmux pane
15
+ * 4. Extract tasks dir + session IDs from lsof (open files under ~/.claude/)
16
+ */
17
+ export async function discoverClaudePanes() {
18
+ const result = {
19
+ byTasksDir: new Map(),
20
+ byPid: new Map(),
21
+ bySessionId: new Map(),
22
+ byPaneTitle: new Map(),
23
+ panePrompts: new Map(),
24
+ byItermSession: new Map(),
25
+ orphanItermSessions: [],
26
+ };
27
+ const claudeHome = path.join(os.homedir(), '.claude');
28
+ try {
29
+ // Step 1: Get all tmux pane PIDs and titles
30
+ const { paneMap, titleMap } = await getTmuxPanes();
31
+ // Step 2: Find all claude processes
32
+ const claudePids = await findClaudePids();
33
+ // Build set of pane PIDs for fast lookup
34
+ const panePidSet = new Set(paneMap.keys());
35
+ // Step 3: For each claude PID, walk parent chain to find tmux pane
36
+ const pidToPaneId = new Map();
37
+ if (paneMap.size > 0) {
38
+ for (const claudePid of claudePids) {
39
+ const panePid = await walkParentChain(claudePid, panePidSet);
40
+ if (panePid !== null) {
41
+ const paneId = paneMap.get(panePid);
42
+ if (paneId) {
43
+ pidToPaneId.set(claudePid, paneId);
44
+ result.byPid.set(claudePid, paneId);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ // Step 3b: For unmapped PIDs, try iTerm2 native session matching
50
+ const unmappedPids = claudePids.filter(pid => !pidToPaneId.has(pid));
51
+ const itermSessions = await getItermSessions();
52
+ if (unmappedPids.length > 0 && itermSessions.length > 0) {
53
+ console.log(`[discovery] Step 3b: ${unmappedPids.length} unmapped PIDs: ${unmappedPids.join(', ')}`);
54
+ console.log(`[discovery] Step 3b: got ${itermSessions.length} iTerm2 sessions`);
55
+ const ttyMap = await getProcessTtys(unmappedPids);
56
+ console.log(`[discovery] Step 3b: TTY map: ${[...ttyMap.entries()].map(([p, t]) => `${p}→${t}`).join(', ')}`);
57
+ for (const [pid, tty] of ttyMap) {
58
+ const match = matchTtyToIterm(tty, itermSessions);
59
+ if (match) {
60
+ result.byItermSession.set(pid, {
61
+ itermId: match.uniqueId,
62
+ tty: match.tty,
63
+ name: match.name,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ // Step 3b2: For remaining unmapped PIDs, detect Warp/Terminal.app ancestry
69
+ const stillUnmapped = claudePids.filter(pid => !pidToPaneId.has(pid) && !result.byItermSession.has(pid));
70
+ if (stillUnmapped.length > 0) {
71
+ const terminalTypes = await detectTerminalForPids(stillUnmapped);
72
+ for (const [pid, terminal] of terminalTypes) {
73
+ if (terminal === 'warp' || terminal === 'terminal') {
74
+ // Get CWD for this PID
75
+ let cwd;
76
+ try {
77
+ const { stdout } = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 3000 });
78
+ const cwdLine = stdout.split('\n').find(l => l.startsWith('n/'));
79
+ if (cwdLine)
80
+ cwd = cwdLine.slice(1);
81
+ }
82
+ catch { /* best effort */ }
83
+ // Derive session ID from JSONL file timestamps
84
+ // Unlike iTerm (which has a unique session ID), Warp/Terminal have no tab ID,
85
+ // so we rely on: file created after PID started → most recently modified wins
86
+ let sessionId;
87
+ if (cwd) {
88
+ const startTimes = await getPidStartTimes([pid]);
89
+ const pidStart = startTimes.get(pid);
90
+ if (pidStart) {
91
+ try {
92
+ const projectDir = cwd.replace(/\//g, '-');
93
+ const sessionsDir = path.join(claudeHome, 'projects', projectDir);
94
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl') && !f.includes(':'));
95
+ let bestFile = '';
96
+ let bestMtime = 0;
97
+ for (const f of files) {
98
+ try {
99
+ const stat = fs.statSync(path.join(sessionsDir, f));
100
+ // File must be created after PID started (with 60s tolerance)
101
+ if (stat.birthtimeMs >= pidStart - 60_000 && stat.mtimeMs > bestMtime) {
102
+ bestMtime = stat.mtimeMs;
103
+ bestFile = f;
104
+ }
105
+ }
106
+ catch { /* skip */ }
107
+ }
108
+ if (bestFile)
109
+ sessionId = bestFile.replace('.jsonl', '');
110
+ }
111
+ catch { /* best effort */ }
112
+ }
113
+ }
114
+ const paneId = `${terminal === 'warp' ? 'warp' : 'terminal'}:${pid}`;
115
+ if (sessionId) {
116
+ result.bySessionId.set(sessionId, paneId);
117
+ }
118
+ if (cwd) {
119
+ // Register in byTasksDir via lsof
120
+ try {
121
+ const { stdout: lsofOut } = await execFileAsync('lsof', ['-a', '-p', String(pid)], { maxBuffer: 512 * 1024, timeout: 5000 });
122
+ const tasksRe = /\.claude\/tasks\/([^\s/]+)/;
123
+ for (const line of lsofOut.split('\n')) {
124
+ const taskMatch = tasksRe.exec(line);
125
+ if (taskMatch) {
126
+ result.byTasksDir.set(taskMatch[1], paneId);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ catch { /* best effort */ }
132
+ }
133
+ console.log(`[discovery] Step 3b2: PID ${pid} → ${terminal} (cwd=${cwd?.slice(-30) ?? 'unknown'}, session=${sessionId?.slice(0, 8) ?? 'none'})`);
134
+ }
135
+ }
136
+ }
137
+ // Step 3c: Extract cwd for iTerm-matched PIDs (for cwd-based session matching)
138
+ for (const [pid, info] of result.byItermSession) {
139
+ try {
140
+ const { stdout } = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 3000 });
141
+ const cwdLine = stdout.split('\n').find(l => l.startsWith('n/'));
142
+ if (cwdLine) {
143
+ info.cwd = cwdLine.slice(1);
144
+ }
145
+ }
146
+ catch {
147
+ // Best effort
148
+ }
149
+ }
150
+ // Step 3d: Derive session ID for iTerm PIDs by matching file creation/modification times.
151
+ // Claude doesn't keep JSONL files open, so lsof won't find them. Strategy:
152
+ // 1. Filter to files created AFTER the PID started (within the PID's lifetime)
153
+ // 2. Among those, pick the one with the most recent mtime (the currently active file)
154
+ // This handles context compaction where claude creates a new JSONL mid-session.
155
+ const pidStartTimes = await getPidStartTimes([...result.byItermSession.keys()]);
156
+ for (const [pid, info] of result.byItermSession) {
157
+ if (!info.cwd || info.sessionId)
158
+ continue;
159
+ const pidStart = pidStartTimes.get(pid);
160
+ if (!pidStart)
161
+ continue;
162
+ try {
163
+ const projectDir = info.cwd.replace(/\//g, '-');
164
+ const sessionsDir = path.join(claudeHome, 'projects', projectDir);
165
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl') && !f.includes(':'));
166
+ if (files.length === 0)
167
+ continue;
168
+ // Filter to files created after PID started, then pick most recently modified
169
+ let bestFile = '';
170
+ let bestMtime = 0;
171
+ const now = Date.now();
172
+ for (const f of files) {
173
+ try {
174
+ const stat = fs.statSync(path.join(sessionsDir, f));
175
+ // File must be created after PID started (with 60s tolerance)
176
+ // and modified recently (within 5 minutes)
177
+ const createdAfterPid = stat.birthtimeMs >= pidStart - 60_000;
178
+ const recentlyModified = now - stat.mtimeMs < 5 * 60 * 1000;
179
+ if (createdAfterPid && recentlyModified && stat.mtimeMs > bestMtime) {
180
+ bestMtime = stat.mtimeMs;
181
+ bestFile = f;
182
+ }
183
+ }
184
+ catch {
185
+ // Skip
186
+ }
187
+ }
188
+ if (bestFile) {
189
+ info.sessionId = bestFile.replace('.jsonl', '');
190
+ }
191
+ }
192
+ catch {
193
+ // Best effort
194
+ }
195
+ }
196
+ // Step 4: Extract tasks dirs + session IDs from lsof for ALL matched PIDs (tmux + iTerm)
197
+ const allMappedPids = [...pidToPaneId.keys(), ...result.byItermSession.keys()];
198
+ if (allMappedPids.length > 0) {
199
+ const lsofData = await extractFromLsof(allMappedPids);
200
+ for (const [pid, tasksDir] of lsofData.tasksDirs) {
201
+ const tmuxPaneId = pidToPaneId.get(pid);
202
+ if (tmuxPaneId) {
203
+ result.byTasksDir.set(tasksDir, tmuxPaneId);
204
+ }
205
+ const itermInfo = result.byItermSession.get(pid);
206
+ if (itermInfo) {
207
+ result.byTasksDir.set(tasksDir, `iterm:${itermInfo.itermId}`);
208
+ }
209
+ }
210
+ for (const [pid, sessionId] of lsofData.sessionIds) {
211
+ const tmuxPaneId = pidToPaneId.get(pid);
212
+ if (tmuxPaneId) {
213
+ result.bySessionId.set(sessionId, tmuxPaneId);
214
+ }
215
+ const itermInfo = result.byItermSession.get(pid);
216
+ if (itermInfo) {
217
+ result.bySessionId.set(sessionId, `iterm:${itermInfo.itermId}`);
218
+ }
219
+ }
220
+ }
221
+ // Step 4b: Register iTerm session IDs derived from JSONL files (step 3d) into bySessionId
222
+ for (const [, info] of result.byItermSession) {
223
+ if (info.sessionId) {
224
+ result.bySessionId.set(info.sessionId, `iterm:${info.itermId}`);
225
+ }
226
+ }
227
+ // Step 4c: For tmux PIDs not matched by lsof, derive session ID from JSONL file
228
+ // creation timestamps (same approach as iTerm step 3d and Warp step 3b2).
229
+ // Claude doesn't keep JSONL files open, so lsof rarely catches them. Instead:
230
+ // find the JSONL file created after this PID started AND most recently modified.
231
+ const tmuxPidsWithoutSession = [...pidToPaneId.keys()].filter((pid) => !result.bySessionId.has(
232
+ // Check if any session points to this PID's pane
233
+ [...result.bySessionId.entries()].find(([, p]) => p === pidToPaneId.get(pid))?.[0] ?? ''));
234
+ if (tmuxPidsWithoutSession.length > 0) {
235
+ const pidStartTimes2 = await getPidStartTimes(tmuxPidsWithoutSession);
236
+ // Get CWDs for these PIDs
237
+ const pidCwds = new Map();
238
+ for (const pid of tmuxPidsWithoutSession) {
239
+ try {
240
+ const { stdout } = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 3000 });
241
+ const cwdLine = stdout.split('\n').find(l => l.startsWith('n/'));
242
+ if (cwdLine)
243
+ pidCwds.set(pid, cwdLine.slice(1));
244
+ }
245
+ catch { /* best effort */ }
246
+ }
247
+ // Track which session IDs have already been assigned to avoid conflicts
248
+ const assignedSessionIds = new Set(result.bySessionId.keys());
249
+ for (const pid of tmuxPidsWithoutSession) {
250
+ const pidStart = pidStartTimes2.get(pid);
251
+ const cwd = pidCwds.get(pid);
252
+ if (!pidStart || !cwd)
253
+ continue;
254
+ try {
255
+ const projectDir = cwd.replace(/\//g, '-');
256
+ const sessionsDir = path.join(claudeHome, 'projects', projectDir);
257
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl') && !f.includes(':'));
258
+ let bestFile = '';
259
+ let bestMtime = 0;
260
+ const now = Date.now();
261
+ for (const f of files) {
262
+ try {
263
+ const stat = fs.statSync(path.join(sessionsDir, f));
264
+ const sessionId = f.replace('.jsonl', '');
265
+ // Skip already-assigned sessions
266
+ if (assignedSessionIds.has(sessionId))
267
+ continue;
268
+ // File must be created after PID started (with 60s tolerance)
269
+ // and modified recently (within 5 minutes)
270
+ const createdAfterPid = stat.birthtimeMs >= pidStart - 60_000;
271
+ const recentlyModified = now - stat.mtimeMs < 5 * 60 * 1000;
272
+ if (createdAfterPid && recentlyModified && stat.mtimeMs > bestMtime) {
273
+ bestMtime = stat.mtimeMs;
274
+ bestFile = f;
275
+ }
276
+ }
277
+ catch { /* skip */ }
278
+ }
279
+ if (bestFile) {
280
+ const sessionId = bestFile.replace('.jsonl', '');
281
+ const tmuxPaneId = pidToPaneId.get(pid);
282
+ result.bySessionId.set(sessionId, tmuxPaneId);
283
+ assignedSessionIds.add(sessionId);
284
+ }
285
+ }
286
+ catch { /* best effort */ }
287
+ }
288
+ }
289
+ // Step 5: Build pane title map for fuzzy matching (tmux only)
290
+ const claudePaneIds = new Set(pidToPaneId.values());
291
+ for (const [paneId, rawTitle] of titleMap) {
292
+ if (!claudePaneIds.has(paneId))
293
+ continue;
294
+ const title = normalizeTitle(rawTitle);
295
+ if (title && title !== 'claude code') {
296
+ result.byPaneTitle.set(title, paneId);
297
+ }
298
+ }
299
+ // Step 6: Capture user prompts from pane scrollback for content-based matching (tmux only)
300
+ if (claudePaneIds.size > 0) {
301
+ await capturePanePrompts([...claudePaneIds], result.panePrompts);
302
+ }
303
+ // Step 7: Collect orphan iTerm sessions (sessions with no matching claude process)
304
+ if (itermSessions.length > 0) {
305
+ const matchedItermIds = new Set([...result.byItermSession.values()].map(info => info.itermId));
306
+ for (const session of itermSessions) {
307
+ if (matchedItermIds.has(session.uniqueId))
308
+ continue;
309
+ // Skip tmux client tabs — those are tmux connections, not direct sessions
310
+ if (session.name.toLowerCase().startsWith('tmux'))
311
+ continue;
312
+ const ttyNum = parseTtyNumber(session.tty);
313
+ if (ttyNum === null)
314
+ continue;
315
+ let cwd;
316
+ let foundShellPid;
317
+ // Check TTY and TTY+1 (figterm allocates child PTY)
318
+ for (const offset of [0, 1]) {
319
+ const checkTty = `ttys${String(ttyNum + offset).padStart(3, '0')}`;
320
+ try {
321
+ const { stdout } = await execFileAsync('ps', ['-t', checkTty, '-o', 'pid=,comm='], { timeout: 3000 });
322
+ for (const line of stdout.trim().split('\n')) {
323
+ if (!line.trim())
324
+ continue;
325
+ const parts = line.trim().split(/\s+/);
326
+ const shellPid = parseInt(parts[0], 10);
327
+ const comm = parts.slice(1).join(' ');
328
+ if (isNaN(shellPid))
329
+ continue;
330
+ if (/(^|\/)(-?)(zsh|bash|fish)\b/.test(comm)) {
331
+ try {
332
+ const { stdout: lsofOut } = await execFileAsync('lsof', ['-a', '-p', String(shellPid), '-d', 'cwd', '-Fn'], { timeout: 3000 });
333
+ const cwdLine = lsofOut.split('\n').find(l => l.startsWith('n/'));
334
+ if (cwdLine) {
335
+ cwd = cwdLine.slice(1);
336
+ foundShellPid = shellPid;
337
+ }
338
+ }
339
+ catch { /* best effort */ }
340
+ }
341
+ if (cwd)
342
+ break;
343
+ }
344
+ if (cwd)
345
+ break;
346
+ }
347
+ catch { /* TTY may not exist */ }
348
+ }
349
+ // Derive candidate session IDs: find JSONL files in the project dir
350
+ // that were active during this shell's lifetime, sorted by mtime desc
351
+ const candidateSessionIds = [];
352
+ if (cwd && foundShellPid) {
353
+ try {
354
+ const shellStartTimes = await getPidStartTimes([foundShellPid]);
355
+ const shellStart = shellStartTimes.get(foundShellPid);
356
+ if (shellStart) {
357
+ const projectDir = cwd.replace(/\//g, '-');
358
+ const sessionsDir = path.join(claudeHome, 'projects', projectDir);
359
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl') && !f.includes(':'));
360
+ const candidates = [];
361
+ const now = Date.now();
362
+ for (const f of files) {
363
+ try {
364
+ const stat = fs.statSync(path.join(sessionsDir, f));
365
+ // Skip actively-written files (< 5 min old) — those have running claude processes
366
+ const isActive = now - stat.mtimeMs < 5 * 60 * 1000;
367
+ // File must have been modified after shell started (session was active in this shell)
368
+ if (!isActive && stat.mtimeMs >= shellStart) {
369
+ candidates.push({ name: f, mtimeMs: stat.mtimeMs });
370
+ }
371
+ }
372
+ catch { /* skip */ }
373
+ }
374
+ // Sort by mtime desc (most recent first)
375
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
376
+ for (const c of candidates) {
377
+ candidateSessionIds.push(c.name.replace('.jsonl', ''));
378
+ }
379
+ }
380
+ }
381
+ catch { /* best effort */ }
382
+ }
383
+ result.orphanItermSessions.push({
384
+ itermId: session.uniqueId,
385
+ tty: session.tty,
386
+ name: session.name,
387
+ cwd,
388
+ candidateSessionIds,
389
+ });
390
+ }
391
+ }
392
+ const itermCount = result.byItermSession.size;
393
+ console.log(`[discovery] Found ${claudePids.length} claude PIDs → ${pidToPaneId.size} tmux panes, ${itermCount} iTerm2 sessions, ${result.orphanItermSessions.length} orphan iTerm, ${result.panePrompts.size} with prompts`);
394
+ }
395
+ catch {
396
+ // Discovery is best-effort — failures shouldn't crash the server
397
+ }
398
+ return result;
399
+ }
400
+ /**
401
+ * Normalize a pane title for matching: remove leading emoji/symbols, lowercase, trim.
402
+ */
403
+ function normalizeTitle(raw) {
404
+ return raw
405
+ .replace(/^[\s✳⠐⠂⠈⠄✻✶·•]+/, '')
406
+ .trim()
407
+ .toLowerCase();
408
+ }
409
+ /**
410
+ * Get all tmux panes with PIDs and titles.
411
+ */
412
+ async function getTmuxPanes() {
413
+ const paneMap = new Map();
414
+ const titleMap = new Map();
415
+ try {
416
+ // Use TAB as separator since titles can contain spaces
417
+ const { stdout } = await execFileAsync('tmux', [
418
+ 'list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}\t#{pane_title}',
419
+ ]);
420
+ for (const line of stdout.trim().split('\n')) {
421
+ if (!line)
422
+ continue;
423
+ const parts = line.split('\t');
424
+ const paneId = parts[0];
425
+ const pid = parseInt(parts[1], 10);
426
+ const title = parts.slice(2).join('\t');
427
+ if (paneId && !isNaN(pid)) {
428
+ paneMap.set(pid, paneId);
429
+ if (title)
430
+ titleMap.set(paneId, title);
431
+ }
432
+ }
433
+ }
434
+ catch {
435
+ // tmux not running
436
+ }
437
+ return { paneMap, titleMap };
438
+ }
439
+ /**
440
+ * Find all PIDs of processes named 'claude'.
441
+ * Uses `ps` instead of `pgrep` because macOS pgrep can miss processes.
442
+ */
443
+ async function findClaudePids() {
444
+ try {
445
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid,comm']);
446
+ const pids = [];
447
+ for (const line of stdout.split('\n')) {
448
+ const trimmed = line.trim();
449
+ if (!trimmed)
450
+ continue;
451
+ const match = /^(\d+)\s+claude$/.exec(trimmed);
452
+ if (match)
453
+ pids.push(parseInt(match[1], 10));
454
+ }
455
+ return pids;
456
+ }
457
+ catch {
458
+ return [];
459
+ }
460
+ }
461
+ /**
462
+ * Walk the parent chain of a PID to find a tmux pane PID.
463
+ * Returns the pane PID if found, null otherwise.
464
+ */
465
+ async function walkParentChain(pid, panePids) {
466
+ let current = pid;
467
+ const visited = new Set();
468
+ // Walk up to 20 levels (safety limit)
469
+ for (let i = 0; i < 20; i++) {
470
+ if (panePids.has(current))
471
+ return current;
472
+ if (visited.has(current))
473
+ return null;
474
+ visited.add(current);
475
+ try {
476
+ const { stdout } = await execFileAsync('ps', ['-o', 'ppid=', '-p', String(current)]);
477
+ const ppid = parseInt(stdout.trim(), 10);
478
+ if (isNaN(ppid) || ppid <= 1)
479
+ return null;
480
+ current = ppid;
481
+ }
482
+ catch {
483
+ return null;
484
+ }
485
+ }
486
+ return null;
487
+ }
488
+ /**
489
+ * Extract tasks dirs and session IDs from lsof output for given claude PIDs.
490
+ *
491
+ * Matches:
492
+ * - ~/.claude/tasks/{name}/ → tasks dir name (for team builds)
493
+ * - ~/.claude/projects/{dir}/{uuid}[/...] → session UUID (parent sessions with subagent dirs)
494
+ * - ~/.claude/projects/{dir}/{uuid}.jsonl → session UUID (if caught during write)
495
+ */
496
+ async function extractFromLsof(pids) {
497
+ const result = {
498
+ tasksDirs: new Map(),
499
+ sessionIds: new Map(),
500
+ };
501
+ const tasksRe = /\.claude\/tasks\/([^\s/]+)/;
502
+ // Match a UUID after the project dir name in .claude/projects/ paths
503
+ const sessionRe = /\.claude\/projects\/[^\s/]+\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/;
504
+ const parseLine = (line) => {
505
+ const parts = line.trim().split(/\s+/);
506
+ const linePid = parseInt(parts[1], 10);
507
+ if (isNaN(linePid))
508
+ return;
509
+ if (!result.tasksDirs.has(linePid)) {
510
+ const taskMatch = tasksRe.exec(line);
511
+ if (taskMatch)
512
+ result.tasksDirs.set(linePid, taskMatch[1]);
513
+ }
514
+ if (!result.sessionIds.has(linePid)) {
515
+ const sessionMatch = sessionRe.exec(line);
516
+ if (sessionMatch)
517
+ result.sessionIds.set(linePid, sessionMatch[1]);
518
+ }
519
+ };
520
+ try {
521
+ const pidArgs = pids.join(',');
522
+ const { stdout } = await execFileAsync('lsof', ['-a', '-p', pidArgs], {
523
+ maxBuffer: 2 * 1024 * 1024,
524
+ });
525
+ for (const line of stdout.split('\n')) {
526
+ parseLine(line);
527
+ }
528
+ }
529
+ catch {
530
+ // Fallback: try individual PIDs
531
+ for (const pid of pids) {
532
+ if (result.tasksDirs.has(pid) && result.sessionIds.has(pid))
533
+ continue;
534
+ try {
535
+ const { stdout } = await execFileAsync('lsof', ['-a', '-p', String(pid)], {
536
+ maxBuffer: 512 * 1024,
537
+ });
538
+ for (const line of stdout.split('\n')) {
539
+ parseLine(line);
540
+ }
541
+ }
542
+ catch {
543
+ // Skip this PID
544
+ }
545
+ }
546
+ }
547
+ return result;
548
+ }
549
+ /** Persistent path for the iTerm2 query script */
550
+ const ITERM_SCRIPT_PATH = path.join(os.tmpdir(), 'conductor-iterm-query.applescript');
551
+ /** Cached iTerm sessions (refreshed on successful query, used as fallback on failure) */
552
+ let cachedItermSessions = [];
553
+ /**
554
+ * Get all iTerm2 sessions via AppleScript.
555
+ * Writes the script to a temp file and runs osascript on it.
556
+ * Caches the result so flaky failures still return the last known state.
557
+ */
558
+ async function getItermSessions() {
559
+ // Ensure the script file exists
560
+ if (!fs.existsSync(ITERM_SCRIPT_PATH)) {
561
+ fs.writeFileSync(ITERM_SCRIPT_PATH, [
562
+ 'tell application "iTerm2"',
563
+ ' set tb to character id 9',
564
+ ' set lf to character id 10',
565
+ ' set output to ""',
566
+ ' repeat with w in windows',
567
+ ' repeat with t in tabs of w',
568
+ ' repeat with s in sessions of t',
569
+ ' set output to output & (tty of s) & tb & (unique ID of s) & tb & (name of s) & lf',
570
+ ' end repeat',
571
+ ' end repeat',
572
+ ' end repeat',
573
+ ' return output',
574
+ 'end tell',
575
+ ].join('\n'));
576
+ }
577
+ try {
578
+ // Try execFile first (direct, no shell), fall back to exec through shell
579
+ let stdout;
580
+ try {
581
+ const result = await execFileAsync('osascript', [ITERM_SCRIPT_PATH], { timeout: 10000 });
582
+ stdout = result.stdout;
583
+ }
584
+ catch {
585
+ const result = await execAsync(`osascript '${ITERM_SCRIPT_PATH}'`, { timeout: 10000 });
586
+ stdout = result.stdout;
587
+ }
588
+ const sessions = [];
589
+ for (const line of stdout.trim().split(/[\r\n]+/)) {
590
+ if (!line.trim())
591
+ continue;
592
+ const parts = line.split('\t');
593
+ if (parts.length >= 2) {
594
+ sessions.push({
595
+ tty: parts[0].trim(),
596
+ uniqueId: parts[1].trim(),
597
+ name: (parts.slice(2).join('\t') || '').trim(),
598
+ });
599
+ }
600
+ }
601
+ cachedItermSessions = sessions;
602
+ return sessions;
603
+ }
604
+ catch {
605
+ // Return cached sessions on failure (flaky AppleScript)
606
+ if (cachedItermSessions.length > 0) {
607
+ console.log(`[discovery] getItermSessions failed, using ${cachedItermSessions.length} cached sessions`);
608
+ }
609
+ return cachedItermSessions;
610
+ }
611
+ }
612
+ /**
613
+ * Get TTY for each PID via ps.
614
+ */
615
+ async function getProcessTtys(pids) {
616
+ const map = new Map();
617
+ try {
618
+ const { stdout } = await execFileAsync('ps', ['-o', 'pid=,tty=', '-p', pids.join(',')]);
619
+ for (const line of stdout.trim().split('\n')) {
620
+ if (!line.trim())
621
+ continue;
622
+ const parts = line.trim().split(/\s+/);
623
+ const pid = parseInt(parts[0], 10);
624
+ const tty = parts[1];
625
+ if (!isNaN(pid) && tty && tty !== '??') {
626
+ map.set(pid, tty);
627
+ }
628
+ }
629
+ }
630
+ catch {
631
+ for (const pid of pids) {
632
+ try {
633
+ const { stdout } = await execFileAsync('ps', ['-o', 'tty=', '-p', String(pid)]);
634
+ const tty = stdout.trim();
635
+ if (tty && tty !== '??') {
636
+ map.set(pid, tty);
637
+ }
638
+ }
639
+ catch {
640
+ // Skip
641
+ }
642
+ }
643
+ }
644
+ return map;
645
+ }
646
+ /**
647
+ * Parse TTY number from strings like "ttys013" or "/dev/ttys013".
648
+ */
649
+ function parseTtyNumber(tty) {
650
+ const m = tty.match(/ttys(\d+)$/);
651
+ return m ? parseInt(m[1], 10) : null;
652
+ }
653
+ /**
654
+ * Match a claude process TTY to an iTerm2 session.
655
+ * Tries exact match first, then off-by-1 (figterm allocates child PTY).
656
+ */
657
+ function matchTtyToIterm(claudeTty, itermSessions) {
658
+ const claudeNum = parseTtyNumber(claudeTty);
659
+ if (claudeNum === null)
660
+ return null;
661
+ // Exact match (no figterm)
662
+ for (const session of itermSessions) {
663
+ const itermNum = parseTtyNumber(session.tty);
664
+ if (itermNum !== null && claudeNum === itermNum) {
665
+ return session;
666
+ }
667
+ }
668
+ // Off-by-1 (figterm child PTY)
669
+ for (const session of itermSessions) {
670
+ const itermNum = parseTtyNumber(session.tty);
671
+ if (itermNum !== null && claudeNum === itermNum + 1) {
672
+ return session;
673
+ }
674
+ }
675
+ return null;
676
+ }
677
+ /**
678
+ * Detect which terminal application is an ancestor of each PID.
679
+ * Builds the full process tree once and walks up from each PID.
680
+ */
681
+ async function detectTerminalForPids(pids) {
682
+ if (pids.length === 0)
683
+ return new Map();
684
+ try {
685
+ const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,comm=']);
686
+ const procs = new Map();
687
+ for (const line of stdout.trim().split('\n')) {
688
+ const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
689
+ if (match) {
690
+ procs.set(parseInt(match[1]), { ppid: parseInt(match[2]), comm: match[3].trim() });
691
+ }
692
+ }
693
+ const result = new Map();
694
+ for (const pid of pids) {
695
+ let current = pid;
696
+ const visited = new Set();
697
+ let detected = 'unknown';
698
+ while (current > 1 && !visited.has(current)) {
699
+ visited.add(current);
700
+ const proc = procs.get(current);
701
+ if (!proc)
702
+ break;
703
+ if (/[Ww]arp/.test(proc.comm)) {
704
+ detected = 'warp';
705
+ break;
706
+ }
707
+ if (/^Terminal$/.test(proc.comm)) {
708
+ detected = 'terminal';
709
+ break;
710
+ }
711
+ current = proc.ppid;
712
+ }
713
+ result.set(pid, detected);
714
+ }
715
+ return result;
716
+ }
717
+ catch {
718
+ return new Map();
719
+ }
720
+ }
721
+ /**
722
+ * Get the start times (in epoch ms) for given PIDs using `ps -o lstart=`.
723
+ */
724
+ async function getPidStartTimes(pids) {
725
+ const result = new Map();
726
+ for (const pid of pids) {
727
+ try {
728
+ const { stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)]);
729
+ const startTime = new Date(stdout.trim()).getTime();
730
+ if (!isNaN(startTime)) {
731
+ result.set(pid, startTime);
732
+ }
733
+ }
734
+ catch {
735
+ // Process may have exited
736
+ }
737
+ }
738
+ return result;
739
+ }
740
+ /**
741
+ * Capture user prompts from tmux pane scrollback for content-based matching.
742
+ * Extracts lines starting with ❯ (the Claude Code input prompt marker).
743
+ */
744
+ async function capturePanePrompts(paneIds, out) {
745
+ for (const paneId of paneIds) {
746
+ try {
747
+ const { stdout } = await execFileAsync('tmux', [
748
+ 'capture-pane', '-t', paneId, '-p', '-S', '-200',
749
+ ], { maxBuffer: 512 * 1024 });
750
+ const prompts = [];
751
+ for (const line of stdout.split('\n')) {
752
+ // Match lines starting with ❯ (the input prompt marker)
753
+ const match = /^❯\s*(.+)/.exec(line);
754
+ if (match) {
755
+ const text = match[1].trim();
756
+ // Skip very short/generic prompts that won't uniquely identify a session
757
+ if (text.length > 3) {
758
+ prompts.push(text);
759
+ }
760
+ }
761
+ }
762
+ if (prompts.length > 0) {
763
+ out.set(paneId, prompts);
764
+ }
765
+ else {
766
+ // Count total lines and ❯ occurrences for debugging
767
+ const totalLines = stdout.split('\n').length;
768
+ const rawMarkers = stdout.split('\n').filter((l) => l.includes('❯')).length;
769
+ console.log(`[discovery] Pane ${paneId}: 0 prompts extracted (${totalLines} lines, ${rawMarkers} ❯ markers)`);
770
+ }
771
+ }
772
+ catch (err) {
773
+ console.log(`[discovery] Pane ${paneId}: capture error: ${err instanceof Error ? err.message : 'unknown'}`);
774
+ }
775
+ }
776
+ }