@way_marks/server 4.0.2 → 4.3.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/dist/api/routes/agent-monitor.js +187 -0
- package/dist/api/server.js +13 -0
- package/dist/approvals/handler.js +13 -2
- package/dist/collectors/claude.js +753 -0
- package/dist/collectors/codex.js +516 -0
- package/dist/collectors/copilot.js +373 -0
- package/dist/collectors/multi-collector.js +221 -0
- package/dist/collectors/process.js +257 -0
- package/dist/collectors/rate-limit.js +161 -0
- package/dist/collectors/secrets.js +43 -0
- package/dist/collectors/types.js +47 -0
- package/dist/mcp/server.js +14 -0
- package/dist/mcp/tools/agent-monitor.js +272 -0
- package/package.json +1 -1
- package/src/ui-dist/assets/{index-DNdosrlQ.css → index-BJAvjazt.css} +1 -1
- package/src/ui-dist/assets/index-CcybEu0u.js +87 -0
- package/src/ui-dist/index.html +2 -2
- package/src/ui-dist/assets/index-BEo79vjN.js +0 -87
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code session collector.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors abtop/src/collector/claude.rs:
|
|
6
|
+
* - Session discovery: find running `claude` processes → read ~/.claude/sessions/{PID}.json
|
|
7
|
+
* - Transcript parsing: ~/.claude/projects/{encoded-cwd}/{sessionId}.jsonl
|
|
8
|
+
* - Incremental reads: tracks byte offset to parse only new data each tick
|
|
9
|
+
* - Sub-agent discovery: {projectDir}/{sessionId}/subagents/
|
|
10
|
+
* - Memory status: {projectDir}/memory/
|
|
11
|
+
*
|
|
12
|
+
* Data sources are undocumented Claude Code internals — use defensive parsing throughout.
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.ClaudeCollector = void 0;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const os = __importStar(require("os"));
|
|
52
|
+
const types_1 = require("./types");
|
|
53
|
+
const process_1 = require("./process");
|
|
54
|
+
const secrets_1 = require("./secrets");
|
|
55
|
+
// ─── Collector class ──────────────────────────────────────────────────────────
|
|
56
|
+
class ClaudeCollector {
|
|
57
|
+
constructor() {
|
|
58
|
+
this.transcriptCache = {};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Collect all live Claude sessions.
|
|
62
|
+
*
|
|
63
|
+
* @param processInfo Current ps snapshot (pid → ProcInfo)
|
|
64
|
+
* @param childrenMap Parent → children adjacency
|
|
65
|
+
* @param ports pid → listening port[]
|
|
66
|
+
* @param gitMap cwd → {added, modified} (populated by caller on slow ticks)
|
|
67
|
+
*/
|
|
68
|
+
collect(processInfo, childrenMap, ports, gitMap) {
|
|
69
|
+
const sessions = this.collectSessions(processInfo, childrenMap, ports, gitMap);
|
|
70
|
+
this.evictStaleCache(sessions);
|
|
71
|
+
sessions.sort((a, b) => b.startedAt - a.startedAt);
|
|
72
|
+
return sessions;
|
|
73
|
+
}
|
|
74
|
+
evictStaleCache(sessions) {
|
|
75
|
+
const activeIds = new Set(sessions.map((s) => s.sessionId));
|
|
76
|
+
for (const sid of Object.keys(this.transcriptCache)) {
|
|
77
|
+
if (!activeIds.has(sid))
|
|
78
|
+
delete this.transcriptCache[sid];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
collectSessions(processInfo, childrenMap, ports, gitMap) {
|
|
82
|
+
const claudePids = findClaudePids(processInfo);
|
|
83
|
+
const configRoot = path.join(os.homedir(), '.claude');
|
|
84
|
+
const sessionsDir = path.join(configRoot, 'sessions');
|
|
85
|
+
const projectsDir = path.join(configRoot, 'projects');
|
|
86
|
+
// Read all session JSON files
|
|
87
|
+
const sessionFiles = readSessionFiles(sessionsDir, claudePids);
|
|
88
|
+
const discoveryCtx = buildDiscoveryContext(sessionFiles, processInfo);
|
|
89
|
+
const sessions = [];
|
|
90
|
+
const seenIds = new Set();
|
|
91
|
+
for (const sf of sessionFiles) {
|
|
92
|
+
const session = this.loadSession(sf, projectsDir, processInfo, childrenMap, ports, gitMap, discoveryCtx);
|
|
93
|
+
if (session && !seenIds.has(session.sessionId)) {
|
|
94
|
+
seenIds.add(session.sessionId);
|
|
95
|
+
sessions.push(session);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return sessions;
|
|
99
|
+
}
|
|
100
|
+
loadSession(sf, projectsDir, processInfo, childrenMap, ports, gitMap, ctx) {
|
|
101
|
+
const procCmd = processInfo.get(sf.pid)?.command;
|
|
102
|
+
const pidAlive = procCmd != null && (0, process_1.cmdHasBinary)(procCmd, 'claude');
|
|
103
|
+
// Skip --print sessions (abtop/waymark's own LLM summary calls)
|
|
104
|
+
if (procCmd?.includes('--print'))
|
|
105
|
+
return null;
|
|
106
|
+
// Resolve project directory and apply /clear sid override
|
|
107
|
+
const projectDir = resolveProjectDir(projectsDir, sf.cwd, sf.sessionId);
|
|
108
|
+
const siblings = ctx.pidsPerCwd.get(sf.cwd) ?? 1;
|
|
109
|
+
let sessionId = sf.sessionId;
|
|
110
|
+
if (siblings <= 1) {
|
|
111
|
+
const excluded = new Set([...ctx.claimedSidsByPid.entries()]
|
|
112
|
+
.filter(([p]) => p !== sf.pid)
|
|
113
|
+
.map(([, s]) => s));
|
|
114
|
+
const liveSid = findLiveSessionId(projectDir, sf.startedAt, excluded);
|
|
115
|
+
if (liveSid && liveSid !== sessionId)
|
|
116
|
+
sessionId = liveSid;
|
|
117
|
+
}
|
|
118
|
+
// Locate transcript file
|
|
119
|
+
const transcriptPath = projectDir
|
|
120
|
+
? path.join(projectDir, `${sessionId}.jsonl`)
|
|
121
|
+
: null;
|
|
122
|
+
const transcriptExists = transcriptPath != null && fs.existsSync(transcriptPath) && !isSymlink(transcriptPath);
|
|
123
|
+
// Parse transcript (incremental)
|
|
124
|
+
let cached = this.transcriptCache[sessionId];
|
|
125
|
+
if (transcriptExists && transcriptPath) {
|
|
126
|
+
const stat = safeStatSync(transcriptPath);
|
|
127
|
+
const currentIdentity = stat
|
|
128
|
+
? [stat.size, stat.mtimeMs]
|
|
129
|
+
: [0, 0];
|
|
130
|
+
const identityChanged = cached != null &&
|
|
131
|
+
(cached.fileIdentity[0] !== currentIdentity[0] ||
|
|
132
|
+
cached.fileIdentity[1] !== currentIdentity[1]);
|
|
133
|
+
const fromOffset = identityChanged || cached == null ? 0 : cached.newOffset;
|
|
134
|
+
const delta = parseTranscript(transcriptPath, fromOffset, currentIdentity);
|
|
135
|
+
if (cached == null || identityChanged || fromOffset === 0) {
|
|
136
|
+
this.transcriptCache[sessionId] = delta;
|
|
137
|
+
cached = delta;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
mergeTranscriptDelta(cached, delta);
|
|
141
|
+
this.transcriptCache[sessionId] = cached;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const tr = cached ?? emptyTranscriptResult();
|
|
145
|
+
if (!pidAlive)
|
|
146
|
+
return null;
|
|
147
|
+
const proc = processInfo.get(sf.pid);
|
|
148
|
+
const memMb = proc ? Math.floor(proc.rssKb / 1024) : 0;
|
|
149
|
+
// Status detection
|
|
150
|
+
const hasActiveChild = (0, process_1.hasActiveDescendant)(sf.pid, childrenMap, processInfo, 5.0);
|
|
151
|
+
const modelGenerating = tr.lastUserTsMs > 0;
|
|
152
|
+
let status;
|
|
153
|
+
if (hasActiveChild) {
|
|
154
|
+
status = 'executing';
|
|
155
|
+
}
|
|
156
|
+
else if (modelGenerating) {
|
|
157
|
+
status = 'thinking';
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
status = 'waiting';
|
|
161
|
+
}
|
|
162
|
+
const currentTasks = tr.currentTask
|
|
163
|
+
? [tr.currentTask]
|
|
164
|
+
: status === 'waiting'
|
|
165
|
+
? ['waiting for input']
|
|
166
|
+
: ['thinking...'];
|
|
167
|
+
// Context window %
|
|
168
|
+
const contextWindow = (0, types_1.contextWindowForModel)(tr.model, tr.maxContextTokens);
|
|
169
|
+
const contextPercent = contextWindow > 0 ? (tr.lastContextTokens / contextWindow) * 100 : 0;
|
|
170
|
+
// Children (all descendants, not just direct)
|
|
171
|
+
const children = collectDescendants(sf.pid, childrenMap, processInfo, ports);
|
|
172
|
+
// Git stats
|
|
173
|
+
const git = gitMap.get(sf.cwd) ?? { added: 0, modified: 0 };
|
|
174
|
+
// Sub-agents and memory
|
|
175
|
+
const subagentDir = projectDir ? path.join(projectDir, sessionId, 'subagents') : null;
|
|
176
|
+
const subagents = subagentDir ? collectSubAgents(subagentDir) : [];
|
|
177
|
+
const memoryDir = projectDir ? path.join(projectDir, 'memory') : null;
|
|
178
|
+
const [memFileCount, memLineCount] = memoryDir
|
|
179
|
+
? collectMemoryStatus(memoryDir)
|
|
180
|
+
: [0, 0];
|
|
181
|
+
return {
|
|
182
|
+
agentCli: 'claude',
|
|
183
|
+
pid: sf.pid,
|
|
184
|
+
sessionId,
|
|
185
|
+
cwd: sf.cwd,
|
|
186
|
+
projectName: sf.cwd.split('/').pop() || '?',
|
|
187
|
+
startedAt: sf.startedAt,
|
|
188
|
+
status,
|
|
189
|
+
model: tr.model,
|
|
190
|
+
effort: readEffortLevel(sf.cwd),
|
|
191
|
+
contextPercent,
|
|
192
|
+
totalInputTokens: tr.totalInput,
|
|
193
|
+
totalOutputTokens: tr.totalOutput,
|
|
194
|
+
totalCacheRead: tr.totalCacheRead,
|
|
195
|
+
totalCacheCreate: tr.totalCacheCreate,
|
|
196
|
+
turnCount: tr.turnCount,
|
|
197
|
+
currentTasks,
|
|
198
|
+
memMb,
|
|
199
|
+
version: tr.version,
|
|
200
|
+
gitBranch: tr.gitBranch,
|
|
201
|
+
gitAdded: git.added,
|
|
202
|
+
gitModified: git.modified,
|
|
203
|
+
tokenHistory: tr.tokenHistory,
|
|
204
|
+
contextHistory: tr.contextHistory,
|
|
205
|
+
compactionCount: tr.compactionCount,
|
|
206
|
+
contextWindow,
|
|
207
|
+
subagents,
|
|
208
|
+
memFileCount,
|
|
209
|
+
memLineCount,
|
|
210
|
+
children,
|
|
211
|
+
initialPrompt: tr.initialPrompt,
|
|
212
|
+
firstAssistantText: tr.firstAssistantText,
|
|
213
|
+
toolCalls: tr.toolCalls,
|
|
214
|
+
pendingSinceMs: tr.lastAssistantTsMs,
|
|
215
|
+
thinkingSinceMs: tr.lastUserTsMs,
|
|
216
|
+
fileAccesses: tr.fileAccesses,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
exports.ClaudeCollector = ClaudeCollector;
|
|
221
|
+
// ─── Session file discovery ───────────────────────────────────────────────────
|
|
222
|
+
function findClaudePids(processInfo) {
|
|
223
|
+
const pids = [];
|
|
224
|
+
for (const [pid, info] of processInfo) {
|
|
225
|
+
if ((0, process_1.cmdHasBinary)(info.command, 'claude') && !info.command.includes('--print')) {
|
|
226
|
+
pids.push(pid);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return pids;
|
|
230
|
+
}
|
|
231
|
+
function readSessionFiles(sessionsDir, alivePids) {
|
|
232
|
+
const results = [];
|
|
233
|
+
if (!fs.existsSync(sessionsDir))
|
|
234
|
+
return results;
|
|
235
|
+
const alivePidSet = new Set(alivePids);
|
|
236
|
+
let entries;
|
|
237
|
+
try {
|
|
238
|
+
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
if (entry.isSymbolicLink())
|
|
245
|
+
continue;
|
|
246
|
+
if (!entry.name.endsWith('.json'))
|
|
247
|
+
continue;
|
|
248
|
+
const filePath = path.join(sessionsDir, entry.name);
|
|
249
|
+
try {
|
|
250
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
251
|
+
const sf = JSON.parse(content);
|
|
252
|
+
if (!sf.pid || !sf.sessionId || !sf.cwd)
|
|
253
|
+
continue;
|
|
254
|
+
// Sanitize
|
|
255
|
+
sf.sessionId = sf.sessionId.slice(0, 256);
|
|
256
|
+
sf.cwd = sf.cwd.slice(0, 4096);
|
|
257
|
+
// Only return sessions whose PID is running (or scan all for fallback)
|
|
258
|
+
if (alivePidSet.has(sf.pid) || alivePids.length === 0) {
|
|
259
|
+
results.push(sf);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Skip unreadable or malformed files
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return results;
|
|
267
|
+
}
|
|
268
|
+
function buildDiscoveryContext(sessionFiles, processInfo) {
|
|
269
|
+
const claimedSidsByPid = new Map();
|
|
270
|
+
const pidsPerCwd = new Map();
|
|
271
|
+
const seenPids = new Set();
|
|
272
|
+
for (const sf of sessionFiles) {
|
|
273
|
+
if (seenPids.has(sf.pid))
|
|
274
|
+
continue;
|
|
275
|
+
seenPids.add(sf.pid);
|
|
276
|
+
const info = processInfo.get(sf.pid);
|
|
277
|
+
if (!info)
|
|
278
|
+
continue;
|
|
279
|
+
if (!(0, process_1.cmdHasBinary)(info.command, 'claude'))
|
|
280
|
+
continue;
|
|
281
|
+
if (info.command.includes('--print'))
|
|
282
|
+
continue;
|
|
283
|
+
pidsPerCwd.set(sf.cwd, (pidsPerCwd.get(sf.cwd) ?? 0) + 1);
|
|
284
|
+
claimedSidsByPid.set(sf.pid, sf.sessionId);
|
|
285
|
+
}
|
|
286
|
+
return { claimedSidsByPid, pidsPerCwd };
|
|
287
|
+
}
|
|
288
|
+
// ─── Project directory resolution ─────────────────────────────────────────────
|
|
289
|
+
/**
|
|
290
|
+
* Encode a cwd path the same way Claude Code does for project directory names:
|
|
291
|
+
* `/Users/foo/bar` → `-Users-foo-bar`
|
|
292
|
+
*/
|
|
293
|
+
function encodeCwdPath(cwd) {
|
|
294
|
+
return cwd.replace(/\//g, '-');
|
|
295
|
+
}
|
|
296
|
+
function resolveProjectDir(projectsDir, cwd, originalSid) {
|
|
297
|
+
const encoded = encodeCwdPath(cwd);
|
|
298
|
+
const primary = path.join(projectsDir, encoded);
|
|
299
|
+
const jsonlName = `${originalSid}.jsonl`;
|
|
300
|
+
if (fs.existsSync(path.join(primary, jsonlName)) && !isSymlink(path.join(primary, jsonlName))) {
|
|
301
|
+
return primary;
|
|
302
|
+
}
|
|
303
|
+
// Search sibling directories (handles worktree sessions)
|
|
304
|
+
try {
|
|
305
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
306
|
+
for (const entry of entries) {
|
|
307
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
308
|
+
continue;
|
|
309
|
+
const candidate = path.join(projectsDir, entry.name, jsonlName);
|
|
310
|
+
if (fs.existsSync(candidate) && !isSymlink(candidate)) {
|
|
311
|
+
return path.join(projectsDir, entry.name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// ignore
|
|
317
|
+
}
|
|
318
|
+
return fs.existsSync(primary) ? primary : null;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Find the currently-live session_id after a `/clear` command.
|
|
322
|
+
* Picks the most recently modified `.jsonl` in the project directory
|
|
323
|
+
* whose mtime >= (startedAt - 5s) and is not in the excluded set.
|
|
324
|
+
*/
|
|
325
|
+
function findLiveSessionId(projectDir, startedAtMs, excluded) {
|
|
326
|
+
if (!projectDir || !fs.existsSync(projectDir))
|
|
327
|
+
return null;
|
|
328
|
+
const minMtime = startedAtMs - 5000;
|
|
329
|
+
let best = null;
|
|
330
|
+
let entries;
|
|
331
|
+
try {
|
|
332
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
if (entry.isSymbolicLink())
|
|
339
|
+
continue;
|
|
340
|
+
if (!entry.name.endsWith('.jsonl'))
|
|
341
|
+
continue;
|
|
342
|
+
const stem = entry.name.slice(0, -6);
|
|
343
|
+
if (excluded.has(stem))
|
|
344
|
+
continue;
|
|
345
|
+
const filePath = path.join(projectDir, entry.name);
|
|
346
|
+
const stat = safeStatSync(filePath);
|
|
347
|
+
if (!stat)
|
|
348
|
+
continue;
|
|
349
|
+
if (stat.mtimeMs < minMtime)
|
|
350
|
+
continue;
|
|
351
|
+
if (best == null || stat.mtimeMs > best.mtime) {
|
|
352
|
+
best = { mtime: stat.mtimeMs, sid: stem };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return best?.sid ?? null;
|
|
356
|
+
}
|
|
357
|
+
// ─── Transcript parsing ───────────────────────────────────────────────────────
|
|
358
|
+
const MAX_LINE_BYTES = 10 * 1024 * 1024; // 10 MB per line cap
|
|
359
|
+
function emptyTranscriptResult() {
|
|
360
|
+
return {
|
|
361
|
+
model: '-',
|
|
362
|
+
totalInput: 0,
|
|
363
|
+
totalOutput: 0,
|
|
364
|
+
totalCacheRead: 0,
|
|
365
|
+
totalCacheCreate: 0,
|
|
366
|
+
lastContextTokens: 0,
|
|
367
|
+
maxContextTokens: 0,
|
|
368
|
+
contextHistory: [],
|
|
369
|
+
compactionCount: 0,
|
|
370
|
+
turnCount: 0,
|
|
371
|
+
currentTask: '',
|
|
372
|
+
version: '',
|
|
373
|
+
gitBranch: '',
|
|
374
|
+
tokenHistory: [],
|
|
375
|
+
initialPrompt: '',
|
|
376
|
+
firstAssistantText: '',
|
|
377
|
+
toolCalls: [],
|
|
378
|
+
lastAssistantTsMs: 0,
|
|
379
|
+
lastUserTsMs: 0,
|
|
380
|
+
sawTurn: false,
|
|
381
|
+
fileAccesses: [],
|
|
382
|
+
newOffset: 0,
|
|
383
|
+
fileIdentity: [0, 0],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Parse a Claude transcript JSONL file starting from `fromOffset` bytes.
|
|
388
|
+
* Returns cumulative deltas since that offset.
|
|
389
|
+
*
|
|
390
|
+
* Mirrors abtop's `parse_transcript`.
|
|
391
|
+
*/
|
|
392
|
+
function parseTranscript(filePath, fromOffset, fileIdentity) {
|
|
393
|
+
const result = emptyTranscriptResult();
|
|
394
|
+
result.fileIdentity = fileIdentity;
|
|
395
|
+
result.newOffset = fromOffset;
|
|
396
|
+
let fd;
|
|
397
|
+
try {
|
|
398
|
+
fd = fs.openSync(filePath, 'r');
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const stat = fs.fstatSync(fd);
|
|
405
|
+
const fileLen = stat.size;
|
|
406
|
+
if (fileLen === fromOffset) {
|
|
407
|
+
result.newOffset = fileLen;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
// File shrank → reparse from start
|
|
411
|
+
const effectiveOffset = fileLen < fromOffset ? 0 : fromOffset;
|
|
412
|
+
// Read from effectiveOffset to EOF
|
|
413
|
+
const bufSize = fileLen - effectiveOffset;
|
|
414
|
+
if (bufSize <= 0)
|
|
415
|
+
return result;
|
|
416
|
+
const buf = Buffer.allocUnsafe(bufSize);
|
|
417
|
+
fs.readSync(fd, buf, 0, bufSize, effectiveOffset);
|
|
418
|
+
const text = buf.toString('utf8');
|
|
419
|
+
let bytesRead = effectiveOffset;
|
|
420
|
+
let lineStart = 0;
|
|
421
|
+
const lines = text.split('\n');
|
|
422
|
+
for (let i = 0; i < lines.length; i++) {
|
|
423
|
+
const rawLine = lines[i];
|
|
424
|
+
const lineBytes = Buffer.byteLength(rawLine, 'utf8') + 1; // +1 for \n
|
|
425
|
+
// Last fragment without newline — defer to next poll
|
|
426
|
+
const isLast = i === lines.length - 1;
|
|
427
|
+
if (isLast && rawLine.length > 0)
|
|
428
|
+
break;
|
|
429
|
+
const line = rawLine.trim();
|
|
430
|
+
if (!line) {
|
|
431
|
+
bytesRead += lineBytes;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
|
|
435
|
+
// Oversize line — skip to EOF
|
|
436
|
+
bytesRead = fileLen;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
let val;
|
|
440
|
+
try {
|
|
441
|
+
val = JSON.parse(line);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
bytesRead += lineBytes;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
processTranscriptLine(val, result);
|
|
448
|
+
bytesRead += lineBytes;
|
|
449
|
+
}
|
|
450
|
+
result.newOffset = bytesRead;
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
try {
|
|
454
|
+
fs.closeSync(fd);
|
|
455
|
+
}
|
|
456
|
+
catch { /* ignore */ }
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
function processTranscriptLine(val, result) {
|
|
461
|
+
const type = val['type'];
|
|
462
|
+
if (type === 'assistant') {
|
|
463
|
+
const entryTsMs = parseIso(val['timestamp']);
|
|
464
|
+
result.sawTurn = true;
|
|
465
|
+
result.turnCount++;
|
|
466
|
+
result.currentTask = '';
|
|
467
|
+
result.lastAssistantTsMs = entryTsMs || result.lastAssistantTsMs;
|
|
468
|
+
result.lastUserTsMs = 0;
|
|
469
|
+
const msg = val['message'];
|
|
470
|
+
if (!msg)
|
|
471
|
+
return;
|
|
472
|
+
if (typeof msg['model'] === 'string')
|
|
473
|
+
result.model = msg['model'];
|
|
474
|
+
const usage = msg['usage'];
|
|
475
|
+
if (usage) {
|
|
476
|
+
const inp = usage['input_tokens'] || 0;
|
|
477
|
+
const out = usage['output_tokens'] || 0;
|
|
478
|
+
const cr = usage['cache_read_input_tokens'] || 0;
|
|
479
|
+
const cc = usage['cache_creation_input_tokens'] || 0;
|
|
480
|
+
result.totalInput += inp;
|
|
481
|
+
result.totalOutput += out;
|
|
482
|
+
result.totalCacheRead += cr;
|
|
483
|
+
result.totalCacheCreate += cc;
|
|
484
|
+
// Context = input + cache_read (exclude cache_creation, see #54)
|
|
485
|
+
const prevContext = result.lastContextTokens;
|
|
486
|
+
result.lastContextTokens = inp + cr;
|
|
487
|
+
if (result.lastContextTokens > result.maxContextTokens) {
|
|
488
|
+
result.maxContextTokens = result.lastContextTokens;
|
|
489
|
+
}
|
|
490
|
+
// Compaction: context drops > 30% between turns
|
|
491
|
+
if (prevContext > 0 && result.lastContextTokens < prevContext * 0.7) {
|
|
492
|
+
result.compactionCount++;
|
|
493
|
+
}
|
|
494
|
+
if (result.contextHistory.length < 10000)
|
|
495
|
+
result.contextHistory.push(result.lastContextTokens);
|
|
496
|
+
if (result.tokenHistory.length < 10000)
|
|
497
|
+
result.tokenHistory.push(inp + out + cr + cc);
|
|
498
|
+
}
|
|
499
|
+
// Extract first assistant text for summary fallback
|
|
500
|
+
if (!result.firstAssistantText) {
|
|
501
|
+
const content = msg['content'];
|
|
502
|
+
if (Array.isArray(content)) {
|
|
503
|
+
const texts = content
|
|
504
|
+
.filter((b) => b['type'] === 'text')
|
|
505
|
+
.map((b) => b['text'] || '')
|
|
506
|
+
.filter(Boolean);
|
|
507
|
+
if (texts.length > 0) {
|
|
508
|
+
result.firstAssistantText = texts.join(' ').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Extract tool_use blocks → current task + tool timeline
|
|
513
|
+
const content = msg['content'];
|
|
514
|
+
if (Array.isArray(content)) {
|
|
515
|
+
for (const block of content) {
|
|
516
|
+
if (block['type'] !== 'tool_use')
|
|
517
|
+
continue;
|
|
518
|
+
const toolName = block['name'] || '';
|
|
519
|
+
const input = block['input'] || {};
|
|
520
|
+
const toolArg = extractToolArg(toolName, input);
|
|
521
|
+
if (!result.currentTask)
|
|
522
|
+
result.currentTask = `${toolName} ${toolArg}`.trim();
|
|
523
|
+
// Track file accesses for audit log
|
|
524
|
+
const fileOp = toolNameToFileOp(toolName);
|
|
525
|
+
const filePath = input['file_path'] || input['path'] || '';
|
|
526
|
+
if (fileOp && filePath) {
|
|
527
|
+
result.fileAccesses.push({ path: filePath, operation: fileOp, turnIndex: result.turnCount });
|
|
528
|
+
// Sliding window cap
|
|
529
|
+
if (result.fileAccesses.length > types_1.MAX_FILE_ACCESSES) {
|
|
530
|
+
result.fileAccesses.splice(0, result.fileAccesses.length - types_1.MAX_FILE_ACCESSES);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Add to tool call timeline
|
|
534
|
+
if (result.toolCalls.length < 500) {
|
|
535
|
+
result.toolCalls.push({ name: toolName, arg: toolArg, durationMs: 0 });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
else if (type === 'user') {
|
|
541
|
+
result.sawTurn = true;
|
|
542
|
+
const entryTsMs = parseIso(val['timestamp']);
|
|
543
|
+
const msg = val['message'];
|
|
544
|
+
// Skip tool_result wrappers — only real user prompts should flip lastUserTsMs
|
|
545
|
+
const role = msg?.['role'];
|
|
546
|
+
const content = msg?.['content'];
|
|
547
|
+
const isToolResult = Array.isArray(content) &&
|
|
548
|
+
content.some((b) => b['type'] === 'tool_result');
|
|
549
|
+
if (!isToolResult && role === 'user') {
|
|
550
|
+
result.lastUserTsMs = entryTsMs || 0;
|
|
551
|
+
result.lastAssistantTsMs = 0;
|
|
552
|
+
}
|
|
553
|
+
if (typeof val['version'] === 'string' && !result.version) {
|
|
554
|
+
result.version = val['version'];
|
|
555
|
+
}
|
|
556
|
+
if (typeof val['gitBranch'] === 'string') {
|
|
557
|
+
result.gitBranch = val['gitBranch'];
|
|
558
|
+
}
|
|
559
|
+
// Capture initial prompt (first real user message)
|
|
560
|
+
if (!result.initialPrompt && !isToolResult && typeof content === 'string') {
|
|
561
|
+
result.initialPrompt = (0, secrets_1.redactSecrets)(content.slice(0, 120));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// ─── Transcript cache merge ───────────────────────────────────────────────────
|
|
566
|
+
function mergeTranscriptDelta(prev, delta) {
|
|
567
|
+
if (delta.model !== '-')
|
|
568
|
+
prev.model = delta.model;
|
|
569
|
+
prev.totalInput += delta.totalInput;
|
|
570
|
+
prev.totalOutput += delta.totalOutput;
|
|
571
|
+
prev.totalCacheRead += delta.totalCacheRead;
|
|
572
|
+
prev.totalCacheCreate += delta.totalCacheCreate;
|
|
573
|
+
if (delta.lastContextTokens > 0)
|
|
574
|
+
prev.lastContextTokens = delta.lastContextTokens;
|
|
575
|
+
if (delta.maxContextTokens > prev.maxContextTokens)
|
|
576
|
+
prev.maxContextTokens = delta.maxContextTokens;
|
|
577
|
+
prev.compactionCount += delta.compactionCount;
|
|
578
|
+
prev.turnCount += delta.turnCount;
|
|
579
|
+
if (delta.turnCount > 0)
|
|
580
|
+
prev.currentTask = delta.currentTask;
|
|
581
|
+
if (delta.version)
|
|
582
|
+
prev.version = delta.version;
|
|
583
|
+
if (delta.gitBranch)
|
|
584
|
+
prev.gitBranch = delta.gitBranch;
|
|
585
|
+
prev.tokenHistory.push(...delta.tokenHistory);
|
|
586
|
+
prev.contextHistory.push(...delta.contextHistory);
|
|
587
|
+
if (prev.toolCalls.length < 500) {
|
|
588
|
+
prev.toolCalls.push(...delta.toolCalls.slice(0, 500 - prev.toolCalls.length));
|
|
589
|
+
}
|
|
590
|
+
if (delta.sawTurn) {
|
|
591
|
+
prev.lastAssistantTsMs = delta.lastAssistantTsMs;
|
|
592
|
+
prev.lastUserTsMs = delta.lastUserTsMs;
|
|
593
|
+
}
|
|
594
|
+
if (!prev.initialPrompt && delta.initialPrompt)
|
|
595
|
+
prev.initialPrompt = delta.initialPrompt;
|
|
596
|
+
if (!prev.firstAssistantText && delta.firstAssistantText)
|
|
597
|
+
prev.firstAssistantText = delta.firstAssistantText;
|
|
598
|
+
prev.fileAccesses.push(...delta.fileAccesses);
|
|
599
|
+
if (prev.fileAccesses.length > types_1.MAX_FILE_ACCESSES) {
|
|
600
|
+
prev.fileAccesses.splice(0, prev.fileAccesses.length - types_1.MAX_FILE_ACCESSES);
|
|
601
|
+
}
|
|
602
|
+
prev.newOffset = delta.newOffset;
|
|
603
|
+
prev.fileIdentity = delta.fileIdentity;
|
|
604
|
+
prev.sawTurn = delta.sawTurn;
|
|
605
|
+
}
|
|
606
|
+
// ─── Sub-agents ───────────────────────────────────────────────────────────────
|
|
607
|
+
function collectSubAgents(subagentsDir) {
|
|
608
|
+
const subagents = [];
|
|
609
|
+
if (!fs.existsSync(subagentsDir))
|
|
610
|
+
return subagents;
|
|
611
|
+
let entries;
|
|
612
|
+
try {
|
|
613
|
+
entries = fs.readdirSync(subagentsDir, { withFileTypes: true });
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return subagents;
|
|
617
|
+
}
|
|
618
|
+
for (const entry of entries) {
|
|
619
|
+
if (entry.isSymbolicLink())
|
|
620
|
+
continue;
|
|
621
|
+
if (!entry.name.endsWith('.meta.json'))
|
|
622
|
+
continue;
|
|
623
|
+
const metaPath = path.join(subagentsDir, entry.name);
|
|
624
|
+
let meta;
|
|
625
|
+
try {
|
|
626
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const description = meta['description'] || 'agent';
|
|
632
|
+
const jsonlName = entry.name.replace('.meta.json', '.jsonl');
|
|
633
|
+
const jsonlPath = path.join(subagentsDir, jsonlName);
|
|
634
|
+
let tokens = 0;
|
|
635
|
+
let mtimeMs = 0;
|
|
636
|
+
if (fs.existsSync(jsonlPath)) {
|
|
637
|
+
const stat = safeStatSync(jsonlPath);
|
|
638
|
+
mtimeMs = stat?.mtimeMs ?? 0;
|
|
639
|
+
// Parse transcript for token total
|
|
640
|
+
const identity = stat ? [stat.size, stat.mtimeMs] : [0, 0];
|
|
641
|
+
const tr = parseTranscript(jsonlPath, 0, identity);
|
|
642
|
+
tokens = tr.totalInput + tr.totalOutput + tr.totalCacheRead + tr.totalCacheCreate;
|
|
643
|
+
}
|
|
644
|
+
const ageSecs = (Date.now() - mtimeMs) / 1000;
|
|
645
|
+
subagents.push({
|
|
646
|
+
name: description.slice(0, 30),
|
|
647
|
+
status: ageSecs < 30 ? 'working' : 'done',
|
|
648
|
+
tokens,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return subagents;
|
|
652
|
+
}
|
|
653
|
+
// ─── Memory status ────────────────────────────────────────────────────────────
|
|
654
|
+
function collectMemoryStatus(memoryDir) {
|
|
655
|
+
let fileCount = 0;
|
|
656
|
+
let lineCount = 0;
|
|
657
|
+
if (!fs.existsSync(memoryDir))
|
|
658
|
+
return [fileCount, lineCount];
|
|
659
|
+
try {
|
|
660
|
+
const entries = fs.readdirSync(memoryDir, { withFileTypes: true });
|
|
661
|
+
fileCount = entries.filter((e) => e.isFile()).length;
|
|
662
|
+
}
|
|
663
|
+
catch { /* ignore */ }
|
|
664
|
+
const memMd = path.join(memoryDir, 'MEMORY.md');
|
|
665
|
+
try {
|
|
666
|
+
const content = fs.readFileSync(memMd, 'utf8');
|
|
667
|
+
lineCount = content.split('\n').length;
|
|
668
|
+
}
|
|
669
|
+
catch { /* ignore */ }
|
|
670
|
+
return [fileCount, lineCount];
|
|
671
|
+
}
|
|
672
|
+
// ─── Effort level ─────────────────────────────────────────────────────────────
|
|
673
|
+
/**
|
|
674
|
+
* Read the effort level from `~/.claude/settings.json` or the project's
|
|
675
|
+
* `.claude/settings.json`. Returns empty string when not configured.
|
|
676
|
+
*/
|
|
677
|
+
function readEffortLevel(cwd) {
|
|
678
|
+
const candidates = [
|
|
679
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
680
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
681
|
+
];
|
|
682
|
+
for (const p of candidates) {
|
|
683
|
+
try {
|
|
684
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
685
|
+
const level = obj['effortLevel'];
|
|
686
|
+
if (level)
|
|
687
|
+
return level;
|
|
688
|
+
}
|
|
689
|
+
catch { /* continue */ }
|
|
690
|
+
}
|
|
691
|
+
return '';
|
|
692
|
+
}
|
|
693
|
+
// ─── Descendant collection ────────────────────────────────────────────────────
|
|
694
|
+
function collectDescendants(pid, childrenMap, processInfo, ports) {
|
|
695
|
+
const result = [];
|
|
696
|
+
const stack = [...(childrenMap.get(pid) ?? [])];
|
|
697
|
+
const visited = new Set();
|
|
698
|
+
while (stack.length > 0) {
|
|
699
|
+
const cpid = stack.pop();
|
|
700
|
+
if (visited.has(cpid))
|
|
701
|
+
continue;
|
|
702
|
+
visited.add(cpid);
|
|
703
|
+
const proc = processInfo.get(cpid);
|
|
704
|
+
if (proc) {
|
|
705
|
+
const port = ports.get(cpid)?.[0];
|
|
706
|
+
result.push({ pid: cpid, command: proc.command, memKb: proc.rssKb, port });
|
|
707
|
+
}
|
|
708
|
+
stack.push(...(childrenMap.get(cpid) ?? []));
|
|
709
|
+
}
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
713
|
+
function safeStatSync(filePath) {
|
|
714
|
+
try {
|
|
715
|
+
return fs.statSync(filePath);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function isSymlink(filePath) {
|
|
722
|
+
try {
|
|
723
|
+
return fs.lstatSync(filePath).isSymbolicLink();
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function parseIso(ts) {
|
|
730
|
+
if (!ts)
|
|
731
|
+
return 0;
|
|
732
|
+
const ms = new Date(ts).getTime();
|
|
733
|
+
return isNaN(ms) ? 0 : ms;
|
|
734
|
+
}
|
|
735
|
+
function extractToolArg(toolName, input) {
|
|
736
|
+
const filePath = input['file_path'] || input['path'];
|
|
737
|
+
if (filePath)
|
|
738
|
+
return (0, secrets_1.redactSecrets)(filePath.split('/').pop() ?? filePath).slice(0, 120);
|
|
739
|
+
const cmd = input['command'] || input['cmd'];
|
|
740
|
+
if (cmd)
|
|
741
|
+
return (0, secrets_1.redactSecrets)(cmd).slice(0, 120);
|
|
742
|
+
return '';
|
|
743
|
+
}
|
|
744
|
+
function toolNameToFileOp(name) {
|
|
745
|
+
const lower = name.toLowerCase();
|
|
746
|
+
if (lower === 'read' || lower === 'read_file')
|
|
747
|
+
return 'Read';
|
|
748
|
+
if (lower === 'write' || lower === 'write_file')
|
|
749
|
+
return 'Write';
|
|
750
|
+
if (lower === 'edit' || lower === 'edit_file')
|
|
751
|
+
return 'Edit';
|
|
752
|
+
return null;
|
|
753
|
+
}
|