@towles/tool 0.0.120 → 0.0.122

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 (64) 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 +106 -31
  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/core/skills/towles-tool/SKILL.md +1 -0
  25. package/packages/shared/node_modules/consola/LICENSE +47 -0
  26. package/packages/shared/node_modules/consola/README.md +352 -0
  27. package/packages/shared/node_modules/consola/basic.d.ts +1 -0
  28. package/packages/shared/node_modules/consola/browser.d.ts +1 -0
  29. package/packages/shared/node_modules/consola/core.d.ts +1 -0
  30. package/packages/shared/node_modules/consola/dist/basic.cjs +32 -0
  31. package/packages/shared/node_modules/consola/dist/basic.d.cts +23 -0
  32. package/packages/shared/node_modules/consola/dist/basic.d.mts +21 -0
  33. package/packages/shared/node_modules/consola/dist/basic.d.ts +23 -0
  34. package/packages/shared/node_modules/consola/dist/basic.mjs +24 -0
  35. package/packages/shared/node_modules/consola/dist/browser.cjs +84 -0
  36. package/packages/shared/node_modules/consola/dist/browser.d.cts +23 -0
  37. package/packages/shared/node_modules/consola/dist/browser.d.mts +21 -0
  38. package/packages/shared/node_modules/consola/dist/browser.d.ts +23 -0
  39. package/packages/shared/node_modules/consola/dist/browser.mjs +76 -0
  40. package/packages/shared/node_modules/consola/dist/chunks/prompt.cjs +288 -0
  41. package/packages/shared/node_modules/consola/dist/chunks/prompt.mjs +280 -0
  42. package/packages/shared/node_modules/consola/dist/core.cjs +517 -0
  43. package/packages/shared/node_modules/consola/dist/core.d.cts +459 -0
  44. package/packages/shared/node_modules/consola/dist/core.d.mts +459 -0
  45. package/packages/shared/node_modules/consola/dist/core.d.ts +459 -0
  46. package/packages/shared/node_modules/consola/dist/core.mjs +512 -0
  47. package/packages/shared/node_modules/consola/dist/index.cjs +663 -0
  48. package/packages/shared/node_modules/consola/dist/index.d.cts +24 -0
  49. package/packages/shared/node_modules/consola/dist/index.d.mts +22 -0
  50. package/packages/shared/node_modules/consola/dist/index.d.ts +24 -0
  51. package/packages/shared/node_modules/consola/dist/index.mjs +651 -0
  52. package/packages/shared/node_modules/consola/dist/shared/consola.DCGIlDNP.cjs +75 -0
  53. package/packages/shared/node_modules/consola/dist/shared/consola.DRwqZj3T.mjs +72 -0
  54. package/packages/shared/node_modules/consola/dist/shared/consola.DXBYu-KD.mjs +288 -0
  55. package/packages/shared/node_modules/consola/dist/shared/consola.DwRq1yyg.cjs +312 -0
  56. package/packages/shared/node_modules/consola/dist/utils.cjs +64 -0
  57. package/packages/shared/node_modules/consola/dist/utils.d.cts +286 -0
  58. package/packages/shared/node_modules/consola/dist/utils.d.mts +286 -0
  59. package/packages/shared/node_modules/consola/dist/utils.d.ts +286 -0
  60. package/packages/shared/node_modules/consola/dist/utils.mjs +54 -0
  61. package/packages/shared/node_modules/consola/lib/index.cjs +10 -0
  62. package/packages/shared/node_modules/consola/package.json +136 -0
  63. package/packages/shared/node_modules/consola/utils.d.ts +1 -0
  64. 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,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 status is "running" but journal hasn't been written to
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
- try {
157
- const mtime = (await stat(filePath)).mtimeMs;
158
- if (Date.now() - mtime > JOURNAL_IDLE_TIMEOUT_MS) {
159
- prev.status = "idle";
160
- const session = prev.projectDir ? this.ctx?.resolveSession(prev.projectDir) : undefined;
161
- if (session) {
162
- this.ctx?.emit({
163
- agent: "claude-code",
164
- session,
165
- status: "idle",
166
- ts: Date.now(),
167
- threadId,
168
- threadName: prev.threadName,
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
- } catch {}
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
- entry = JSON.parse(line);
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, { status: latestStatus, fileSize: size, threadName, projectDir });
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
- entry = JSON.parse(line);
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, { status: latestStatus, fileSize: size, threadName, projectDir });
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: state.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"]);