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.
- package/.claude/skills/brainstorm/SKILL.md +340 -0
- package/.claude/skills/code-review-excellence/SKILL.md +198 -0
- package/.claude/skills/directive/SKILL.md +121 -0
- package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
- package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
- package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
- package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
- package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
- package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
- package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
- package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
- package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
- package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
- package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
- package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
- package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
- package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
- package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
- package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
- package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
- package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
- package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
- package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
- package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
- package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
- package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
- package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
- package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
- package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
- package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
- package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
- package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
- package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
- package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
- package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
- package/.claude/skills/frontend-design/SKILL.md +42 -0
- package/.claude/skills/gruai-agents/SKILL.md +161 -0
- package/.claude/skills/gruai-config/SKILL.md +61 -0
- package/.claude/skills/healthcheck/SKILL.md +216 -0
- package/.claude/skills/report/SKILL.md +380 -0
- package/.claude/skills/scout/SKILL.md +452 -0
- package/.claude/skills/seo-audit/SKILL.md +107 -0
- package/.claude/skills/walkthrough/SKILL.md +274 -0
- package/.claude/skills/webapp-testing/SKILL.md +96 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/cli/templates/CLAUDE.md.template +57 -0
- package/cli/templates/agent-roles/backend.md +47 -0
- package/cli/templates/agent-roles/cmo.md +52 -0
- package/cli/templates/agent-roles/content.md +48 -0
- package/cli/templates/agent-roles/coo.md +66 -0
- package/cli/templates/agent-roles/cpo.md +52 -0
- package/cli/templates/agent-roles/cto.md +63 -0
- package/cli/templates/agent-roles/data.md +46 -0
- package/cli/templates/agent-roles/design.md +46 -0
- package/cli/templates/agent-roles/frontend.md +47 -0
- package/cli/templates/agent-roles/fullstack.md +47 -0
- package/cli/templates/agent-roles/qa.md +46 -0
- package/cli/templates/backlog.json.template +3 -0
- package/cli/templates/directive.json.template +9 -0
- package/cli/templates/directive.md.template +23 -0
- package/cli/templates/goals-index.md +21 -0
- package/cli/templates/gruai.config.json.template +12 -0
- package/cli/templates/lessons.md +16 -0
- package/cli/templates/vision.md +35 -0
- package/cli/templates/welcome-directive/directive.json +9 -0
- package/cli/templates/welcome-directive/directive.md +53 -0
- package/dist/assets/GamePage-C5XQQOQH.js +49 -0
- package/dist/assets/README.md +17 -0
- package/dist/assets/characters/char_0.png +0 -0
- package/dist/assets/characters/char_1.png +0 -0
- package/dist/assets/characters/char_10.png +0 -0
- package/dist/assets/characters/char_11.png +0 -0
- package/dist/assets/characters/char_2.png +0 -0
- package/dist/assets/characters/char_3.png +0 -0
- package/dist/assets/characters/char_4.png +0 -0
- package/dist/assets/characters/char_5.png +0 -0
- package/dist/assets/characters/char_6.png +0 -0
- package/dist/assets/characters/char_7.png +0 -0
- package/dist/assets/characters/char_8.png +0 -0
- package/dist/assets/characters/char_9.png +0 -0
- package/dist/assets/index-CnTPDqpP.js +12 -0
- package/dist/assets/index-gR5q7ikB.css +1 -0
- package/dist/assets/office/furniture.png +0 -0
- package/dist/assets/office/room-builder.png +0 -0
- package/dist/index.html +16 -0
- package/dist-server/scripts/intelligence-trends.d.ts +100 -0
- package/dist-server/scripts/intelligence-trends.js +365 -0
- package/dist-server/server/actions/cleanup.d.ts +4 -0
- package/dist-server/server/actions/cleanup.js +30 -0
- package/dist-server/server/actions/send-input.d.ts +6 -0
- package/dist-server/server/actions/send-input.js +147 -0
- package/dist-server/server/actions/terminal.d.ts +4 -0
- package/dist-server/server/actions/terminal.js +427 -0
- package/dist-server/server/config.d.ts +9 -0
- package/dist-server/server/config.js +217 -0
- package/dist-server/server/db.d.ts +7 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/hooks/event-receiver.d.ts +11 -0
- package/dist-server/server/hooks/event-receiver.js +36 -0
- package/dist-server/server/index.d.ts +1 -0
- package/dist-server/server/index.js +552 -0
- package/dist-server/server/notifications/macos.d.ts +5 -0
- package/dist-server/server/notifications/macos.js +22 -0
- package/dist-server/server/notifications/notifier.d.ts +17 -0
- package/dist-server/server/notifications/notifier.js +110 -0
- package/dist-server/server/parsers/process-discovery.d.ts +39 -0
- package/dist-server/server/parsers/process-discovery.js +776 -0
- package/dist-server/server/parsers/session-scanner.d.ts +56 -0
- package/dist-server/server/parsers/session-scanner.js +390 -0
- package/dist-server/server/parsers/session-state.d.ts +68 -0
- package/dist-server/server/parsers/session-state.js +696 -0
- package/dist-server/server/parsers/session-state.test.d.ts +1 -0
- package/dist-server/server/parsers/session-state.test.js +950 -0
- package/dist-server/server/parsers/task-parser.d.ts +10 -0
- package/dist-server/server/parsers/task-parser.js +97 -0
- package/dist-server/server/parsers/team-parser.d.ts +3 -0
- package/dist-server/server/parsers/team-parser.js +67 -0
- package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
- package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
- package/dist-server/server/platform/claude-code.d.ts +34 -0
- package/dist-server/server/platform/claude-code.js +94 -0
- package/dist-server/server/platform/index.d.ts +5 -0
- package/dist-server/server/platform/index.js +1 -0
- package/dist-server/server/platform/types.d.ts +190 -0
- package/dist-server/server/platform/types.js +9 -0
- package/dist-server/server/state/aggregator.d.ts +42 -0
- package/dist-server/server/state/aggregator.js +1080 -0
- package/dist-server/server/state/work-item-types.d.ts +555 -0
- package/dist-server/server/state/work-item-types.js +168 -0
- package/dist-server/server/types.d.ts +237 -0
- package/dist-server/server/types.js +1 -0
- package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
- package/dist-server/server/watchers/claude-watcher.js +130 -0
- package/dist-server/server/watchers/context-watcher.d.ts +22 -0
- package/dist-server/server/watchers/context-watcher.js +125 -0
- package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
- package/dist-server/server/watchers/directive-watcher.js +497 -0
- package/dist-server/server/watchers/session-watcher.d.ts +18 -0
- package/dist-server/server/watchers/session-watcher.js +126 -0
- package/dist-server/server/watchers/state-watcher.d.ts +36 -0
- package/dist-server/server/watchers/state-watcher.js +369 -0
- 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
|
+
}
|