@idl3/claude-control 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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.js +68 -0
- package/bin/install-service.sh +107 -0
- package/bin/self-update.sh +43 -0
- package/bin/uninstall-service.sh +22 -0
- package/lib/answer.js +64 -0
- package/lib/auth.js +81 -0
- package/lib/config.js +118 -0
- package/lib/push.js +153 -0
- package/lib/resources.js +137 -0
- package/lib/sessions.js +529 -0
- package/lib/terminal.js +278 -0
- package/lib/tmux.js +462 -0
- package/lib/transcript.js +451 -0
- package/lib/tui.js +50 -0
- package/lib/uploads.js +42 -0
- package/lib/version.js +73 -0
- package/package.json +49 -0
- package/public/app.js +756 -0
- package/public/index.html +120 -0
- package/public/styles.css +848 -0
- package/server.js +910 -0
- package/web/README.md +66 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/bash-I8pq0VWm.js +1 -0
- package/web/dist/assets/core-BYJcZW10.js +3 -0
- package/web/dist/assets/css-DazXZka4.js +1 -0
- package/web/dist/assets/diff-DiTmLxSS.js +1 -0
- package/web/dist/assets/index-Bb7gXgl-.css +1 -0
- package/web/dist/assets/index-wrjqfzbL.js +77 -0
- package/web/dist/assets/javascript-BKRaQes9.js +1 -0
- package/web/dist/assets/json-DIYVocXf.js +1 -0
- package/web/dist/assets/markdown-BrP960CR.js +1 -0
- package/web/dist/assets/python-sE43i1Pi.js +1 -0
- package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
- package/web/dist/assets/xml-BXBhIUeX.js +1 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +25 -0
- package/web/dist/sw.js +57 -0
package/lib/sessions.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sessions.js — SessionRegistry
|
|
3
|
+
*
|
|
4
|
+
* Periodically reconciles tmux windows with Claude transcript files found under
|
|
5
|
+
* projectsRoot. Emits 'change' when the session list changes. Never reads a
|
|
6
|
+
* transcript file in full — only the tail (≤64 KB) of the newest *.jsonl per
|
|
7
|
+
* project directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from 'node:events';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { parseTuiStatus, prettyModel } from './tui.js';
|
|
15
|
+
|
|
16
|
+
// A pane is a Claude Code session when its process title is the Claude version
|
|
17
|
+
// (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
|
|
18
|
+
function isClaudeCmd(cmd) {
|
|
19
|
+
return /^\d+\.\d+(\.\d+)?$/.test(String(cmd || '').trim());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TAIL_BYTES = 64 * 1024; // 64 KB max tail read
|
|
23
|
+
const REFRESH_INTERVAL_MS = 4000;
|
|
24
|
+
const CTX_POLL_INTERVAL_MS = 12000; // TUI ctx%/model capture — slower than refresh
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encode an absolute cwd the way Claude Code names its transcript project
|
|
28
|
+
* directories: every '/' and '.' becomes '-'. This is derived from the cwd the
|
|
29
|
+
* session was LAUNCHED in (== the tmux pane's current path), so it is immune to
|
|
30
|
+
* a mid-session `cd` that would change the cwd recorded inside the transcript.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} cwd
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function encodeCwd(cwd) {
|
|
36
|
+
return cwd.replace(/[/.]/g, '-');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is the cwd recorded inside a transcript consistent with a tmux window's cwd?
|
|
41
|
+
* True when unknown (null), equal, or a descendant directory (the session
|
|
42
|
+
* launched in winCwd and later cd'd deeper). Guards against encodeCwd collisions.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|null} recCwd cwd recorded in the transcript tail
|
|
45
|
+
* @param {string} winCwd tmux pane current path
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isCwdConsistent(recCwd, winCwd) {
|
|
49
|
+
if (!recCwd) return true;
|
|
50
|
+
return recCwd === winCwd || recCwd.startsWith(winCwd.replace(/\/$/, '') + '/');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walk a set of JSONL tail lines and decide whether an AskUserQuestion is still
|
|
57
|
+
* OPEN — i.e. an assistant `tool_use` block named "AskUserQuestion" exists whose
|
|
58
|
+
* id has NO matching `tool_result` (tool_use_id) later in the tail. Pure and
|
|
59
|
+
* unit-testable in isolation (see test/push-pending.test.js).
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} lines Complete JSONL lines (partial first line tolerated).
|
|
62
|
+
* @returns {{ transcriptPending: boolean, pendingToolUseId: string|null, pendingQuestion: string|null }}
|
|
63
|
+
*/
|
|
64
|
+
export function detectTranscriptPending(lines) {
|
|
65
|
+
/** @type {Map<string, string|null>} open AskUserQuestion id -> first question text */
|
|
66
|
+
const open = new Map();
|
|
67
|
+
const resolved = new Set();
|
|
68
|
+
|
|
69
|
+
for (const raw of lines) {
|
|
70
|
+
const line = String(raw || '').trim();
|
|
71
|
+
if (!line) continue;
|
|
72
|
+
let rec;
|
|
73
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
74
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
75
|
+
|
|
76
|
+
const content = rec.message?.content;
|
|
77
|
+
if (!Array.isArray(content)) continue;
|
|
78
|
+
|
|
79
|
+
for (const block of content) {
|
|
80
|
+
if (!block || typeof block !== 'object') continue;
|
|
81
|
+
if (
|
|
82
|
+
rec.type === 'assistant' &&
|
|
83
|
+
block.type === 'tool_use' &&
|
|
84
|
+
block.name === 'AskUserQuestion' &&
|
|
85
|
+
typeof block.id === 'string'
|
|
86
|
+
) {
|
|
87
|
+
const q = block.input?.questions?.[0]?.question;
|
|
88
|
+
open.set(block.id, typeof q === 'string' ? q : null);
|
|
89
|
+
} else if (
|
|
90
|
+
rec.type === 'user' &&
|
|
91
|
+
block.type === 'tool_result' &&
|
|
92
|
+
typeof block.tool_use_id === 'string'
|
|
93
|
+
) {
|
|
94
|
+
resolved.add(block.tool_use_id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Newest still-open AskUserQuestion (Map preserves insertion order).
|
|
100
|
+
let pendingToolUseId = null;
|
|
101
|
+
let pendingQuestion = null;
|
|
102
|
+
for (const [id, question] of open) {
|
|
103
|
+
if (resolved.has(id)) continue;
|
|
104
|
+
pendingToolUseId = id;
|
|
105
|
+
pendingQuestion = question;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (pendingQuestion && pendingQuestion.length > PENDING_QUESTION_MAX) {
|
|
109
|
+
pendingQuestion = pendingQuestion.slice(0, PENDING_QUESTION_MAX);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
transcriptPending: pendingToolUseId !== null,
|
|
114
|
+
pendingToolUseId,
|
|
115
|
+
pendingQuestion,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Tiny tail-read helper
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the last `maxBytes` of a file and return its contents as a Buffer.
|
|
125
|
+
* Never throws — returns null on any error.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} filePath
|
|
128
|
+
* @param {number} maxBytes
|
|
129
|
+
* @returns {Promise<Buffer|null>}
|
|
130
|
+
*/
|
|
131
|
+
async function readTail(filePath, maxBytes) {
|
|
132
|
+
let fh;
|
|
133
|
+
try {
|
|
134
|
+
fh = await fs.open(filePath, 'r');
|
|
135
|
+
const stat = await fh.stat();
|
|
136
|
+
const size = stat.size;
|
|
137
|
+
if (size === 0) return Buffer.alloc(0);
|
|
138
|
+
const readSize = Math.min(size, maxBytes);
|
|
139
|
+
const offset = size - readSize;
|
|
140
|
+
const buf = Buffer.allocUnsafe(readSize);
|
|
141
|
+
const { bytesRead } = await fh.read(buf, 0, readSize, offset);
|
|
142
|
+
return buf.subarray(0, bytesRead);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
} finally {
|
|
146
|
+
if (fh) await fh.close().catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse the tail buffer of a JSONL file and return the last record that has a
|
|
152
|
+
* truthy `.cwd` field, plus basic metadata.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} filePath Absolute path of the .jsonl file
|
|
155
|
+
* @param {number} mtime mtime (ms since epoch) of the file
|
|
156
|
+
* @returns {Promise<{cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}|null>}
|
|
157
|
+
*/
|
|
158
|
+
async function extractTailRecord(filePath, mtime) {
|
|
159
|
+
const buf = await readTail(filePath, TAIL_BYTES);
|
|
160
|
+
if (!buf) return null;
|
|
161
|
+
|
|
162
|
+
const text = buf.toString('utf8');
|
|
163
|
+
// Split on newlines; the first segment may be a partial line (the tail read
|
|
164
|
+
// can start part-way through a line), so we never trust it — we only walk
|
|
165
|
+
// complete lines from the end.
|
|
166
|
+
const lines = text.split('\n');
|
|
167
|
+
|
|
168
|
+
const base = {
|
|
169
|
+
cwd: null,
|
|
170
|
+
sessionId: null,
|
|
171
|
+
lastActivity: null,
|
|
172
|
+
model: null,
|
|
173
|
+
aiTitle: null,
|
|
174
|
+
customTitle: null,
|
|
175
|
+
transcriptPath: filePath,
|
|
176
|
+
mtime,
|
|
177
|
+
transcriptPending: false,
|
|
178
|
+
pendingToolUseId: null,
|
|
179
|
+
pendingQuestion: null,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Transcript-derived pending: detect an AskUserQuestion that is open in the
|
|
183
|
+
// tail (no matching tool_result) even when no tailer is subscribed. Notifies
|
|
184
|
+
// for ANY session, not just the one a client is watching.
|
|
185
|
+
const pending = detectTranscriptPending(lines);
|
|
186
|
+
base.transcriptPending = pending.transcriptPending;
|
|
187
|
+
base.pendingToolUseId = pending.pendingToolUseId;
|
|
188
|
+
base.pendingQuestion = pending.pendingQuestion;
|
|
189
|
+
|
|
190
|
+
// Walk from end collecting the newest cwd/sessionId/timestamp/model/title.
|
|
191
|
+
// ai-title is re-emitted throughout the file so the tail usually carries it;
|
|
192
|
+
// custom-title (a user /rename) is written when renamed, so it appears late.
|
|
193
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
194
|
+
const line = lines[i].trim();
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
let rec;
|
|
197
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
198
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
199
|
+
if (base.lastActivity === null && typeof rec.timestamp === 'string') base.lastActivity = rec.timestamp;
|
|
200
|
+
if (base.sessionId === null && typeof rec.sessionId === 'string') base.sessionId = rec.sessionId;
|
|
201
|
+
if (base.customTitle === null && rec.type === 'custom-title' && rec.customTitle) base.customTitle = rec.customTitle;
|
|
202
|
+
if (base.aiTitle === null && rec.type === 'ai-title' && rec.aiTitle) base.aiTitle = rec.aiTitle;
|
|
203
|
+
if (base.model === null && rec.type === 'assistant' && typeof rec.message?.model === 'string') base.model = rec.message.model;
|
|
204
|
+
if (base.cwd === null && typeof rec.cwd === 'string' && rec.cwd) base.cwd = rec.cwd;
|
|
205
|
+
if (base.cwd && base.sessionId && base.model && (base.customTitle || base.aiTitle)) {
|
|
206
|
+
break; // everything found
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return base;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// findNewestJsonl — returns { path, mtime } or null
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Given a directory, find the *.jsonl file with the newest mtime.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} dir
|
|
220
|
+
* @returns {Promise<{filePath:string, mtime:number}|null>}
|
|
221
|
+
*/
|
|
222
|
+
async function findNewestJsonl(dir) {
|
|
223
|
+
let entries;
|
|
224
|
+
try {
|
|
225
|
+
entries = await fs.readdir(dir);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let newest = null;
|
|
231
|
+
|
|
232
|
+
await Promise.all(
|
|
233
|
+
entries
|
|
234
|
+
.filter((e) => e.endsWith('.jsonl'))
|
|
235
|
+
.map(async (e) => {
|
|
236
|
+
const full = path.join(dir, e);
|
|
237
|
+
let st;
|
|
238
|
+
try { st = await fs.stat(full); } catch { return; }
|
|
239
|
+
const mtime = st.mtimeMs;
|
|
240
|
+
if (!newest || mtime > newest.mtime) {
|
|
241
|
+
newest = { filePath: full, mtime };
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return newest;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// SessionRegistry
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
export class SessionRegistry extends EventEmitter {
|
|
254
|
+
/**
|
|
255
|
+
* @param {{ projectsRoot: string, tmux: object, debounceMs?: number }} opts
|
|
256
|
+
*/
|
|
257
|
+
constructor({ projectsRoot, tmux, debounceMs = 1000 } = {}) {
|
|
258
|
+
super();
|
|
259
|
+
this._projectsRoot = projectsRoot;
|
|
260
|
+
this._tmux = tmux;
|
|
261
|
+
this._debounceMs = debounceMs;
|
|
262
|
+
|
|
263
|
+
/** @type {Session[]} */
|
|
264
|
+
this._sessions = [];
|
|
265
|
+
/** @type {string|null} — last JSON snapshot for change detection */
|
|
266
|
+
this._lastEmitted = null;
|
|
267
|
+
/** @type {Map<string, boolean>} id -> pending flag */
|
|
268
|
+
this._pendingMap = new Map();
|
|
269
|
+
/** @type {Map<string, {ctxPct:number|null, model:string|null}>} windowId -> TUI status */
|
|
270
|
+
this._ctxMap = new Map();
|
|
271
|
+
/** @type {ReturnType<setInterval>|null} */
|
|
272
|
+
this._interval = null;
|
|
273
|
+
/** @type {ReturnType<setInterval>|null} */
|
|
274
|
+
this._ctxInterval = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// Public API
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/** @returns {Session[]} */
|
|
282
|
+
getSessions() {
|
|
283
|
+
return this._sessions;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Set the pending flag for a session (called by server when tailer fires
|
|
288
|
+
* 'pending'). Emits 'change' if the flag actually flipped.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} id
|
|
291
|
+
* @param {boolean} pending
|
|
292
|
+
*/
|
|
293
|
+
setPending(id, pending) {
|
|
294
|
+
const session = this._sessions.find((s) => s.id === id);
|
|
295
|
+
if (!session) return;
|
|
296
|
+
const was = session.pending;
|
|
297
|
+
session.pending = !!pending;
|
|
298
|
+
this._pendingMap.set(id, !!pending);
|
|
299
|
+
if (was !== session.pending) {
|
|
300
|
+
this._maybeEmit();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Rescan tmux windows and project directories. Returns the new session list.
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<Session[]>}
|
|
308
|
+
*/
|
|
309
|
+
async refresh() {
|
|
310
|
+
const [allWindows, transcriptIndex] = await Promise.all([
|
|
311
|
+
this._listWindows(),
|
|
312
|
+
this._buildTranscriptIndex(),
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
// Grouped tmux sessions (e.g. a `_mobile` mirror of session `0`) expose the
|
|
316
|
+
// SAME underlying window under multiple session names — identical window_id.
|
|
317
|
+
// Collapse those so the UI shows each real window once (keeping the first,
|
|
318
|
+
// which is the primary session by tmux's list ordering).
|
|
319
|
+
const seenWindowIds = new Set();
|
|
320
|
+
const windows = allWindows.filter((w) => {
|
|
321
|
+
if (seenWindowIds.has(w.windowId)) return false;
|
|
322
|
+
seenWindowIds.add(w.windowId);
|
|
323
|
+
return true;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const sessions = windows.map((win) => {
|
|
327
|
+
// Primary match: directory-name encoding (survives mid-session `cd`).
|
|
328
|
+
// encodeCwd is lossy ('/' and '.' both -> '-'), so a byDir hit is only
|
|
329
|
+
// trusted when the cwd recorded inside the transcript is consistent with
|
|
330
|
+
// this window's cwd — equal, a descendant (the agent cd'd into a subdir),
|
|
331
|
+
// or absent. An unrelated sibling (e.g. my.lib vs my-lib) is rejected and
|
|
332
|
+
// falls through to the exact-cwd index.
|
|
333
|
+
const byDirHit = transcriptIndex.byDir.get(encodeCwd(win.cwd));
|
|
334
|
+
const transcript =
|
|
335
|
+
(byDirHit && isCwdConsistent(byDirHit.cwd, win.cwd) ? byDirHit : null) ??
|
|
336
|
+
transcriptIndex.byCwd.get(win.cwd) ??
|
|
337
|
+
null;
|
|
338
|
+
const id = win.target;
|
|
339
|
+
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
340
|
+
// pending (works for ANY session, even unsubscribed ones, for push).
|
|
341
|
+
const pending =
|
|
342
|
+
(this._pendingMap.get(id) ?? false) || !!transcript?.transcriptPending;
|
|
343
|
+
const title = transcript?.customTitle || transcript?.aiTitle || null;
|
|
344
|
+
const ctx = this._ctxMap.get(win.windowId) || {};
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
id,
|
|
348
|
+
sessionId: transcript?.sessionId ?? null,
|
|
349
|
+
// Best label: live TUI/transcript title > tmux window name > target.
|
|
350
|
+
name: title || win.windowName || win.target,
|
|
351
|
+
title,
|
|
352
|
+
tmuxName: win.windowName,
|
|
353
|
+
target: win.target,
|
|
354
|
+
sessionName: win.sessionName,
|
|
355
|
+
windowIndex: win.windowIndex,
|
|
356
|
+
paneIndex: win.paneIndex,
|
|
357
|
+
windowId: win.windowId,
|
|
358
|
+
active: win.active,
|
|
359
|
+
cwd: win.cwd,
|
|
360
|
+
transcriptPath: transcript?.transcriptPath ?? null,
|
|
361
|
+
lastActivity: transcript?.lastActivity ?? null,
|
|
362
|
+
pending,
|
|
363
|
+
pendingQuestion: transcript?.pendingQuestion ?? null,
|
|
364
|
+
cmd: win.cmd,
|
|
365
|
+
isClaude: true,
|
|
366
|
+
model: ctx.model || prettyModel(transcript?.model) || null,
|
|
367
|
+
ctxPct: ctx.ctxPct ?? null,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Dedup transcript collisions: when multiple tmux windows share a cwd they
|
|
372
|
+
// all match the SAME newest transcript for that dir → the rail shows the
|
|
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.
|
|
398
|
+
this._sessions = sessions.filter((s) => isClaudeCmd(s.cmd) || s.transcriptPath);
|
|
399
|
+
this._maybeEmit();
|
|
400
|
+
return this._sessions;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Capture each Claude pane's TUI status line and parse model + context %.
|
|
405
|
+
* Throttled (separate from the 4 s refresh) and best-effort — capture-pane is
|
|
406
|
+
* cheap but we keep it off the hot path per the resource doctrine.
|
|
407
|
+
*/
|
|
408
|
+
async _pollCtx() {
|
|
409
|
+
const sessions = this._sessions;
|
|
410
|
+
await Promise.all(
|
|
411
|
+
sessions.map(async (s) => {
|
|
412
|
+
if (!this._tmux.isValidTarget(s.target)) return;
|
|
413
|
+
try {
|
|
414
|
+
const cap = await this._tmux.capturePane(s.target, 8);
|
|
415
|
+
const { ctxPct, model } = parseTuiStatus(cap);
|
|
416
|
+
this._ctxMap.set(s.windowId, { ctxPct, model });
|
|
417
|
+
// Merge into the live session object without a full rebuild.
|
|
418
|
+
if (ctxPct !== null) s.ctxPct = ctxPct;
|
|
419
|
+
if (model) s.model = model;
|
|
420
|
+
} catch {
|
|
421
|
+
// pane gone / capture failed — leave previous values
|
|
422
|
+
}
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
this._maybeEmit();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Start periodic refresh (every 4 s) + a slower ctx poll, and fire both once. */
|
|
429
|
+
start() {
|
|
430
|
+
this.refresh().then(() => this._pollCtx()).catch(() => {});
|
|
431
|
+
this._interval = setInterval(() => {
|
|
432
|
+
this.refresh().catch(() => {});
|
|
433
|
+
}, REFRESH_INTERVAL_MS);
|
|
434
|
+
this._ctxInterval = setInterval(() => {
|
|
435
|
+
this._pollCtx().catch(() => {});
|
|
436
|
+
}, CTX_POLL_INTERVAL_MS);
|
|
437
|
+
if (this._interval.unref) this._interval.unref();
|
|
438
|
+
if (this._ctxInterval.unref) this._ctxInterval.unref();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Stop periodic refresh. */
|
|
442
|
+
stop() {
|
|
443
|
+
if (this._interval !== null) {
|
|
444
|
+
clearInterval(this._interval);
|
|
445
|
+
this._interval = null;
|
|
446
|
+
}
|
|
447
|
+
if (this._ctxInterval) {
|
|
448
|
+
clearInterval(this._ctxInterval);
|
|
449
|
+
this._ctxInterval = null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// -------------------------------------------------------------------------
|
|
454
|
+
// Private helpers
|
|
455
|
+
// -------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Safely call tmux.listWindows(), falling back to [] on any error.
|
|
459
|
+
*
|
|
460
|
+
* @returns {Promise<import('./tmux.js').Window[]>}
|
|
461
|
+
*/
|
|
462
|
+
async _listWindows() {
|
|
463
|
+
try {
|
|
464
|
+
return await this._tmux.listWindows();
|
|
465
|
+
} catch {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Scan all immediate subdirectories of projectsRoot. For each, find the
|
|
472
|
+
* newest *.jsonl and extract the last record that carries a .cwd field.
|
|
473
|
+
* Returns a Map keyed by cwd (keeping the newest mtime entry per cwd).
|
|
474
|
+
*
|
|
475
|
+
* @returns {Promise<Map<string, {cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}>>}
|
|
476
|
+
*/
|
|
477
|
+
async _buildTranscriptIndex() {
|
|
478
|
+
/** @type {{byDir: Map<string, object>, byCwd: Map<string, object>}} */
|
|
479
|
+
const index = { byDir: new Map(), byCwd: new Map() };
|
|
480
|
+
|
|
481
|
+
let projectEntries;
|
|
482
|
+
try {
|
|
483
|
+
const entries = await fs.readdir(this._projectsRoot, { withFileTypes: true });
|
|
484
|
+
projectEntries = entries
|
|
485
|
+
.filter((e) => e.isDirectory())
|
|
486
|
+
.map((e) => ({ name: e.name, dir: path.join(this._projectsRoot, e.name) }));
|
|
487
|
+
} catch {
|
|
488
|
+
return index;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
await Promise.all(
|
|
492
|
+
projectEntries.map(async ({ name, dir }) => {
|
|
493
|
+
const newest = await findNewestJsonl(dir);
|
|
494
|
+
if (!newest) return;
|
|
495
|
+
|
|
496
|
+
const rec = await extractTailRecord(newest.filePath, newest.mtime);
|
|
497
|
+
if (!rec) return;
|
|
498
|
+
|
|
499
|
+
// Primary key: the project directory name (Claude Code's cwd encoding).
|
|
500
|
+
const byDirExisting = index.byDir.get(name);
|
|
501
|
+
if (!byDirExisting || newest.mtime > byDirExisting.mtime) {
|
|
502
|
+
index.byDir.set(name, rec);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Secondary key: the exact cwd recorded inside the transcript, when present.
|
|
506
|
+
if (rec.cwd) {
|
|
507
|
+
const byCwdExisting = index.byCwd.get(rec.cwd);
|
|
508
|
+
if (!byCwdExisting || newest.mtime > byCwdExisting.mtime) {
|
|
509
|
+
index.byCwd.set(rec.cwd, rec);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
return index;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Emit 'change' only when the serialized sessions differ from the last
|
|
520
|
+
* emission.
|
|
521
|
+
*/
|
|
522
|
+
_maybeEmit() {
|
|
523
|
+
const serialized = JSON.stringify(this._sessions);
|
|
524
|
+
if (serialized !== this._lastEmitted) {
|
|
525
|
+
this._lastEmitted = serialized;
|
|
526
|
+
this.emit('change', this._sessions);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|