@towles/tool 0.0.120 → 0.0.121
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/node_modules/@towles/shared/package.json +15 -0
- package/node_modules/@towles/shared/src/date-utils.test.ts +97 -0
- package/node_modules/@towles/shared/src/date-utils.ts +54 -0
- package/node_modules/@towles/shared/src/fs.ts +19 -0
- package/node_modules/@towles/shared/src/git/branch-name.test.ts +83 -0
- package/node_modules/@towles/shared/src/git/branch-name.ts +10 -0
- package/node_modules/@towles/shared/src/git/exec.ts +41 -0
- package/node_modules/@towles/shared/src/git/gh-cli-wrapper.test.ts +55 -0
- package/node_modules/@towles/shared/src/git/gh-cli-wrapper.ts +74 -0
- package/node_modules/@towles/shared/src/index.ts +8 -0
- package/node_modules/@towles/shared/src/render.test.ts +71 -0
- package/node_modules/@towles/shared/src/render.ts +36 -0
- package/package.json +4 -1
- package/packages/agentboard/apps/tui/src/components/DetailPanel.tsx +62 -1
- package/packages/agentboard/packages/runtime/package.json +1 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +38 -1
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +106 -31
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.test.ts +74 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.ts +57 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.test.ts +148 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.ts +78 -0
- package/packages/agentboard/packages/runtime/src/contracts/agent.ts +17 -0
- package/packages/agentboard/packages/runtime/src/server/pane-scanner.ts +10 -4
- package/packages/shared/node_modules/consola/LICENSE +47 -0
- package/packages/shared/node_modules/consola/README.md +352 -0
- package/packages/shared/node_modules/consola/basic.d.ts +1 -0
- package/packages/shared/node_modules/consola/browser.d.ts +1 -0
- package/packages/shared/node_modules/consola/core.d.ts +1 -0
- package/packages/shared/node_modules/consola/dist/basic.cjs +32 -0
- package/packages/shared/node_modules/consola/dist/basic.d.cts +23 -0
- package/packages/shared/node_modules/consola/dist/basic.d.mts +21 -0
- package/packages/shared/node_modules/consola/dist/basic.d.ts +23 -0
- package/packages/shared/node_modules/consola/dist/basic.mjs +24 -0
- package/packages/shared/node_modules/consola/dist/browser.cjs +84 -0
- package/packages/shared/node_modules/consola/dist/browser.d.cts +23 -0
- package/packages/shared/node_modules/consola/dist/browser.d.mts +21 -0
- package/packages/shared/node_modules/consola/dist/browser.d.ts +23 -0
- package/packages/shared/node_modules/consola/dist/browser.mjs +76 -0
- package/packages/shared/node_modules/consola/dist/chunks/prompt.cjs +288 -0
- package/packages/shared/node_modules/consola/dist/chunks/prompt.mjs +280 -0
- package/packages/shared/node_modules/consola/dist/core.cjs +517 -0
- package/packages/shared/node_modules/consola/dist/core.d.cts +459 -0
- package/packages/shared/node_modules/consola/dist/core.d.mts +459 -0
- package/packages/shared/node_modules/consola/dist/core.d.ts +459 -0
- package/packages/shared/node_modules/consola/dist/core.mjs +512 -0
- package/packages/shared/node_modules/consola/dist/index.cjs +663 -0
- package/packages/shared/node_modules/consola/dist/index.d.cts +24 -0
- package/packages/shared/node_modules/consola/dist/index.d.mts +22 -0
- package/packages/shared/node_modules/consola/dist/index.d.ts +24 -0
- package/packages/shared/node_modules/consola/dist/index.mjs +651 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DCGIlDNP.cjs +75 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DRwqZj3T.mjs +72 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DXBYu-KD.mjs +288 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DwRq1yyg.cjs +312 -0
- package/packages/shared/node_modules/consola/dist/utils.cjs +64 -0
- package/packages/shared/node_modules/consola/dist/utils.d.cts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.d.mts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.d.ts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.mjs +54 -0
- package/packages/shared/node_modules/consola/lib/index.cjs +10 -0
- package/packages/shared/node_modules/consola/package.json +136 -0
- package/packages/shared/node_modules/consola/utils.d.ts +1 -0
- package/packages/shared/tsconfig.json +0 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { determineStatus } from "./claude-code";
|
|
2
|
+
import { determineStatus, summaryToDetails } from "./claude-code";
|
|
3
|
+
import type { ClaudeUsageSummary } from "./claude-usage";
|
|
3
4
|
|
|
4
5
|
describe("determineStatus", () => {
|
|
5
6
|
it("returns null when no message", () => {
|
|
@@ -61,3 +62,39 @@ describe("determineStatus", () => {
|
|
|
61
62
|
).toBeNull();
|
|
62
63
|
});
|
|
63
64
|
});
|
|
65
|
+
|
|
66
|
+
describe("summaryToDetails", () => {
|
|
67
|
+
it("maps all fields including cache", () => {
|
|
68
|
+
const s: ClaudeUsageSummary = {
|
|
69
|
+
model: "claude-opus-4-6",
|
|
70
|
+
contextUsed: 1000,
|
|
71
|
+
contextMax: 200_000,
|
|
72
|
+
cacheTtlMs: 300_000,
|
|
73
|
+
cacheExpiresAt: 1_700_000_000_000,
|
|
74
|
+
lastActivityAt: 1_699_999_700_000,
|
|
75
|
+
};
|
|
76
|
+
expect(summaryToDetails(s)).toEqual({
|
|
77
|
+
model: "claude-opus-4-6",
|
|
78
|
+
contextUsed: 1000,
|
|
79
|
+
contextMax: 200_000,
|
|
80
|
+
cacheTtlMs: 300_000,
|
|
81
|
+
cacheExpiresAt: 1_700_000_000_000,
|
|
82
|
+
lastActivityAt: 1_699_999_700_000,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("omits cache fields when null (converts to undefined)", () => {
|
|
87
|
+
const s: ClaudeUsageSummary = {
|
|
88
|
+
model: "claude-haiku-4-5",
|
|
89
|
+
contextUsed: 500,
|
|
90
|
+
contextMax: 200_000,
|
|
91
|
+
cacheTtlMs: null,
|
|
92
|
+
cacheExpiresAt: null,
|
|
93
|
+
lastActivityAt: 1_700_000_000_000,
|
|
94
|
+
};
|
|
95
|
+
const details = summaryToDetails(s);
|
|
96
|
+
expect(details.cacheTtlMs).toBeUndefined();
|
|
97
|
+
expect(details.cacheExpiresAt).toBeUndefined();
|
|
98
|
+
expect(details.model).toBe("claude-haiku-4-5");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -19,6 +19,10 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import type { AgentStatus } from "../../contracts/agent";
|
|
20
20
|
import type { AgentWatcher, AgentWatcherContext } from "../../contracts/agent-watcher";
|
|
21
21
|
import { JOURNAL_IDLE_TIMEOUT_MS } from "../../shared";
|
|
22
|
+
import { createClaudePidLookup } from "./claude-pid";
|
|
23
|
+
import type { ClaudePidLookup } from "./claude-pid";
|
|
24
|
+
import { extractUsageSummary } from "./claude-usage";
|
|
25
|
+
import type { ClaudeUsageSummary } from "./claude-usage";
|
|
22
26
|
|
|
23
27
|
// --- Types ---
|
|
24
28
|
|
|
@@ -41,6 +45,7 @@ interface SessionState {
|
|
|
41
45
|
fileSize: number;
|
|
42
46
|
threadName?: string;
|
|
43
47
|
projectDir?: string;
|
|
48
|
+
usage?: ClaudeUsageSummary;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
const POLL_MS = 2000;
|
|
@@ -98,6 +103,21 @@ function decodeProjectDir(encoded: string): string {
|
|
|
98
103
|
return encoded.replace(/-/g, "/");
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
// --- Usage summary → AgentEventDetails ---
|
|
107
|
+
|
|
108
|
+
export function summaryToDetails(
|
|
109
|
+
s: ClaudeUsageSummary,
|
|
110
|
+
): import("../../contracts/agent").AgentEventDetails {
|
|
111
|
+
return {
|
|
112
|
+
model: s.model,
|
|
113
|
+
contextUsed: s.contextUsed,
|
|
114
|
+
contextMax: s.contextMax,
|
|
115
|
+
cacheExpiresAt: s.cacheExpiresAt ?? undefined,
|
|
116
|
+
cacheTtlMs: s.cacheTtlMs ?? undefined,
|
|
117
|
+
lastActivityAt: s.lastActivityAt,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
101
121
|
// --- Watcher implementation ---
|
|
102
122
|
|
|
103
123
|
export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
@@ -110,9 +130,11 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
110
130
|
private projectsDir: string;
|
|
111
131
|
private scanning = false;
|
|
112
132
|
private seeded = false;
|
|
133
|
+
private pidLookup: ClaudePidLookup;
|
|
113
134
|
|
|
114
|
-
constructor() {
|
|
135
|
+
constructor(pidLookup: ClaudePidLookup = createClaudePidLookup()) {
|
|
115
136
|
this.projectsDir = join(homedir(), ".claude", "projects");
|
|
137
|
+
this.pidLookup = pidLookup;
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
start(ctx: AgentWatcherContext): void {
|
|
@@ -150,26 +172,36 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
150
172
|
const prev = this.sessions.get(threadId);
|
|
151
173
|
|
|
152
174
|
if (prev && size === prev.fileSize) {
|
|
153
|
-
// Post-seed: if
|
|
154
|
-
// in >2min, the process likely exited — downgrade to "idle".
|
|
175
|
+
// Post-seed: check if the process is actually still alive.
|
|
155
176
|
if (this.seeded && prev.status === "running") {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
const pid = await this.pidLookup.pidForThread(threadId);
|
|
178
|
+
const processGone = pid != null && !this.pidLookup.isAlive(pid);
|
|
179
|
+
|
|
180
|
+
let becomeIdle = false;
|
|
181
|
+
if (processGone) {
|
|
182
|
+
becomeIdle = true;
|
|
183
|
+
} else {
|
|
184
|
+
try {
|
|
185
|
+
const mtime = (await stat(filePath)).mtimeMs;
|
|
186
|
+
if (Date.now() - mtime > JOURNAL_IDLE_TIMEOUT_MS) becomeIdle = true;
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (becomeIdle) {
|
|
191
|
+
prev.status = "idle";
|
|
192
|
+
const session = prev.projectDir ? this.ctx?.resolveSession(prev.projectDir) : undefined;
|
|
193
|
+
if (session) {
|
|
194
|
+
this.ctx?.emit({
|
|
195
|
+
agent: "claude-code",
|
|
196
|
+
session,
|
|
197
|
+
status: "idle",
|
|
198
|
+
ts: Date.now(),
|
|
199
|
+
threadId,
|
|
200
|
+
threadName: prev.threadName,
|
|
201
|
+
details: prev.usage ? summaryToDetails(prev.usage) : undefined,
|
|
202
|
+
});
|
|
171
203
|
}
|
|
172
|
-
}
|
|
204
|
+
}
|
|
173
205
|
}
|
|
174
206
|
return;
|
|
175
207
|
}
|
|
@@ -184,16 +216,19 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
184
216
|
}
|
|
185
217
|
|
|
186
218
|
const lines = text.split("\n").filter(Boolean);
|
|
187
|
-
let latestStatus: AgentStatus = "idle";
|
|
188
|
-
let threadName: string | undefined;
|
|
189
219
|
|
|
220
|
+
const parsed: JournalEntry[] = [];
|
|
190
221
|
for (const line of lines) {
|
|
191
|
-
let entry: JournalEntry;
|
|
192
222
|
try {
|
|
193
|
-
|
|
223
|
+
parsed.push(JSON.parse(line) as JournalEntry);
|
|
194
224
|
} catch {
|
|
195
225
|
continue;
|
|
196
226
|
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let latestStatus: AgentStatus = "idle";
|
|
230
|
+
let threadName: string | undefined;
|
|
231
|
+
for (const entry of parsed) {
|
|
197
232
|
if (!threadName) {
|
|
198
233
|
const name = extractThreadName(entry);
|
|
199
234
|
if (name) threadName = name;
|
|
@@ -201,6 +236,8 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
201
236
|
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
202
237
|
}
|
|
203
238
|
|
|
239
|
+
const usage = extractUsageSummary(parsed) ?? undefined;
|
|
240
|
+
|
|
204
241
|
// If "running" but journal file is stale, the process likely exited
|
|
205
242
|
if (latestStatus === "running") {
|
|
206
243
|
try {
|
|
@@ -209,7 +246,13 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
209
246
|
} catch {}
|
|
210
247
|
}
|
|
211
248
|
|
|
212
|
-
this.sessions.set(threadId, {
|
|
249
|
+
this.sessions.set(threadId, {
|
|
250
|
+
status: latestStatus,
|
|
251
|
+
fileSize: size,
|
|
252
|
+
threadName,
|
|
253
|
+
projectDir,
|
|
254
|
+
usage,
|
|
255
|
+
});
|
|
213
256
|
return;
|
|
214
257
|
}
|
|
215
258
|
|
|
@@ -225,27 +268,45 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
225
268
|
}
|
|
226
269
|
|
|
227
270
|
const lines = text.split("\n").filter(Boolean);
|
|
228
|
-
let latestStatus: AgentStatus = prev?.status ?? "idle";
|
|
229
|
-
let threadName = prev?.threadName;
|
|
230
271
|
|
|
272
|
+
const parsed: JournalEntry[] = [];
|
|
231
273
|
for (const line of lines) {
|
|
232
|
-
let entry: JournalEntry;
|
|
233
274
|
try {
|
|
234
|
-
|
|
275
|
+
parsed.push(JSON.parse(line) as JournalEntry);
|
|
235
276
|
} catch {
|
|
236
277
|
continue;
|
|
237
278
|
}
|
|
279
|
+
}
|
|
238
280
|
|
|
281
|
+
let latestStatus: AgentStatus = prev?.status ?? "idle";
|
|
282
|
+
let threadName = prev?.threadName;
|
|
283
|
+
for (const entry of parsed) {
|
|
239
284
|
if (!threadName) {
|
|
240
285
|
const name = extractThreadName(entry);
|
|
241
286
|
if (name) threadName = name;
|
|
242
287
|
}
|
|
243
|
-
|
|
244
288
|
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
245
289
|
}
|
|
246
290
|
|
|
291
|
+
// Merge new usage summary onto the previous one (incremental reads may not include the latest assistant turn)
|
|
292
|
+
const newUsage = extractUsageSummary(parsed);
|
|
293
|
+
const usage = newUsage ?? prev?.usage;
|
|
294
|
+
|
|
295
|
+
if (latestStatus === "running") {
|
|
296
|
+
const pid = await this.pidLookup.pidForThread(threadId);
|
|
297
|
+
if (pid != null && !this.pidLookup.isAlive(pid)) {
|
|
298
|
+
latestStatus = "idle";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
247
302
|
const prevStatus = prev?.status;
|
|
248
|
-
this.sessions.set(threadId, {
|
|
303
|
+
this.sessions.set(threadId, {
|
|
304
|
+
status: latestStatus,
|
|
305
|
+
fileSize: size,
|
|
306
|
+
threadName,
|
|
307
|
+
projectDir,
|
|
308
|
+
usage,
|
|
309
|
+
});
|
|
249
310
|
|
|
250
311
|
if (latestStatus !== prevStatus) {
|
|
251
312
|
const session = this.ctx.resolveSession(projectDir);
|
|
@@ -257,6 +318,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
257
318
|
ts: Date.now(),
|
|
258
319
|
threadId,
|
|
259
320
|
threadName,
|
|
321
|
+
details: usage ? summaryToDetails(usage) : undefined,
|
|
260
322
|
});
|
|
261
323
|
}
|
|
262
324
|
}
|
|
@@ -265,6 +327,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
265
327
|
private async scan(): Promise<void> {
|
|
266
328
|
if (this.scanning || !this.ctx) return;
|
|
267
329
|
this.scanning = true;
|
|
330
|
+
this.pidLookup.invalidate();
|
|
268
331
|
|
|
269
332
|
try {
|
|
270
333
|
let dirs: string[];
|
|
@@ -313,15 +376,27 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
313
376
|
// Emit seeded sessions with non-idle status (like amp watcher does)
|
|
314
377
|
for (const [threadId, state] of this.sessions) {
|
|
315
378
|
if (state.status === "idle" || !state.projectDir) continue;
|
|
379
|
+
|
|
380
|
+
let status = state.status;
|
|
381
|
+
if (status === "running") {
|
|
382
|
+
const pid = await this.pidLookup.pidForThread(threadId);
|
|
383
|
+
if (pid != null && !this.pidLookup.isAlive(pid)) {
|
|
384
|
+
status = "idle";
|
|
385
|
+
state.status = "idle";
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
316
390
|
const session = this.ctx?.resolveSession(state.projectDir);
|
|
317
391
|
if (!session) continue;
|
|
318
392
|
this.ctx?.emit({
|
|
319
393
|
agent: "claude-code",
|
|
320
394
|
session,
|
|
321
|
-
status
|
|
395
|
+
status,
|
|
322
396
|
ts: Date.now(),
|
|
323
397
|
threadId,
|
|
324
398
|
threadName: state.threadName,
|
|
399
|
+
details: state.usage ? summaryToDetails(state.usage) : undefined,
|
|
325
400
|
});
|
|
326
401
|
}
|
|
327
402
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createClaudePidLookup } from "./claude-pid";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
let sessionsDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(join(tmpdir(), "claude-pid-test-"));
|
|
12
|
+
sessionsDir = join(tmpDir, "sessions");
|
|
13
|
+
mkdirSync(sessionsDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function writeSession(pid: number, sessionId: string) {
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(sessionsDir, `${pid}.json`),
|
|
23
|
+
JSON.stringify({ pid, sessionId, cwd: "/tmp", startedAt: Date.now(), kind: "interactive" }),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("claude-pid lookup", () => {
|
|
28
|
+
it("finds PID by threadId", async () => {
|
|
29
|
+
writeSession(12345, "thread-a");
|
|
30
|
+
writeSession(67890, "thread-b");
|
|
31
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
32
|
+
expect(await lookup.pidForThread("thread-a")).toBe(12345);
|
|
33
|
+
expect(await lookup.pidForThread("thread-b")).toBe(67890);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null for unknown threadId", async () => {
|
|
37
|
+
writeSession(12345, "thread-a");
|
|
38
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
39
|
+
expect(await lookup.pidForThread("thread-missing")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when sessions dir is missing", async () => {
|
|
43
|
+
const lookup = createClaudePidLookup(join(tmpDir, "does-not-exist"));
|
|
44
|
+
expect(await lookup.pidForThread("thread-a")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("skips invalid session JSON files", async () => {
|
|
48
|
+
writeFileSync(join(sessionsDir, "bad.json"), "not json");
|
|
49
|
+
writeSession(12345, "thread-a");
|
|
50
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
51
|
+
expect(await lookup.pidForThread("thread-a")).toBe(12345);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("isAlive returns true for current process pid", () => {
|
|
55
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
56
|
+
expect(lookup.isAlive(process.pid)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("isAlive returns false for pid 999999999", () => {
|
|
60
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
61
|
+
expect(lookup.isAlive(999_999_999)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("invalidate clears the cache so new sessions are picked up", async () => {
|
|
65
|
+
const lookup = createClaudePidLookup(sessionsDir);
|
|
66
|
+
// Prime cache with empty result
|
|
67
|
+
expect(await lookup.pidForThread("thread-a")).toBeNull();
|
|
68
|
+
writeSession(12345, "thread-a");
|
|
69
|
+
// Still null because cache is stale
|
|
70
|
+
expect(await lookup.pidForThread("thread-a")).toBeNull();
|
|
71
|
+
lookup.invalidate();
|
|
72
|
+
expect(await lookup.pidForThread("thread-a")).toBe(12345);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ClaudePidLookup {
|
|
6
|
+
pidForThread(threadId: string): Promise<number | null>;
|
|
7
|
+
isAlive(pid: number): boolean;
|
|
8
|
+
invalidate(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SESSIONS_DIR = join(homedir(), ".claude", "sessions");
|
|
12
|
+
|
|
13
|
+
export function createClaudePidLookup(sessionsDir: string = DEFAULT_SESSIONS_DIR): ClaudePidLookup {
|
|
14
|
+
let cache: Map<string, number> | null = null;
|
|
15
|
+
|
|
16
|
+
async function loadCache(): Promise<Map<string, number>> {
|
|
17
|
+
const map = new Map<string, number>();
|
|
18
|
+
let files: string[];
|
|
19
|
+
try {
|
|
20
|
+
files = await readdir(sessionsDir);
|
|
21
|
+
} catch {
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
if (!file.endsWith(".json")) continue;
|
|
27
|
+
try {
|
|
28
|
+
const text = await readFile(join(sessionsDir, file), "utf-8");
|
|
29
|
+
const data = JSON.parse(text) as { pid?: number; sessionId?: string };
|
|
30
|
+
if (typeof data.pid === "number" && typeof data.sessionId === "string") {
|
|
31
|
+
map.set(data.sessionId, data.pid);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// skip unreadable / invalid JSON
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async pidForThread(threadId) {
|
|
42
|
+
if (!cache) cache = await loadCache();
|
|
43
|
+
return cache.get(threadId) ?? null;
|
|
44
|
+
},
|
|
45
|
+
isAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
invalidate() {
|
|
54
|
+
cache = null;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { contextMax, contextUsed, cacheTtlMs, extractUsageSummary } from "./claude-usage";
|
|
3
|
+
|
|
4
|
+
describe("contextMax", () => {
|
|
5
|
+
it("returns 1M for sonnet with [1m] suffix", () => {
|
|
6
|
+
expect(contextMax("claude-sonnet-4-5[1m]")).toBe(1_000_000);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns 200K for opus", () => {
|
|
10
|
+
expect(contextMax("claude-opus-4-6")).toBe(200_000);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns 200K for haiku", () => {
|
|
14
|
+
expect(contextMax("claude-haiku-4-5")).toBe(200_000);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns 200K for empty or unknown model", () => {
|
|
18
|
+
expect(contextMax("")).toBe(200_000);
|
|
19
|
+
expect(contextMax("gpt-4")).toBe(200_000);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("contextUsed", () => {
|
|
24
|
+
it("sums input + output + cache_read + cache_creation", () => {
|
|
25
|
+
expect(
|
|
26
|
+
contextUsed({
|
|
27
|
+
input_tokens: 100,
|
|
28
|
+
output_tokens: 50,
|
|
29
|
+
cache_read_input_tokens: 1000,
|
|
30
|
+
cache_creation_input_tokens: 200,
|
|
31
|
+
}),
|
|
32
|
+
).toBe(1350);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats missing fields as 0", () => {
|
|
36
|
+
expect(contextUsed({ input_tokens: 10 })).toBe(10);
|
|
37
|
+
expect(contextUsed({})).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("treats null cache fields as 0", () => {
|
|
41
|
+
expect(
|
|
42
|
+
contextUsed({
|
|
43
|
+
input_tokens: 10,
|
|
44
|
+
output_tokens: 5,
|
|
45
|
+
cache_read_input_tokens: null,
|
|
46
|
+
cache_creation_input_tokens: null,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(15);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("cacheTtlMs", () => {
|
|
53
|
+
it("returns null when no cache activity", () => {
|
|
54
|
+
expect(cacheTtlMs({ input_tokens: 100, output_tokens: 50 })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns 1h when ephemeral_1h_input_tokens > 0", () => {
|
|
58
|
+
expect(
|
|
59
|
+
cacheTtlMs({
|
|
60
|
+
cache_creation: { ephemeral_1h_input_tokens: 100, ephemeral_5m_input_tokens: 0 },
|
|
61
|
+
}),
|
|
62
|
+
).toBe(60 * 60 * 1000);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns 5m when only 5m tokens", () => {
|
|
66
|
+
expect(
|
|
67
|
+
cacheTtlMs({
|
|
68
|
+
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 100 },
|
|
69
|
+
}),
|
|
70
|
+
).toBe(5 * 60 * 1000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns 5m when only cache_read (no creation this turn)", () => {
|
|
74
|
+
expect(cacheTtlMs({ cache_read_input_tokens: 500 })).toBe(5 * 60 * 1000);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("prefers 1h when both present", () => {
|
|
78
|
+
expect(
|
|
79
|
+
cacheTtlMs({
|
|
80
|
+
cache_creation: { ephemeral_1h_input_tokens: 50, ephemeral_5m_input_tokens: 100 },
|
|
81
|
+
}),
|
|
82
|
+
).toBe(60 * 60 * 1000);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("extractUsageSummary", () => {
|
|
87
|
+
const assistantEntry = (timestamp: string, model: string, usage: unknown) => ({
|
|
88
|
+
type: "assistant",
|
|
89
|
+
timestamp,
|
|
90
|
+
message: { role: "assistant", model, usage },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns null for empty entries", () => {
|
|
94
|
+
expect(extractUsageSummary([])).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null when no assistant entry has usage", () => {
|
|
98
|
+
expect(
|
|
99
|
+
extractUsageSummary([
|
|
100
|
+
{
|
|
101
|
+
type: "user",
|
|
102
|
+
timestamp: "2026-04-12T00:00:00Z",
|
|
103
|
+
message: { role: "user", content: "hi" },
|
|
104
|
+
} as never,
|
|
105
|
+
]),
|
|
106
|
+
).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("extracts fields from most recent assistant entry with usage", () => {
|
|
110
|
+
const entries = [
|
|
111
|
+
assistantEntry("2026-04-12T00:00:00Z", "claude-opus-4-6", {
|
|
112
|
+
input_tokens: 10,
|
|
113
|
+
output_tokens: 5,
|
|
114
|
+
cache_read_input_tokens: 0,
|
|
115
|
+
cache_creation_input_tokens: 0,
|
|
116
|
+
}),
|
|
117
|
+
assistantEntry("2026-04-12T00:05:00Z", "claude-opus-4-6", {
|
|
118
|
+
input_tokens: 1,
|
|
119
|
+
output_tokens: 249,
|
|
120
|
+
cache_read_input_tokens: 50612,
|
|
121
|
+
cache_creation_input_tokens: 2297,
|
|
122
|
+
cache_creation: { ephemeral_1h_input_tokens: 2297, ephemeral_5m_input_tokens: 0 },
|
|
123
|
+
}),
|
|
124
|
+
] as never[];
|
|
125
|
+
|
|
126
|
+
const result = extractUsageSummary(entries);
|
|
127
|
+
expect(result).not.toBeNull();
|
|
128
|
+
expect(result!.model).toBe("claude-opus-4-6");
|
|
129
|
+
expect(result!.contextUsed).toBe(53159);
|
|
130
|
+
expect(result!.contextMax).toBe(200_000);
|
|
131
|
+
expect(result!.cacheTtlMs).toBe(60 * 60 * 1000);
|
|
132
|
+
expect(result!.lastActivityAt).toBe(new Date("2026-04-12T00:05:00Z").getTime());
|
|
133
|
+
expect(result!.cacheExpiresAt).toBe(result!.lastActivityAt + 60 * 60 * 1000);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("leaves cacheExpiresAt and cacheTtlMs null when no cache activity", () => {
|
|
137
|
+
const entries = [
|
|
138
|
+
assistantEntry("2026-04-12T00:00:00Z", "claude-opus-4-6", {
|
|
139
|
+
input_tokens: 100,
|
|
140
|
+
output_tokens: 50,
|
|
141
|
+
}),
|
|
142
|
+
] as never[];
|
|
143
|
+
|
|
144
|
+
const result = extractUsageSummary(entries);
|
|
145
|
+
expect(result!.cacheExpiresAt).toBeNull();
|
|
146
|
+
expect(result!.cacheTtlMs).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Usage } from "@anthropic-ai/sdk/resources/messages/messages";
|
|
2
|
+
|
|
3
|
+
type PartialUsage = Partial<Usage> & {
|
|
4
|
+
cache_creation?: {
|
|
5
|
+
ephemeral_5m_input_tokens?: number;
|
|
6
|
+
ephemeral_1h_input_tokens?: number;
|
|
7
|
+
} | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
interface AssistantLikeEntry {
|
|
11
|
+
type?: string;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
message?: {
|
|
14
|
+
role?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
usage?: PartialUsage | null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClaudeUsageSummary {
|
|
21
|
+
model: string;
|
|
22
|
+
contextUsed: number;
|
|
23
|
+
contextMax: number;
|
|
24
|
+
cacheTtlMs: number | null;
|
|
25
|
+
cacheExpiresAt: number | null;
|
|
26
|
+
lastActivityAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
30
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export function contextMax(model: string): number {
|
|
33
|
+
if (/\[1m\]$/i.test(model)) return 1_000_000;
|
|
34
|
+
return 200_000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function contextUsed(u: PartialUsage): number {
|
|
38
|
+
return (
|
|
39
|
+
(u.input_tokens ?? 0) +
|
|
40
|
+
(u.output_tokens ?? 0) +
|
|
41
|
+
(u.cache_read_input_tokens ?? 0) +
|
|
42
|
+
(u.cache_creation_input_tokens ?? 0)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cacheTtlMs(u: PartialUsage): number | null {
|
|
47
|
+
const c = u.cache_creation ?? null;
|
|
48
|
+
const h = c?.ephemeral_1h_input_tokens ?? 0;
|
|
49
|
+
const m = c?.ephemeral_5m_input_tokens ?? 0;
|
|
50
|
+
const reads = u.cache_read_input_tokens ?? 0;
|
|
51
|
+
if (h > 0) return ONE_HOUR_MS;
|
|
52
|
+
if (m > 0 || reads > 0) return FIVE_MIN_MS;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractUsageSummary(entries: AssistantLikeEntry[]): ClaudeUsageSummary | null {
|
|
57
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
58
|
+
const e = entries[i]!;
|
|
59
|
+
if (e.message?.role !== "assistant") continue;
|
|
60
|
+
const usage = e.message.usage;
|
|
61
|
+
if (!usage) continue;
|
|
62
|
+
|
|
63
|
+
const model = e.message.model ?? "";
|
|
64
|
+
const ts = e.timestamp ? Date.parse(e.timestamp) : Number.NaN;
|
|
65
|
+
if (Number.isNaN(ts)) continue;
|
|
66
|
+
|
|
67
|
+
const ttl = cacheTtlMs(usage);
|
|
68
|
+
return {
|
|
69
|
+
model,
|
|
70
|
+
contextUsed: contextUsed(usage),
|
|
71
|
+
contextMax: contextMax(model),
|
|
72
|
+
cacheTtlMs: ttl,
|
|
73
|
+
cacheExpiresAt: ttl === null ? null : ts + ttl,
|
|
74
|
+
lastActivityAt: ts,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -7,6 +7,21 @@ export type AgentStatus =
|
|
|
7
7
|
| "question"
|
|
8
8
|
| "interrupted";
|
|
9
9
|
|
|
10
|
+
export interface AgentEventDetails {
|
|
11
|
+
/** Model name from most recent assistant turn (e.g. "claude-opus-4-6") */
|
|
12
|
+
model?: string;
|
|
13
|
+
/** Total tokens consumed in the most recent turn (input + output + cache) */
|
|
14
|
+
contextUsed?: number;
|
|
15
|
+
/** Inferred context window size for the model (200K or 1M) */
|
|
16
|
+
contextMax?: number;
|
|
17
|
+
/** Epoch ms when the prompt cache expires; undefined = no cache active */
|
|
18
|
+
cacheExpiresAt?: number;
|
|
19
|
+
/** Cache TTL type: 300_000 (5m) or 3_600_000 (1h) */
|
|
20
|
+
cacheTtlMs?: number;
|
|
21
|
+
/** Epoch ms of the most recent assistant entry in the journal */
|
|
22
|
+
lastActivityAt?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
export interface AgentEvent {
|
|
11
26
|
agent: string;
|
|
12
27
|
session: string;
|
|
@@ -18,6 +33,8 @@ export interface AgentEvent {
|
|
|
18
33
|
unseen?: boolean;
|
|
19
34
|
/** Set by pane scanner — the tmux pane ID where this agent was detected */
|
|
20
35
|
paneId?: string;
|
|
36
|
+
/** Optional per-agent live details. Currently populated only by the claude-code watcher. */
|
|
37
|
+
details?: AgentEventDetails;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
export const TERMINAL_STATUSES = new Set<AgentStatus>(["done", "error", "interrupted"]);
|