@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.
Files changed (70) hide show
  1. package/node_modules/@towles/shared/package.json +15 -0
  2. package/node_modules/@towles/shared/src/date-utils.test.ts +97 -0
  3. package/node_modules/@towles/shared/src/date-utils.ts +54 -0
  4. package/node_modules/@towles/shared/src/fs.ts +19 -0
  5. package/node_modules/@towles/shared/src/git/branch-name.test.ts +83 -0
  6. package/node_modules/@towles/shared/src/git/branch-name.ts +10 -0
  7. package/node_modules/@towles/shared/src/git/exec.ts +41 -0
  8. package/node_modules/@towles/shared/src/git/gh-cli-wrapper.test.ts +55 -0
  9. package/node_modules/@towles/shared/src/git/gh-cli-wrapper.ts +74 -0
  10. package/node_modules/@towles/shared/src/index.ts +8 -0
  11. package/node_modules/@towles/shared/src/render.test.ts +71 -0
  12. package/node_modules/@towles/shared/src/render.ts +36 -0
  13. package/package.json +4 -1
  14. package/packages/agentboard/apps/tui/src/components/DetailPanel.tsx +62 -1
  15. package/packages/agentboard/packages/runtime/package.json +1 -0
  16. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +38 -1
  17. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +108 -32
  18. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.test.ts +74 -0
  19. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.ts +57 -0
  20. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.test.ts +148 -0
  21. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.ts +78 -0
  22. package/packages/agentboard/packages/runtime/src/contracts/agent.ts +17 -0
  23. package/packages/agentboard/packages/runtime/src/server/pane-scanner.ts +10 -4
  24. package/packages/shared/node_modules/consola/LICENSE +47 -0
  25. package/packages/shared/node_modules/consola/README.md +352 -0
  26. package/packages/shared/node_modules/consola/basic.d.ts +1 -0
  27. package/packages/shared/node_modules/consola/browser.d.ts +1 -0
  28. package/packages/shared/node_modules/consola/core.d.ts +1 -0
  29. package/packages/shared/node_modules/consola/dist/basic.cjs +32 -0
  30. package/packages/shared/node_modules/consola/dist/basic.d.cts +23 -0
  31. package/packages/shared/node_modules/consola/dist/basic.d.mts +21 -0
  32. package/packages/shared/node_modules/consola/dist/basic.d.ts +23 -0
  33. package/packages/shared/node_modules/consola/dist/basic.mjs +24 -0
  34. package/packages/shared/node_modules/consola/dist/browser.cjs +84 -0
  35. package/packages/shared/node_modules/consola/dist/browser.d.cts +23 -0
  36. package/packages/shared/node_modules/consola/dist/browser.d.mts +21 -0
  37. package/packages/shared/node_modules/consola/dist/browser.d.ts +23 -0
  38. package/packages/shared/node_modules/consola/dist/browser.mjs +76 -0
  39. package/packages/shared/node_modules/consola/dist/chunks/prompt.cjs +288 -0
  40. package/packages/shared/node_modules/consola/dist/chunks/prompt.mjs +280 -0
  41. package/packages/shared/node_modules/consola/dist/core.cjs +517 -0
  42. package/packages/shared/node_modules/consola/dist/core.d.cts +459 -0
  43. package/packages/shared/node_modules/consola/dist/core.d.mts +459 -0
  44. package/packages/shared/node_modules/consola/dist/core.d.ts +459 -0
  45. package/packages/shared/node_modules/consola/dist/core.mjs +512 -0
  46. package/packages/shared/node_modules/consola/dist/index.cjs +663 -0
  47. package/packages/shared/node_modules/consola/dist/index.d.cts +24 -0
  48. package/packages/shared/node_modules/consola/dist/index.d.mts +22 -0
  49. package/packages/shared/node_modules/consola/dist/index.d.ts +24 -0
  50. package/packages/shared/node_modules/consola/dist/index.mjs +651 -0
  51. package/packages/shared/node_modules/consola/dist/shared/consola.DCGIlDNP.cjs +75 -0
  52. package/packages/shared/node_modules/consola/dist/shared/consola.DRwqZj3T.mjs +72 -0
  53. package/packages/shared/node_modules/consola/dist/shared/consola.DXBYu-KD.mjs +288 -0
  54. package/packages/shared/node_modules/consola/dist/shared/consola.DwRq1yyg.cjs +312 -0
  55. package/packages/shared/node_modules/consola/dist/utils.cjs +64 -0
  56. package/packages/shared/node_modules/consola/dist/utils.d.cts +286 -0
  57. package/packages/shared/node_modules/consola/dist/utils.d.mts +286 -0
  58. package/packages/shared/node_modules/consola/dist/utils.d.ts +286 -0
  59. package/packages/shared/node_modules/consola/dist/utils.mjs +54 -0
  60. package/packages/shared/node_modules/consola/lib/index.cjs +10 -0
  61. package/packages/shared/node_modules/consola/package.json +136 -0
  62. package/packages/shared/node_modules/consola/utils.d.ts +1 -0
  63. package/src/commands/auto-claude/run-claude.test.ts +1 -1
  64. package/src/commands/auto-claude/stream-parser.test.ts +2 -5
  65. package/src/commands/auto-claude/stream-parser.ts +26 -5
  66. package/src/commands/graph/analyzer.test.ts +38 -21
  67. package/src/commands/graph/tools.ts +1 -1
  68. package/src/commands/graph/types.ts +4 -13
  69. package/src/commands/graph.test.ts +14 -1
  70. package/packages/shared/tsconfig.json +0 -16
@@ -8,6 +8,7 @@
8
8
  "test": "bun test"
9
9
  },
10
10
  "dependencies": {
11
+ "@anthropic-ai/sdk": "^0.56.0",
11
12
  "consola": "^3.4.2"
12
13
  },
13
14
  "devDependencies": {
@@ -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) => (c as any).name === "AskUserQuestion");
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 status is "running" but journal hasn't been written to
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
- try {
156
- const mtime = (await stat(filePath)).mtimeMs;
157
- if (Date.now() - mtime > JOURNAL_IDLE_TIMEOUT_MS) {
158
- prev.status = "idle";
159
- const session = prev.projectDir ? this.ctx?.resolveSession(prev.projectDir) : undefined;
160
- if (session) {
161
- this.ctx?.emit({
162
- agent: "claude-code",
163
- session,
164
- status: "idle",
165
- ts: Date.now(),
166
- threadId,
167
- threadName: prev.threadName,
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
- } catch {}
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
- entry = JSON.parse(line);
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, { status: latestStatus, fileSize: size, threadName, projectDir });
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
- entry = JSON.parse(line);
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, { status: latestStatus, fileSize: size, threadName, projectDir });
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: state.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
+ }