@idl3/claude-control 0.1.2 → 0.1.3
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/lib/match.js +136 -0
- package/lib/pins.js +61 -0
- package/lib/prompt.js +73 -0
- package/lib/sessions.js +248 -105
- package/lib/subagents.js +154 -0
- package/lib/tmux.js +39 -18
- package/lib/uploads.js +20 -0
- package/package.json +1 -1
- package/server.js +193 -7
- package/web/dist/assets/{core-BYJcZW10.js → core-BYoRNKN7.js} +1 -1
- package/web/dist/assets/index-BxcH-YdA.css +1 -0
- package/web/dist/assets/index-CmkTUTz_.js +77 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bb7gXgl-.css +0 -1
- package/web/dist/assets/index-wrjqfzbL.js +0 -77
package/lib/sessions.js
CHANGED
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
import { EventEmitter } from 'node:events';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
13
15
|
|
|
14
16
|
import { parseTuiStatus, prettyModel } from './tui.js';
|
|
17
|
+
import { assignTranscripts, parseEtime } from './match.js';
|
|
18
|
+
import { pinKey } from './pins.js';
|
|
19
|
+
|
|
20
|
+
const execFile = promisify(_execFile);
|
|
21
|
+
|
|
22
|
+
// Matches Claude Code's executable basename (e.g. /Users/x/.local/bin/claude).
|
|
23
|
+
const CLAUDE_COMM_RE = /(^|\/)claude$/;
|
|
15
24
|
|
|
16
25
|
// A pane is a Claude Code session when its process title is the Claude version
|
|
17
26
|
// (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
|
|
@@ -151,11 +160,12 @@ async function readTail(filePath, maxBytes) {
|
|
|
151
160
|
* Parse the tail buffer of a JSONL file and return the last record that has a
|
|
152
161
|
* truthy `.cwd` field, plus basic metadata.
|
|
153
162
|
*
|
|
154
|
-
* @param {string} filePath
|
|
155
|
-
* @param {number} mtime
|
|
156
|
-
* @
|
|
163
|
+
* @param {string} filePath Absolute path of the .jsonl file
|
|
164
|
+
* @param {number} mtime mtime (ms since epoch) of the file
|
|
165
|
+
* @param {number} [birthtime] birthtime (ms since epoch) of the file
|
|
166
|
+
* @returns {Promise<object|null>}
|
|
157
167
|
*/
|
|
158
|
-
async function extractTailRecord(filePath, mtime) {
|
|
168
|
+
async function extractTailRecord(filePath, mtime, birthtime = null) {
|
|
159
169
|
const buf = await readTail(filePath, TAIL_BYTES);
|
|
160
170
|
if (!buf) return null;
|
|
161
171
|
|
|
@@ -169,11 +179,13 @@ async function extractTailRecord(filePath, mtime) {
|
|
|
169
179
|
cwd: null,
|
|
170
180
|
sessionId: null,
|
|
171
181
|
lastActivity: null,
|
|
182
|
+
lastActivityMs: null,
|
|
172
183
|
model: null,
|
|
173
184
|
aiTitle: null,
|
|
174
185
|
customTitle: null,
|
|
175
186
|
transcriptPath: filePath,
|
|
176
187
|
mtime,
|
|
188
|
+
birthtimeMs: birthtime,
|
|
177
189
|
transcriptPending: false,
|
|
178
190
|
pendingToolUseId: null,
|
|
179
191
|
pendingQuestion: null,
|
|
@@ -196,7 +208,11 @@ async function extractTailRecord(filePath, mtime) {
|
|
|
196
208
|
let rec;
|
|
197
209
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
198
210
|
if (!rec || typeof rec !== 'object') continue;
|
|
199
|
-
if (base.lastActivity === null && typeof rec.timestamp === 'string')
|
|
211
|
+
if (base.lastActivity === null && typeof rec.timestamp === 'string') {
|
|
212
|
+
base.lastActivity = rec.timestamp;
|
|
213
|
+
const t = Date.parse(rec.timestamp);
|
|
214
|
+
base.lastActivityMs = Number.isNaN(t) ? null : t;
|
|
215
|
+
}
|
|
200
216
|
if (base.sessionId === null && typeof rec.sessionId === 'string') base.sessionId = rec.sessionId;
|
|
201
217
|
if (base.customTitle === null && rec.type === 'custom-title' && rec.customTitle) base.customTitle = rec.customTitle;
|
|
202
218
|
if (base.aiTitle === null && rec.type === 'ai-title' && rec.aiTitle) base.aiTitle = rec.aiTitle;
|
|
@@ -210,40 +226,83 @@ async function extractTailRecord(filePath, mtime) {
|
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
// ---------------------------------------------------------------------------
|
|
213
|
-
//
|
|
229
|
+
// findRecentJsonl — newest-mtime *.jsonl files in a dir (top K)
|
|
214
230
|
// ---------------------------------------------------------------------------
|
|
215
231
|
|
|
216
232
|
/**
|
|
217
|
-
* Given a directory,
|
|
233
|
+
* Given a directory, return its `k` *.jsonl files with the newest mtime, each
|
|
234
|
+
* with `birthtimeMs`. We need more than one because multiple Claude sessions can
|
|
235
|
+
* share a directory — each needs its own transcript candidate.
|
|
218
236
|
*
|
|
219
237
|
* @param {string} dir
|
|
220
|
-
* @
|
|
238
|
+
* @param {number} k
|
|
239
|
+
* @returns {Promise<Array<{filePath:string, mtime:number, birthtimeMs:number}>>}
|
|
221
240
|
*/
|
|
222
|
-
async function
|
|
241
|
+
async function findRecentJsonl(dir, k) {
|
|
223
242
|
let entries;
|
|
224
243
|
try {
|
|
225
244
|
entries = await fs.readdir(dir);
|
|
226
245
|
} catch {
|
|
227
|
-
return
|
|
246
|
+
return [];
|
|
228
247
|
}
|
|
229
248
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await Promise.all(
|
|
249
|
+
const stats = await Promise.all(
|
|
233
250
|
entries
|
|
234
251
|
.filter((e) => e.endsWith('.jsonl'))
|
|
235
252
|
.map(async (e) => {
|
|
236
253
|
const full = path.join(dir, e);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
254
|
+
try {
|
|
255
|
+
const st = await fs.stat(full);
|
|
256
|
+
return { filePath: full, mtime: st.mtimeMs, birthtimeMs: st.birthtimeMs };
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
242
259
|
}
|
|
243
260
|
}),
|
|
244
261
|
);
|
|
245
262
|
|
|
246
|
-
return
|
|
263
|
+
return stats
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
266
|
+
.slice(0, Math.max(1, k));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* List recent transcripts across all project dirs for the manual-pin picker.
|
|
271
|
+
* Takes the newest .jsonl per project dir (bounded), tail-parses each for a
|
|
272
|
+
* title/cwd/sessionId, and returns the most recently active `limit`.
|
|
273
|
+
*
|
|
274
|
+
* @param {{ projectsRoot: string, limit?: number }} opts
|
|
275
|
+
* @returns {Promise<Array<{transcriptPath,title,sessionId,cwd,lastActivity,mtime}>>}
|
|
276
|
+
*/
|
|
277
|
+
export async function listRecentTranscripts({ projectsRoot, limit = 60 }) {
|
|
278
|
+
let dirs;
|
|
279
|
+
try {
|
|
280
|
+
const entries = await fs.readdir(projectsRoot, { withFileTypes: true });
|
|
281
|
+
dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(projectsRoot, e.name));
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const out = [];
|
|
287
|
+
await Promise.all(
|
|
288
|
+
dirs.map(async (dir) => {
|
|
289
|
+
const recent = await findRecentJsonl(dir, 1);
|
|
290
|
+
if (!recent.length) return;
|
|
291
|
+
const r = recent[0];
|
|
292
|
+
const rec = await extractTailRecord(r.filePath, r.mtime, r.birthtimeMs);
|
|
293
|
+
if (!rec) return;
|
|
294
|
+
out.push({
|
|
295
|
+
transcriptPath: rec.transcriptPath,
|
|
296
|
+
title: rec.customTitle || rec.aiTitle || null,
|
|
297
|
+
sessionId: rec.sessionId,
|
|
298
|
+
cwd: rec.cwd,
|
|
299
|
+
lastActivity: rec.lastActivity,
|
|
300
|
+
mtime: r.mtime,
|
|
301
|
+
});
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return out.sort((a, b) => b.mtime - a.mtime).slice(0, limit);
|
|
247
306
|
}
|
|
248
307
|
|
|
249
308
|
// ---------------------------------------------------------------------------
|
|
@@ -260,13 +319,15 @@ export class SessionRegistry extends EventEmitter {
|
|
|
260
319
|
this._tmux = tmux;
|
|
261
320
|
this._debounceMs = debounceMs;
|
|
262
321
|
|
|
322
|
+
/** @type {Record<string,string>} pin key (windowId.paneIndex) -> transcript path */
|
|
323
|
+
this._pins = {};
|
|
263
324
|
/** @type {Session[]} */
|
|
264
325
|
this._sessions = [];
|
|
265
326
|
/** @type {string|null} — last JSON snapshot for change detection */
|
|
266
327
|
this._lastEmitted = null;
|
|
267
328
|
/** @type {Map<string, boolean>} id -> pending flag */
|
|
268
329
|
this._pendingMap = new Map();
|
|
269
|
-
/** @type {Map<string, {ctxPct:number|null, model:string|null}>}
|
|
330
|
+
/** @type {Map<string, {ctxPct:number|null, model:string|null}>} target -> TUI status */
|
|
270
331
|
this._ctxMap = new Map();
|
|
271
332
|
/** @type {ReturnType<setInterval>|null} */
|
|
272
333
|
this._interval = null;
|
|
@@ -283,6 +344,16 @@ export class SessionRegistry extends EventEmitter {
|
|
|
283
344
|
return this._sessions;
|
|
284
345
|
}
|
|
285
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Replace the manual pin map (windowId.paneIndex -> transcript path) and
|
|
349
|
+
* re-reconcile so the change shows immediately.
|
|
350
|
+
* @param {Record<string,string>} pins
|
|
351
|
+
*/
|
|
352
|
+
setPins(pins) {
|
|
353
|
+
this._pins = pins || {};
|
|
354
|
+
this.refresh().catch(() => {});
|
|
355
|
+
}
|
|
356
|
+
|
|
286
357
|
/**
|
|
287
358
|
* Set the pending flag for a session (called by server when tailer fires
|
|
288
359
|
* 'pending'). Emits 'change' if the flag actually flipped.
|
|
@@ -307,41 +378,69 @@ export class SessionRegistry extends EventEmitter {
|
|
|
307
378
|
* @returns {Promise<Session[]>}
|
|
308
379
|
*/
|
|
309
380
|
async refresh() {
|
|
310
|
-
const
|
|
311
|
-
this._listWindows(),
|
|
312
|
-
this._buildTranscriptIndex(),
|
|
313
|
-
]);
|
|
381
|
+
const allPanes = await this._listWindows();
|
|
314
382
|
|
|
315
383
|
// Grouped tmux sessions (e.g. a `_mobile` mirror of session `0`) expose the
|
|
316
|
-
// SAME underlying
|
|
317
|
-
// Collapse those so the UI shows each real
|
|
318
|
-
// which is the primary session by tmux
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
384
|
+
// SAME underlying pane under multiple session names — identical
|
|
385
|
+
// (window_id, pane_index). Collapse those so the UI shows each real pane
|
|
386
|
+
// once (keeping the first, which is the primary session by tmux ordering).
|
|
387
|
+
const seenPanes = new Set();
|
|
388
|
+
const panes = allPanes.filter((p) => {
|
|
389
|
+
const key = `${p.windowId}.${p.paneIndex}`;
|
|
390
|
+
if (seenPanes.has(key)) return false;
|
|
391
|
+
seenPanes.add(key);
|
|
323
392
|
return true;
|
|
324
393
|
});
|
|
325
394
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
395
|
+
// Only Claude panes have transcripts to match (shells don't).
|
|
396
|
+
const claudePanes = panes.filter((p) => isClaudeCmd(p.cmd));
|
|
397
|
+
|
|
398
|
+
// Manual pins win first: a pinned pane is force-bound to its transcript and
|
|
399
|
+
// that transcript is removed from the auto-matcher pool. Pins are keyed by
|
|
400
|
+
// the stable windowId.paneIndex (the target renumbers; the window id doesn't).
|
|
401
|
+
const pinnedByTarget = new Map();
|
|
402
|
+
const pinnedPaths = new Set();
|
|
403
|
+
for (const p of claudePanes) {
|
|
404
|
+
const pinned = this._pins[pinKey(p.windowId, p.paneIndex)];
|
|
405
|
+
if (!pinned) continue;
|
|
406
|
+
const rec = await this._recordForPath(pinned);
|
|
407
|
+
if (rec) {
|
|
408
|
+
pinnedByTarget.set(p.target, rec);
|
|
409
|
+
pinnedPaths.add(rec.transcriptPath);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Auto-match the rest with the deterministic 1:1 matcher (pinned panes and
|
|
414
|
+
// pinned transcripts excluded so nothing double-binds or gets stolen).
|
|
415
|
+
const autoPanes = claudePanes.filter((p) => !pinnedByTarget.has(p.target));
|
|
416
|
+
const [candidatesRaw, procStart] = await Promise.all([
|
|
417
|
+
this._buildCandidates(autoPanes),
|
|
418
|
+
this._buildProcStart(autoPanes),
|
|
419
|
+
]);
|
|
420
|
+
const candidates = candidatesRaw.filter((c) => !pinnedPaths.has(c.transcriptPath));
|
|
421
|
+
const assignment = assignTranscripts(
|
|
422
|
+
autoPanes.map((p) => ({
|
|
423
|
+
target: p.target,
|
|
424
|
+
windowName: p.windowName,
|
|
425
|
+
cwd: p.cwd,
|
|
426
|
+
procStartMs: procStart.get(p.target) ?? null,
|
|
427
|
+
})),
|
|
428
|
+
candidates,
|
|
429
|
+
);
|
|
430
|
+
for (const [target, rec] of pinnedByTarget) assignment.set(target, rec);
|
|
431
|
+
|
|
432
|
+
const sessions = panes.map((win) => {
|
|
433
|
+
const transcript = isClaudeCmd(win.cmd)
|
|
434
|
+
? assignment.get(win.target) ?? null
|
|
435
|
+
: null;
|
|
436
|
+
const isPinned = pinnedByTarget.has(win.target);
|
|
338
437
|
const id = win.target;
|
|
339
438
|
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
340
439
|
// pending (works for ANY session, even unsubscribed ones, for push).
|
|
341
440
|
const pending =
|
|
342
441
|
(this._pendingMap.get(id) ?? false) || !!transcript?.transcriptPending;
|
|
343
442
|
const title = transcript?.customTitle || transcript?.aiTitle || null;
|
|
344
|
-
const ctx = this._ctxMap.get(win.
|
|
443
|
+
const ctx = this._ctxMap.get(win.target) || {};
|
|
345
444
|
|
|
346
445
|
return {
|
|
347
446
|
id,
|
|
@@ -358,6 +457,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
358
457
|
active: win.active,
|
|
359
458
|
cwd: win.cwd,
|
|
360
459
|
transcriptPath: transcript?.transcriptPath ?? null,
|
|
460
|
+
pinned: isPinned,
|
|
361
461
|
lastActivity: transcript?.lastActivity ?? null,
|
|
362
462
|
pending,
|
|
363
463
|
pendingQuestion: transcript?.pendingQuestion ?? null,
|
|
@@ -368,33 +468,8 @@ export class SessionRegistry extends EventEmitter {
|
|
|
368
468
|
};
|
|
369
469
|
});
|
|
370
470
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
// same title twice and two sessions tail one file. Keep the transcript on
|
|
374
|
-
// the best (active, else first-seen) session; strip it from the others so
|
|
375
|
-
// they stay distinct, transcript-less live panes (still subscribable).
|
|
376
|
-
const seenTranscript = new Set();
|
|
377
|
-
const byPriority = [...sessions].sort((a, b) =>
|
|
378
|
-
a.active === b.active ? 0 : a.active ? -1 : 1,
|
|
379
|
-
);
|
|
380
|
-
for (const s of byPriority) {
|
|
381
|
-
if (!s.transcriptPath) continue;
|
|
382
|
-
if (seenTranscript.has(s.transcriptPath)) {
|
|
383
|
-
s.transcriptPath = null;
|
|
384
|
-
s.sessionId = null;
|
|
385
|
-
s.lastActivity = null;
|
|
386
|
-
s.title = null;
|
|
387
|
-
s.name = s.tmuxName || s.target;
|
|
388
|
-
// This session no longer owns the transcript, so its transcript-derived
|
|
389
|
-
// pending is bogus; drop it (the owning session keeps the real one).
|
|
390
|
-
s.pending = this._pendingMap.get(s.id) ?? false;
|
|
391
|
-
s.pendingQuestion = null;
|
|
392
|
-
} else {
|
|
393
|
-
seenTranscript.add(s.transcriptPath);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Only surface Claude sessions; skip plain shell panes.
|
|
471
|
+
// Only surface Claude sessions; skip plain shell panes. (assignTranscripts
|
|
472
|
+
// already guarantees 1:1, so no post-hoc collision dedup is needed.)
|
|
398
473
|
this._sessions = sessions.filter((s) => isClaudeCmd(s.cmd) || s.transcriptPath);
|
|
399
474
|
this._maybeEmit();
|
|
400
475
|
return this._sessions;
|
|
@@ -413,7 +488,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
413
488
|
try {
|
|
414
489
|
const cap = await this._tmux.capturePane(s.target, 8);
|
|
415
490
|
const { ctxPct, model } = parseTuiStatus(cap);
|
|
416
|
-
this._ctxMap.set(s.
|
|
491
|
+
this._ctxMap.set(s.target, { ctxPct, model });
|
|
417
492
|
// Merge into the live session object without a full rebuild.
|
|
418
493
|
if (ctxPct !== null) s.ctxPct = ctxPct;
|
|
419
494
|
if (model) s.model = model;
|
|
@@ -468,51 +543,119 @@ export class SessionRegistry extends EventEmitter {
|
|
|
468
543
|
}
|
|
469
544
|
|
|
470
545
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
546
|
+
* Build the transcript candidate pool for the given Claude panes. A session's
|
|
547
|
+
* transcript lives in the project dir `encodeCwd(launchCwd)`, where launchCwd
|
|
548
|
+
* is the pane's current path. For each such dir we read the newest few
|
|
549
|
+
* transcripts (enough for every pane sharing that dir) so the matcher has a
|
|
550
|
+
* real choice when sessions collide on a directory. Tail-only reads (≤64 KB).
|
|
474
551
|
*
|
|
475
|
-
* @
|
|
552
|
+
* @param {import('./tmux.js').Window[]} claudePanes
|
|
553
|
+
* @returns {Promise<object[]>} candidate records (see extractTailRecord)
|
|
476
554
|
*/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Build a transcript record for an exact path (for manual pins). Stats the
|
|
557
|
+
* file for mtime/birthtime, then tail-parses it. Null if unreadable.
|
|
558
|
+
* @param {string} filePath
|
|
559
|
+
* @returns {Promise<object|null>}
|
|
560
|
+
*/
|
|
561
|
+
async _recordForPath(filePath) {
|
|
482
562
|
try {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
.filter((e) => e.isDirectory())
|
|
486
|
-
.map((e) => ({ name: e.name, dir: path.join(this._projectsRoot, e.name) }));
|
|
563
|
+
const st = await fs.stat(filePath);
|
|
564
|
+
return await extractTailRecord(filePath, st.mtimeMs, st.birthtimeMs);
|
|
487
565
|
} catch {
|
|
488
|
-
return
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async _buildCandidates(claudePanes) {
|
|
571
|
+
// dir name -> how many panes launched there (so we fetch enough candidates).
|
|
572
|
+
const dirCounts = new Map();
|
|
573
|
+
for (const p of claudePanes) {
|
|
574
|
+
const dir = encodeCwd(p.cwd);
|
|
575
|
+
dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
|
|
489
576
|
}
|
|
490
577
|
|
|
578
|
+
const candidates = [];
|
|
491
579
|
await Promise.all(
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
580
|
+
[...dirCounts.entries()].map(async ([name, count]) => {
|
|
581
|
+
const dir = path.join(this._projectsRoot, name);
|
|
582
|
+
// A small buffer beyond the pane count tolerates resume/compaction
|
|
583
|
+
// spawning a fresh file mid-session.
|
|
584
|
+
const recent = await findRecentJsonl(dir, count + 2);
|
|
585
|
+
const recs = await Promise.all(
|
|
586
|
+
recent.map((r) =>
|
|
587
|
+
extractTailRecord(r.filePath, r.mtime, r.birthtimeMs),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
for (const rec of recs) if (rec) candidates.push(rec);
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
495
593
|
|
|
496
|
-
|
|
497
|
-
|
|
594
|
+
return candidates;
|
|
595
|
+
}
|
|
498
596
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
597
|
+
/**
|
|
598
|
+
* Resolve each Claude pane's claude-process start time (ms epoch) for the
|
|
599
|
+
* start-time matching pass. One `ps` snapshot, then walk the process tree from
|
|
600
|
+
* each pane's shell pid to its `claude` descendant. Best-effort: panes whose
|
|
601
|
+
* proc can't be found map to null and fall through to other match passes.
|
|
602
|
+
*
|
|
603
|
+
* @param {import('./tmux.js').Window[]} claudePanes
|
|
604
|
+
* @returns {Promise<Map<string, number|null>>} target -> startMs
|
|
605
|
+
*/
|
|
606
|
+
async _buildProcStart(claudePanes) {
|
|
607
|
+
const out = new Map();
|
|
608
|
+
if (claudePanes.length === 0) return out;
|
|
609
|
+
|
|
610
|
+
let rows;
|
|
611
|
+
try {
|
|
612
|
+
const { stdout } = await execFile(
|
|
613
|
+
'ps',
|
|
614
|
+
['-axo', 'pid=,ppid=,etime=,comm='],
|
|
615
|
+
{ timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
|
|
616
|
+
);
|
|
617
|
+
rows = stdout.split('\n');
|
|
618
|
+
} catch {
|
|
619
|
+
return out; // ps unavailable — every pane falls back to null
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** @type {Map<number, number[]>} ppid -> child pids */
|
|
623
|
+
const children = new Map();
|
|
624
|
+
/** @type {Map<number, {etime:string, comm:string}>} */
|
|
625
|
+
const info = new Map();
|
|
626
|
+
for (const line of rows) {
|
|
627
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
628
|
+
if (!m) continue;
|
|
629
|
+
const pid = Number(m[1]);
|
|
630
|
+
const ppid = Number(m[2]);
|
|
631
|
+
info.set(pid, { etime: m[3], comm: m[4] });
|
|
632
|
+
if (!children.has(ppid)) children.set(ppid, []);
|
|
633
|
+
children.get(ppid).push(pid);
|
|
634
|
+
}
|
|
504
635
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
636
|
+
const now = Date.now();
|
|
637
|
+
const findClaudeStart = (rootPid) => {
|
|
638
|
+
// BFS for a descendant whose command basename is `claude`.
|
|
639
|
+
const queue = [rootPid];
|
|
640
|
+
const seen = new Set();
|
|
641
|
+
while (queue.length) {
|
|
642
|
+
const pid = queue.shift();
|
|
643
|
+
if (seen.has(pid)) continue;
|
|
644
|
+
seen.add(pid);
|
|
645
|
+
const meta = info.get(pid);
|
|
646
|
+
if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
|
|
647
|
+
const sec = parseEtime(meta.etime);
|
|
648
|
+
return sec == null ? null : now - sec * 1000;
|
|
511
649
|
}
|
|
512
|
-
|
|
513
|
-
|
|
650
|
+
for (const c of children.get(pid) ?? []) queue.push(c);
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
};
|
|
514
654
|
|
|
515
|
-
|
|
655
|
+
for (const p of claudePanes) {
|
|
656
|
+
out.set(p.target, p.panePid ? findClaudeStart(p.panePid) : null);
|
|
657
|
+
}
|
|
658
|
+
return out;
|
|
516
659
|
}
|
|
517
660
|
|
|
518
661
|
/**
|
package/lib/subagents.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/subagents.js — watch a session's sub-agent (Task/Agent) transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code writes each sub-agent's conversation to a sibling of the parent
|
|
5
|
+
* transcript:
|
|
6
|
+
* <project>/<sessionId>.jsonl ← parent
|
|
7
|
+
* <project>/<sessionId>/subagents/agent-<id>.jsonl ← sub-agent transcript
|
|
8
|
+
* <project>/<sessionId>/subagents/agent-<id>.meta.json
|
|
9
|
+
* { agentType, description, toolUseId } ← links to the parent's
|
|
10
|
+
* Task tool-call
|
|
11
|
+
*
|
|
12
|
+
* This watcher discovers those files (lazily — polled when the parent transcript
|
|
13
|
+
* grows, which is exactly when sub-agents spawn), tails each one with the same
|
|
14
|
+
* bounded TranscriptTailer the main transcript uses, and emits a 'change' event
|
|
15
|
+
* carrying the full sub-agent entry whenever it appears or grows. The server
|
|
16
|
+
* relays each entry to subscribed clients as a `subagent` frame.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { EventEmitter } from 'node:events';
|
|
22
|
+
|
|
23
|
+
import { TranscriptTailer } from './transcript.js';
|
|
24
|
+
|
|
25
|
+
const META_RE = /^agent-(.+)\.meta\.json$/;
|
|
26
|
+
// A sub-agent whose transcript hasn't grown in this long is treated as finished,
|
|
27
|
+
// even if we never saw the parent's tool_result (e.g. it predates the parent's
|
|
28
|
+
// bounded message buffer). Live sub-agents append continuously.
|
|
29
|
+
const RUNNING_WINDOW_MS = 45_000;
|
|
30
|
+
|
|
31
|
+
export class SubAgentsWatcher extends EventEmitter {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} transcriptPath absolute path to the PARENT transcript
|
|
34
|
+
* @param {{ maxBuffer?: number }} [opts]
|
|
35
|
+
*/
|
|
36
|
+
constructor(transcriptPath, { maxBuffer = 200 } = {}) {
|
|
37
|
+
super();
|
|
38
|
+
// <project>/<sessionId>.jsonl → <project>/<sessionId>/subagents
|
|
39
|
+
this._dir = path.join(transcriptPath.replace(/\.jsonl$/, ''), 'subagents');
|
|
40
|
+
this._maxBuffer = maxBuffer;
|
|
41
|
+
/** @type {Map<string, {agentId, toolUseId, agentType, description, status, tailer}>} */
|
|
42
|
+
this._agents = new Map(); // keyed by agentId
|
|
43
|
+
this._stopped = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Current sub-agents (snapshot), each with its buffered messages. */
|
|
47
|
+
snapshot() {
|
|
48
|
+
return [...this._agents.values()].map((a) => this._entry(a));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rescan the subagents dir for new agent files. Cheap; safe to call often.
|
|
53
|
+
* Call on each parent-transcript append (when sub-agents are spawned) and once
|
|
54
|
+
* at subscribe time.
|
|
55
|
+
*/
|
|
56
|
+
poll() {
|
|
57
|
+
if (this._stopped) return;
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(this._dir);
|
|
61
|
+
} catch {
|
|
62
|
+
return; // dir doesn't exist yet (no sub-agents) — nothing to do
|
|
63
|
+
}
|
|
64
|
+
for (const name of entries) {
|
|
65
|
+
const m = META_RE.exec(name);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
const agentId = m[1];
|
|
68
|
+
if (this._agents.has(agentId)) continue;
|
|
69
|
+
this._track(agentId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mark a sub-agent finished (the parent transcript produced a tool_result for
|
|
75
|
+
* its toolUseId — the authoritative "done" signal). Idempotent. Accepts a Set
|
|
76
|
+
* or a single id so the server can sweep its whole buffer at subscribe time.
|
|
77
|
+
* @param {string|Set<string>} toolUseIds
|
|
78
|
+
*/
|
|
79
|
+
markDone(toolUseIds) {
|
|
80
|
+
const has = (id) =>
|
|
81
|
+
toolUseIds instanceof Set ? toolUseIds.has(id) : toolUseIds === id;
|
|
82
|
+
for (const a of this._agents.values()) {
|
|
83
|
+
if (a.toolUseId && has(a.toolUseId) && !a.doneByParent) {
|
|
84
|
+
a.doneByParent = true;
|
|
85
|
+
this.emit('change', this._entry(a));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stop() {
|
|
91
|
+
this._stopped = true;
|
|
92
|
+
for (const a of this._agents.values()) a.tailer?.stop();
|
|
93
|
+
this._agents.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -- internals --
|
|
97
|
+
|
|
98
|
+
_track(agentId) {
|
|
99
|
+
const metaPath = path.join(this._dir, `agent-${agentId}.meta.json`);
|
|
100
|
+
const jsonlPath = path.join(this._dir, `agent-${agentId}.jsonl`);
|
|
101
|
+
let meta = {};
|
|
102
|
+
try {
|
|
103
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) || {};
|
|
104
|
+
} catch {
|
|
105
|
+
return; // meta not readable yet — a later poll retries
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tailer = new TranscriptTailer(jsonlPath, { maxBuffer: this._maxBuffer });
|
|
109
|
+
const agent = {
|
|
110
|
+
agentId,
|
|
111
|
+
jsonlPath,
|
|
112
|
+
toolUseId: meta.toolUseId ?? null,
|
|
113
|
+
agentType: meta.agentType ?? null,
|
|
114
|
+
description: meta.description ?? null,
|
|
115
|
+
doneByParent: false,
|
|
116
|
+
tailer,
|
|
117
|
+
};
|
|
118
|
+
this._agents.set(agentId, agent);
|
|
119
|
+
|
|
120
|
+
tailer.on('append', () => this.emit('change', this._entry(agent)));
|
|
121
|
+
tailer.on('error', () => {}); // best-effort; a missing file just yields no messages
|
|
122
|
+
tailer
|
|
123
|
+
.start()
|
|
124
|
+
.then(() => this.emit('change', this._entry(agent)))
|
|
125
|
+
.catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Status: done if the parent confirmed it (authoritative), otherwise inferred
|
|
130
|
+
* from transcript freshness — a live sub-agent's file is actively appended; a
|
|
131
|
+
* finished one goes static. This keeps historical sub-agents correctly "done"
|
|
132
|
+
* even when their parent tool_result predates the bounded message buffer.
|
|
133
|
+
*/
|
|
134
|
+
_statusFor(a) {
|
|
135
|
+
if (a.doneByParent) return 'done';
|
|
136
|
+
try {
|
|
137
|
+
const mtimeMs = fs.statSync(a.jsonlPath).mtimeMs;
|
|
138
|
+
return Date.now() - mtimeMs < RUNNING_WINDOW_MS ? 'running' : 'done';
|
|
139
|
+
} catch {
|
|
140
|
+
return 'done';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_entry(a) {
|
|
145
|
+
return {
|
|
146
|
+
agentId: a.agentId,
|
|
147
|
+
toolUseId: a.toolUseId,
|
|
148
|
+
agentType: a.agentType,
|
|
149
|
+
description: a.description,
|
|
150
|
+
status: this._statusFor(a),
|
|
151
|
+
messages: a.tailer ? a.tailer.getMessages() : [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|