@towles/tool 0.0.119 → 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 +108 -32
- 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/src/commands/auto-claude/run-claude.test.ts +1 -1
- package/src/commands/auto-claude/stream-parser.test.ts +2 -5
- package/src/commands/auto-claude/stream-parser.ts +26 -5
- package/src/commands/graph/analyzer.test.ts +38 -21
- package/src/commands/graph/tools.ts +1 -1
- package/src/commands/graph/types.ts +4 -13
- package/src/commands/graph.test.ts +14 -1
- 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,12 +19,17 @@ 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
|
|
|
25
29
|
interface ContentItem {
|
|
26
30
|
type?: string;
|
|
27
31
|
text?: string;
|
|
32
|
+
name?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
interface JournalEntry {
|
|
@@ -40,6 +45,7 @@ interface SessionState {
|
|
|
40
45
|
fileSize: number;
|
|
41
46
|
threadName?: string;
|
|
42
47
|
projectDir?: string;
|
|
48
|
+
usage?: ClaudeUsageSummary;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
const POLL_MS = 2000;
|
|
@@ -61,7 +67,7 @@ export function determineStatus(entry: JournalEntry): AgentStatus | null {
|
|
|
61
67
|
if (msg.role === "assistant") {
|
|
62
68
|
const toolUses = items.filter((c) => c.type === "tool_use");
|
|
63
69
|
if (toolUses.length === 0) return "done";
|
|
64
|
-
const allAsking = toolUses.every((c) =>
|
|
70
|
+
const allAsking = toolUses.every((c) => c.name === "AskUserQuestion");
|
|
65
71
|
return allAsking ? "question" : "running";
|
|
66
72
|
}
|
|
67
73
|
|
|
@@ -97,6 +103,21 @@ function decodeProjectDir(encoded: string): string {
|
|
|
97
103
|
return encoded.replace(/-/g, "/");
|
|
98
104
|
}
|
|
99
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
|
+
|
|
100
121
|
// --- Watcher implementation ---
|
|
101
122
|
|
|
102
123
|
export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
@@ -109,9 +130,11 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
109
130
|
private projectsDir: string;
|
|
110
131
|
private scanning = false;
|
|
111
132
|
private seeded = false;
|
|
133
|
+
private pidLookup: ClaudePidLookup;
|
|
112
134
|
|
|
113
|
-
constructor() {
|
|
135
|
+
constructor(pidLookup: ClaudePidLookup = createClaudePidLookup()) {
|
|
114
136
|
this.projectsDir = join(homedir(), ".claude", "projects");
|
|
137
|
+
this.pidLookup = pidLookup;
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
start(ctx: AgentWatcherContext): void {
|
|
@@ -149,26 +172,36 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
149
172
|
const prev = this.sessions.get(threadId);
|
|
150
173
|
|
|
151
174
|
if (prev && size === prev.fileSize) {
|
|
152
|
-
// Post-seed: if
|
|
153
|
-
// in >2min, the process likely exited — downgrade to "idle".
|
|
175
|
+
// Post-seed: check if the process is actually still alive.
|
|
154
176
|
if (this.seeded && prev.status === "running") {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
});
|
|
170
203
|
}
|
|
171
|
-
}
|
|
204
|
+
}
|
|
172
205
|
}
|
|
173
206
|
return;
|
|
174
207
|
}
|
|
@@ -183,16 +216,19 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
183
216
|
}
|
|
184
217
|
|
|
185
218
|
const lines = text.split("\n").filter(Boolean);
|
|
186
|
-
let latestStatus: AgentStatus = "idle";
|
|
187
|
-
let threadName: string | undefined;
|
|
188
219
|
|
|
220
|
+
const parsed: JournalEntry[] = [];
|
|
189
221
|
for (const line of lines) {
|
|
190
|
-
let entry: JournalEntry;
|
|
191
222
|
try {
|
|
192
|
-
|
|
223
|
+
parsed.push(JSON.parse(line) as JournalEntry);
|
|
193
224
|
} catch {
|
|
194
225
|
continue;
|
|
195
226
|
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let latestStatus: AgentStatus = "idle";
|
|
230
|
+
let threadName: string | undefined;
|
|
231
|
+
for (const entry of parsed) {
|
|
196
232
|
if (!threadName) {
|
|
197
233
|
const name = extractThreadName(entry);
|
|
198
234
|
if (name) threadName = name;
|
|
@@ -200,6 +236,8 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
200
236
|
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
201
237
|
}
|
|
202
238
|
|
|
239
|
+
const usage = extractUsageSummary(parsed) ?? undefined;
|
|
240
|
+
|
|
203
241
|
// If "running" but journal file is stale, the process likely exited
|
|
204
242
|
if (latestStatus === "running") {
|
|
205
243
|
try {
|
|
@@ -208,7 +246,13 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
208
246
|
} catch {}
|
|
209
247
|
}
|
|
210
248
|
|
|
211
|
-
this.sessions.set(threadId, {
|
|
249
|
+
this.sessions.set(threadId, {
|
|
250
|
+
status: latestStatus,
|
|
251
|
+
fileSize: size,
|
|
252
|
+
threadName,
|
|
253
|
+
projectDir,
|
|
254
|
+
usage,
|
|
255
|
+
});
|
|
212
256
|
return;
|
|
213
257
|
}
|
|
214
258
|
|
|
@@ -224,27 +268,45 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
224
268
|
}
|
|
225
269
|
|
|
226
270
|
const lines = text.split("\n").filter(Boolean);
|
|
227
|
-
let latestStatus: AgentStatus = prev?.status ?? "idle";
|
|
228
|
-
let threadName = prev?.threadName;
|
|
229
271
|
|
|
272
|
+
const parsed: JournalEntry[] = [];
|
|
230
273
|
for (const line of lines) {
|
|
231
|
-
let entry: JournalEntry;
|
|
232
274
|
try {
|
|
233
|
-
|
|
275
|
+
parsed.push(JSON.parse(line) as JournalEntry);
|
|
234
276
|
} catch {
|
|
235
277
|
continue;
|
|
236
278
|
}
|
|
279
|
+
}
|
|
237
280
|
|
|
281
|
+
let latestStatus: AgentStatus = prev?.status ?? "idle";
|
|
282
|
+
let threadName = prev?.threadName;
|
|
283
|
+
for (const entry of parsed) {
|
|
238
284
|
if (!threadName) {
|
|
239
285
|
const name = extractThreadName(entry);
|
|
240
286
|
if (name) threadName = name;
|
|
241
287
|
}
|
|
242
|
-
|
|
243
288
|
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
244
289
|
}
|
|
245
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
|
+
|
|
246
302
|
const prevStatus = prev?.status;
|
|
247
|
-
this.sessions.set(threadId, {
|
|
303
|
+
this.sessions.set(threadId, {
|
|
304
|
+
status: latestStatus,
|
|
305
|
+
fileSize: size,
|
|
306
|
+
threadName,
|
|
307
|
+
projectDir,
|
|
308
|
+
usage,
|
|
309
|
+
});
|
|
248
310
|
|
|
249
311
|
if (latestStatus !== prevStatus) {
|
|
250
312
|
const session = this.ctx.resolveSession(projectDir);
|
|
@@ -256,6 +318,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
256
318
|
ts: Date.now(),
|
|
257
319
|
threadId,
|
|
258
320
|
threadName,
|
|
321
|
+
details: usage ? summaryToDetails(usage) : undefined,
|
|
259
322
|
});
|
|
260
323
|
}
|
|
261
324
|
}
|
|
@@ -264,6 +327,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
264
327
|
private async scan(): Promise<void> {
|
|
265
328
|
if (this.scanning || !this.ctx) return;
|
|
266
329
|
this.scanning = true;
|
|
330
|
+
this.pidLookup.invalidate();
|
|
267
331
|
|
|
268
332
|
try {
|
|
269
333
|
let dirs: string[];
|
|
@@ -312,15 +376,27 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
312
376
|
// Emit seeded sessions with non-idle status (like amp watcher does)
|
|
313
377
|
for (const [threadId, state] of this.sessions) {
|
|
314
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
|
+
|
|
315
390
|
const session = this.ctx?.resolveSession(state.projectDir);
|
|
316
391
|
if (!session) continue;
|
|
317
392
|
this.ctx?.emit({
|
|
318
393
|
agent: "claude-code",
|
|
319
394
|
session,
|
|
320
|
-
status
|
|
395
|
+
status,
|
|
321
396
|
ts: Date.now(),
|
|
322
397
|
threadId,
|
|
323
398
|
threadName: state.threadName,
|
|
399
|
+
details: state.usage ? summaryToDetails(state.usage) : undefined,
|
|
324
400
|
});
|
|
325
401
|
}
|
|
326
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
|
+
}
|