claude-code-discord-presence 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.env.example +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/assets/claude-code.png +0 -0
  5. package/assets/claude-liquid-dark.png +0 -0
  6. package/assets/claude-liquid-light.png +0 -0
  7. package/assets/claude-stats-dark.png +0 -0
  8. package/assets/claude-stats-light.png +0 -0
  9. package/assets/claude-usage-stats.png +0 -0
  10. package/assets/social-preview.jpg +0 -0
  11. package/assets/usage-stats.png +0 -0
  12. package/dist/appearance/theme-assets.js +13 -0
  13. package/dist/appearance/theme-watcher.js +188 -0
  14. package/dist/claude/app-liveness.js +82 -0
  15. package/dist/claude/cost.js +54 -0
  16. package/dist/claude/default-selection.js +268 -0
  17. package/dist/claude/desktop-focus.js +191 -0
  18. package/dist/claude/limits.js +90 -0
  19. package/dist/claude/monthly-usage.js +279 -0
  20. package/dist/claude/oauth-discovery.js +82 -0
  21. package/dist/claude/paths.js +13 -0
  22. package/dist/claude/plan-info.js +102 -0
  23. package/dist/claude/session-store.js +617 -0
  24. package/dist/claude/tool-labels.js +55 -0
  25. package/dist/claude/transcript.js +150 -0
  26. package/dist/claude/usage-poller.js +243 -0
  27. package/dist/cli.js +137 -0
  28. package/dist/config.js +96 -0
  29. package/dist/discord/presence-builder.js +295 -0
  30. package/dist/discord/rpc-client.js +224 -0
  31. package/dist/index.js +160 -0
  32. package/dist/remote/tunnel-manager.js +83 -0
  33. package/dist/server/http-server.js +89 -0
  34. package/dist/types.js +1 -0
  35. package/dist/util/autostart.js +73 -0
  36. package/dist/util/logger.js +74 -0
  37. package/dist/util/process-liveness.js +231 -0
  38. package/dist/util/process-scan-watcher.js +196 -0
  39. package/package.json +73 -0
  40. package/pricing.json +47 -0
  41. package/scripts/hook.mjs +32 -0
  42. package/scripts/install-autostart.mjs +50 -0
  43. package/scripts/install-remote.mjs +53 -0
  44. package/scripts/remove-autostart.mjs +35 -0
  45. package/scripts/remove-autostart.ps1 +7 -0
  46. package/scripts/run-hidden.vbs +7 -0
  47. package/scripts/run-service.ps1 +44 -0
  48. package/scripts/setup-autostart.ps1 +32 -0
  49. package/scripts/setup-claude.mjs +132 -0
  50. package/scripts/setup-remote.mjs +99 -0
  51. package/scripts/statusline.mjs +59 -0
@@ -0,0 +1,617 @@
1
+ import { limitsFromStatusline, mergeLimits } from "./limits.js";
2
+ import { toolLabel } from "./tool-labels.js";
3
+ import { readModelFromTranscript, readSessionStats, findTranscriptPath } from "./transcript.js";
4
+ import { costBreakdown, costForUsageByModel } from "./cost.js";
5
+ import { modelDisplayName } from "../discord/presence-builder.js";
6
+ const IDLE_MS = 10 * 60 * 1000;
7
+ const APP_CLOSE_GRACE_MS = 45_000;
8
+ const EFFORT_LEVELS = new Set([
9
+ "minimal",
10
+ "low",
11
+ "medium",
12
+ "high",
13
+ "xhigh",
14
+ "max",
15
+ "ultra",
16
+ ]);
17
+ function effortOf(raw) {
18
+ return typeof raw === "string" && EFFORT_LEVELS.has(raw) ? raw : undefined;
19
+ }
20
+ function numeric(value) {
21
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
22
+ }
23
+ function isRemoteTranscript(path, explicit) {
24
+ return explicit === true || (process.platform === "win32" && path.startsWith("/"));
25
+ }
26
+ function modelOf(raw, displayName) {
27
+ if (typeof raw === "string" && raw.trim() !== "") {
28
+ return { id: raw, displayName: modelDisplayName(raw, displayName) };
29
+ }
30
+ if (raw && typeof raw === "object") {
31
+ const obj = raw;
32
+ const id = typeof obj.id === "string" ? obj.id : "";
33
+ const dn = typeof obj.display_name === "string" ? obj.display_name : displayName;
34
+ if (id || dn)
35
+ return { id, displayName: modelDisplayName(id || undefined, dn) };
36
+ }
37
+ if (displayName)
38
+ return { id: "", displayName };
39
+ return undefined;
40
+ }
41
+ function actionForStatus(status, working, agentsRunning) {
42
+ switch (status) {
43
+ case "new":
44
+ return "Waiting for a prompt";
45
+ case "thinking":
46
+ return "Thinking";
47
+ case "idle":
48
+ return agentsRunning ? "Waiting for agents" : "Idle";
49
+ case "waiting":
50
+ return "Waiting for input";
51
+ case "working":
52
+ return working;
53
+ }
54
+ }
55
+ export class SessionStore {
56
+ onChange;
57
+ sessions = new Map();
58
+ activeId;
59
+ planName = "Claude";
60
+ usageLimits;
61
+ localMonthlyUsage;
62
+ remoteMonthlyUsage;
63
+ defaultEffort;
64
+ defaultModel;
65
+ activeSince;
66
+ appAlive = false;
67
+ appLivenessKnown = false;
68
+ appStartedAt;
69
+ cleared = false;
70
+ pendingFocus;
71
+ focusDebounceMs;
72
+ appCloseGraceMs;
73
+ idleTimer;
74
+ modelTimer;
75
+ constructor(onChange, options = {}) {
76
+ this.onChange = onChange;
77
+ this.focusDebounceMs = options.focusDebounceMs ?? 600;
78
+ this.appCloseGraceMs = options.appCloseGraceMs ?? APP_CLOSE_GRACE_MS;
79
+ this.idleTimer = setInterval(() => this.checkIdle(), 5_000);
80
+ this.modelTimer = setInterval(() => void this.pollActiveModel(), options.modelPollIntervalMs ?? 2_000);
81
+ }
82
+ dispose() {
83
+ clearInterval(this.idleTimer);
84
+ clearInterval(this.modelTimer);
85
+ this.clearPendingFocus();
86
+ }
87
+ setPlanName(name) {
88
+ if (name === this.planName)
89
+ return;
90
+ this.planName = name;
91
+ this.onChange();
92
+ }
93
+ setUsageLimits(limits) {
94
+ this.usageLimits = limits;
95
+ this.onChange();
96
+ }
97
+ setMonthlyUsage(remote, usage) {
98
+ const current = remote ? this.remoteMonthlyUsage : this.localMonthlyUsage;
99
+ if (current?.totalTokens === usage.totalTokens &&
100
+ Math.abs((current?.costUsd ?? 0) - usage.costUsd) < 1e-9) {
101
+ return;
102
+ }
103
+ if (remote)
104
+ this.remoteMonthlyUsage = usage;
105
+ else
106
+ this.localMonthlyUsage = usage;
107
+ this.onChange();
108
+ }
109
+ setDefaultEffort(effort) {
110
+ if (effort === this.defaultEffort)
111
+ return;
112
+ this.defaultEffort = effort;
113
+ this.onChange();
114
+ }
115
+ setDefaultModel(model) {
116
+ const parsed = modelOf(model);
117
+ if (parsed?.id === this.defaultModel?.id && parsed?.displayName === this.defaultModel?.displayName)
118
+ return;
119
+ this.defaultModel = parsed;
120
+ this.onChange();
121
+ }
122
+ setAppLiveness(alive, startedAt) {
123
+ const firstReport = !this.appLivenessKnown;
124
+ this.appLivenessKnown = true;
125
+ if (!firstReport && alive === this.appAlive && startedAt === this.appStartedAt)
126
+ return;
127
+ this.appAlive = alive;
128
+ this.appStartedAt = alive ? startedAt : undefined;
129
+ this.cleared = false;
130
+ this.onChange();
131
+ }
132
+ hidden(session, now) {
133
+ if (this.appAlive)
134
+ return false;
135
+ const age = now - session.lastActivity;
136
+ if (session.remote)
137
+ return age > IDLE_MS;
138
+ return this.appLivenessKnown || age > this.appCloseGraceMs;
139
+ }
140
+ ensure(sessionId) {
141
+ let session = this.sessions.get(sessionId);
142
+ if (!session) {
143
+ const now = Date.now();
144
+ session = {
145
+ sessionId,
146
+ startTimestamp: now,
147
+ lastActivity: now,
148
+ lastInteractionAt: 0,
149
+ modelUpdatedAt: 0,
150
+ runtimeModelAt: 0,
151
+ runtimeModelFloorAt: 0,
152
+ desktopModelKnown: false,
153
+ desktopEffortKnown: false,
154
+ hasHookActivity: false,
155
+ status: "idle",
156
+ action: "Idle",
157
+ agents: new Set(),
158
+ idleAgents: 0,
159
+ lastTranscriptLocateAt: 0,
160
+ lastModelReadAt: 0,
161
+ lastStatsReadAt: 0,
162
+ };
163
+ this.sessions.set(sessionId, session);
164
+ }
165
+ return session;
166
+ }
167
+ touch(session) {
168
+ session.lastActivity = Date.now();
169
+ this.cleared = false;
170
+ if (!this.activeId || !this.sessions.has(this.activeId))
171
+ this.activeId = session.sessionId;
172
+ }
173
+ focus(session) {
174
+ session.lastInteractionAt = Date.now();
175
+ this.activeId = session.sessionId;
176
+ this.cleared = false;
177
+ }
178
+ scheduleFocus(session) {
179
+ this.clearPendingFocus();
180
+ if (this.focusDebounceMs <= 0) {
181
+ this.focus(session);
182
+ return;
183
+ }
184
+ const id = session.sessionId;
185
+ this.pendingFocus = {
186
+ sessionId: id,
187
+ timer: setTimeout(() => {
188
+ this.pendingFocus = undefined;
189
+ const target = this.sessions.get(id);
190
+ if (target) {
191
+ this.focus(target);
192
+ this.onChange();
193
+ }
194
+ }, this.focusDebounceMs),
195
+ };
196
+ }
197
+ clearPendingFocus(sessionId) {
198
+ if (!this.pendingFocus)
199
+ return;
200
+ if (sessionId !== undefined && this.pendingFocus.sessionId !== sessionId)
201
+ return;
202
+ clearTimeout(this.pendingFocus.timer);
203
+ this.pendingFocus = undefined;
204
+ }
205
+ applySelectedModel(session, model, updatedAt = Date.now()) {
206
+ if (!model)
207
+ return;
208
+ const changed = session.model?.id !== model.id || session.model?.displayName !== model.displayName;
209
+ const hadSelectedModel = session.model !== undefined;
210
+ session.model = model;
211
+ if (changed) {
212
+ session.modelUpdatedAt = Math.max(session.modelUpdatedAt, updatedAt);
213
+ if (hadSelectedModel) {
214
+ session.runtimeModel = undefined;
215
+ session.runtimeModelAt = 0;
216
+ session.runtimeModelFloorAt = Math.max(session.runtimeModelFloorAt, updatedAt);
217
+ }
218
+ }
219
+ }
220
+ applyRuntimeModel(session, model, updatedAt, event) {
221
+ if (!model || updatedAt < session.runtimeModelFloorAt || (event && event === session.runtimeModelEvent))
222
+ return;
223
+ session.runtimeModel = model;
224
+ session.runtimeModelAt = updatedAt;
225
+ session.runtimeModelEvent = event;
226
+ }
227
+ effectiveModel(session) {
228
+ return session.runtimeModel ?? session.model;
229
+ }
230
+ beginTurn(session) {
231
+ const now = Date.now();
232
+ session.runtimeModel = undefined;
233
+ session.runtimeModelAt = 0;
234
+ session.runtimeModelFloorAt = Math.max(session.runtimeModelFloorAt, now);
235
+ }
236
+ handleHook(payload) {
237
+ const sessionId = payload.session_id;
238
+ if (!sessionId)
239
+ return;
240
+ const event = payload.hook_event_name ?? "";
241
+ const agentId = typeof payload.agent_id === "string" && payload.agent_id !== "" ? payload.agent_id : undefined;
242
+ const fromAgent = agentId !== undefined;
243
+ const session = this.ensure(sessionId);
244
+ const firstHook = !session.hasHookActivity;
245
+ session.hasHookActivity = true;
246
+ this.touch(session);
247
+ const wasThinking = session.status === "thinking";
248
+ if (typeof payload.permission_mode === "string")
249
+ session.permissionMode = payload.permission_mode;
250
+ if (typeof payload.transcript_path === "string") {
251
+ const remote = isRemoteTranscript(payload.transcript_path, payload.remote);
252
+ session.remote = remote;
253
+ if (!remote)
254
+ session.transcriptPath = payload.transcript_path;
255
+ }
256
+ if (!fromAgent) {
257
+ const effort = effortOf(payload.effort?.level);
258
+ if (effort && (firstHook || !session.desktopEffortKnown))
259
+ session.effort = effort;
260
+ const model = modelOf(payload.model);
261
+ if (firstHook || !session.desktopModelKnown)
262
+ this.applySelectedModel(session, model);
263
+ }
264
+ else if (!this.effectiveModel(session)) {
265
+ this.applySelectedModel(session, modelOf(payload.model));
266
+ }
267
+ if (payload.usage)
268
+ this.applyUsage(session, payload.usage, payload.usage_by_model);
269
+ switch (event) {
270
+ case "SessionStart":
271
+ session.status = "new";
272
+ session.action = "Waiting for a prompt";
273
+ this.scheduleFocus(session);
274
+ break;
275
+ case "UserPromptSubmit":
276
+ this.beginTurn(session);
277
+ session.status = "thinking";
278
+ session.action = "Thinking";
279
+ this.clearPendingFocus();
280
+ this.focus(session);
281
+ break;
282
+ case "PreToolUse":
283
+ if (fromAgent) {
284
+ session.agents.add(agentId);
285
+ break;
286
+ }
287
+ session.status = "working";
288
+ session.action = toolLabel(payload.tool_name, payload.tool_input);
289
+ break;
290
+ case "PostToolUse":
291
+ break;
292
+ case "Stop":
293
+ session.status = "idle";
294
+ session.action = "Idle";
295
+ break;
296
+ case "SubagentStart":
297
+ if (payload.agent_id)
298
+ session.agents.add(payload.agent_id);
299
+ break;
300
+ case "SubagentStop":
301
+ if (payload.agent_id)
302
+ session.agents.delete(payload.agent_id);
303
+ if (session.idleAgents > session.agents.size)
304
+ session.idleAgents = session.agents.size;
305
+ break;
306
+ case "Notification":
307
+ this.handleNotification(session, payload.notification_type);
308
+ break;
309
+ case "SessionEnd":
310
+ this.clearPendingFocus(sessionId);
311
+ this.sessions.delete(sessionId);
312
+ if (this.activeId === sessionId)
313
+ this.activeId = this.mostRecentId();
314
+ break;
315
+ default:
316
+ break;
317
+ }
318
+ if (session.status === "thinking") {
319
+ if (!wasThinking || session.thinkingStartedAt === undefined)
320
+ session.thinkingStartedAt = Date.now();
321
+ }
322
+ else {
323
+ delete session.thinkingStartedAt;
324
+ }
325
+ const turnBoundary = event === "SessionStart" || event === "UserPromptSubmit";
326
+ if (!session.transcriptPath && turnBoundary) {
327
+ void this.locateAndRefresh(session);
328
+ }
329
+ else if (session.transcriptPath) {
330
+ if (!this.effectiveModel(session) || turnBoundary)
331
+ void this.refreshModelFromTranscript(session);
332
+ const statsEvent = event === "Stop" || event === "UserPromptSubmit" || event === "PostToolUse" || event === "SessionStart";
333
+ if (statsEvent && !fromAgent) {
334
+ void this.refreshStatsFromTranscript(session, event === "Stop" || event === "SessionStart");
335
+ }
336
+ }
337
+ this.onChange();
338
+ }
339
+ async refreshModelFromTranscript(session) {
340
+ const now = Date.now();
341
+ if (now - session.lastModelReadAt < 1_000)
342
+ return;
343
+ session.lastModelReadAt = now;
344
+ const found = await readModelFromTranscript(session.transcriptPath);
345
+ const current = this.effectiveModel(session);
346
+ const id = found.main ?? (current ? undefined : found.any);
347
+ if (id) {
348
+ const before = this.effectiveModel(session);
349
+ this.applyRuntimeModel(session, { id, displayName: modelDisplayName(id) }, found.mainAt ?? now, found.mainEvent ?? `transcript:${id}`);
350
+ const after = this.effectiveModel(session);
351
+ if (before?.id !== after?.id || before?.displayName !== after?.displayName)
352
+ this.onChange();
353
+ }
354
+ }
355
+ async pollActiveModel() {
356
+ const session = this.active();
357
+ if (!session)
358
+ return;
359
+ if (!session.transcriptPath) {
360
+ await this.locateAndRefresh(session);
361
+ return;
362
+ }
363
+ await this.refreshModelFromTranscript(session);
364
+ }
365
+ async locateAndRefresh(session) {
366
+ const now = Date.now();
367
+ if (now - session.lastTranscriptLocateAt < 2_000)
368
+ return;
369
+ session.lastTranscriptLocateAt = now;
370
+ const path = await findTranscriptPath(session.sessionId);
371
+ if (!path)
372
+ return;
373
+ session.transcriptPath = path;
374
+ await this.refreshModelFromTranscript(session);
375
+ await this.refreshStatsFromTranscript(session, true);
376
+ }
377
+ async refreshStatsFromTranscript(session, force) {
378
+ const now = Date.now();
379
+ if (!force && session.usage && now - session.lastStatsReadAt < 5000)
380
+ return;
381
+ session.lastStatsReadAt = now;
382
+ const stats = await readSessionStats(session.transcriptPath);
383
+ if (!stats)
384
+ return;
385
+ session.usage = stats.usage;
386
+ session.usageByModel = stats.usageByModel;
387
+ this.onChange();
388
+ }
389
+ handleNotification(session, type) {
390
+ switch (type) {
391
+ case "permission_prompt":
392
+ case "idle_prompt":
393
+ session.status = "waiting";
394
+ session.action = "Waiting for input";
395
+ break;
396
+ case "agent_needs_input":
397
+ session.idleAgents = Math.min(session.agents.size, session.idleAgents + 1);
398
+ break;
399
+ case "agent_completed":
400
+ if (session.idleAgents > 0)
401
+ session.idleAgents -= 1;
402
+ break;
403
+ default:
404
+ break;
405
+ }
406
+ }
407
+ setDesktopFocus(sessionId, info) {
408
+ if (!sessionId || !Number.isFinite(info.focusedAt) || info.focusedAt <= 0)
409
+ return;
410
+ if (info.failed) {
411
+ this.clearPendingFocus(sessionId);
412
+ this.sessions.delete(sessionId);
413
+ if (this.activeId === sessionId)
414
+ this.activeId = this.mostRecentId();
415
+ this.cleared = false;
416
+ this.onChange();
417
+ return;
418
+ }
419
+ const existing = this.sessions.get(sessionId);
420
+ const session = this.ensure(sessionId);
421
+ if (!existing) {
422
+ session.startTimestamp = Math.min(info.focusedAt, info.lastActivityAt ?? info.focusedAt);
423
+ session.lastActivity = Math.max(info.focusedAt, info.lastActivityAt ?? 0);
424
+ }
425
+ else if (info.focusedAt > session.lastActivity) {
426
+ session.lastActivity = info.focusedAt;
427
+ }
428
+ const desktopModel = modelOf(info.model);
429
+ if (desktopModel) {
430
+ session.desktopModelKnown = true;
431
+ this.applySelectedModel(session, desktopModel, info.updatedAt ?? Date.now());
432
+ }
433
+ const effort = effortOf(info.effort);
434
+ if (effort) {
435
+ session.desktopEffortKnown = true;
436
+ session.effort = effort;
437
+ }
438
+ const active = this.active();
439
+ if (active && active.sessionId !== sessionId && active.lastInteractionAt > info.focusedAt) {
440
+ this.onChange();
441
+ return;
442
+ }
443
+ this.clearPendingFocus();
444
+ if (info.focusedAt > session.lastInteractionAt)
445
+ session.lastInteractionAt = info.focusedAt;
446
+ this.activeId = sessionId;
447
+ this.cleared = false;
448
+ if (!session.transcriptPath) {
449
+ void this.locateAndRefresh(session);
450
+ }
451
+ else if (!session.usage || !this.effectiveModel(session)) {
452
+ void this.refreshStatsFromTranscript(session, true);
453
+ }
454
+ this.onChange();
455
+ }
456
+ applyUsage(session, usage, byModel) {
457
+ session.usage = {
458
+ input: numeric(usage.input_tokens),
459
+ output: numeric(usage.output_tokens),
460
+ cacheRead: numeric(usage.cache_read_input_tokens),
461
+ cacheWrite: numeric(usage.cache_creation_input_tokens),
462
+ };
463
+ if (byModel && typeof byModel === "object") {
464
+ const map = {};
465
+ for (const [model, u] of Object.entries(byModel)) {
466
+ if (!u || typeof u !== "object")
467
+ continue;
468
+ map[model] = {
469
+ input: numeric(u.input_tokens),
470
+ output: numeric(u.output_tokens),
471
+ cacheRead: numeric(u.cache_read_input_tokens),
472
+ cacheWrite: numeric(u.cache_creation_input_tokens),
473
+ };
474
+ }
475
+ session.usageByModel = Object.keys(map).length > 0 ? map : undefined;
476
+ }
477
+ else {
478
+ session.usageByModel = undefined;
479
+ }
480
+ }
481
+ handleStatusline(payload) {
482
+ const sessionId = payload.session_id;
483
+ if (!sessionId)
484
+ return;
485
+ const session = this.ensure(sessionId);
486
+ if (payload.remote === true)
487
+ session.remote = true;
488
+ session.hasHookActivity = true;
489
+ this.touch(session);
490
+ if (!session.desktopModelKnown) {
491
+ this.applySelectedModel(session, modelOf(payload.model, payload.model?.display_name));
492
+ }
493
+ const effort = effortOf(payload.effort?.level);
494
+ if (effort && !session.desktopEffortKnown)
495
+ session.effort = effort;
496
+ if (typeof payload.fast_mode === "boolean")
497
+ session.fastMode = payload.fast_mode;
498
+ if (typeof payload.permission_mode === "string")
499
+ session.permissionMode = payload.permission_mode;
500
+ const limits = limitsFromStatusline(payload.rate_limits, Date.now());
501
+ if (limits)
502
+ session.statuslineLimits = limits;
503
+ const cw = payload.context_window?.current_usage;
504
+ if (cw && !session.usage) {
505
+ session.usage = {
506
+ input: numeric(cw.input_tokens),
507
+ output: numeric(cw.output_tokens),
508
+ cacheRead: numeric(cw.cache_read_input_tokens),
509
+ cacheWrite: numeric(cw.cache_creation_input_tokens),
510
+ };
511
+ }
512
+ this.onChange();
513
+ }
514
+ mostRecentId(now = Date.now()) {
515
+ let best;
516
+ let bestAt = -1;
517
+ for (const session of this.sessions.values()) {
518
+ if (this.hidden(session, now))
519
+ continue;
520
+ const at = Math.max(session.lastInteractionAt, session.lastActivity);
521
+ if (at > bestAt) {
522
+ bestAt = at;
523
+ best = session.sessionId;
524
+ }
525
+ }
526
+ return best;
527
+ }
528
+ active() {
529
+ const now = Date.now();
530
+ const current = this.activeId ? this.sessions.get(this.activeId) : undefined;
531
+ if (current && now - current.lastActivity <= IDLE_MS && !this.hidden(current, now))
532
+ return current;
533
+ const fallbackId = this.mostRecentId(now);
534
+ const fallback = fallbackId ? this.sessions.get(fallbackId) : undefined;
535
+ if (fallback && (!current || this.hidden(current, now) || fallback.lastActivity > current.lastActivity)) {
536
+ this.activeId = fallbackId;
537
+ return fallback;
538
+ }
539
+ return current;
540
+ }
541
+ checkIdle() {
542
+ const active = this.active();
543
+ const now = Date.now();
544
+ if (!active || now - active.lastActivity > IDLE_MS || this.hidden(active, now)) {
545
+ if (!this.cleared) {
546
+ this.cleared = true;
547
+ this.onChange();
548
+ }
549
+ else if (active && (this.usageLimits || active.statuslineLimits)) {
550
+ this.onChange();
551
+ }
552
+ return;
553
+ }
554
+ if (active.status === "thinking" || this.usageLimits || active.statuslineLimits)
555
+ this.onChange();
556
+ }
557
+ snapshot() {
558
+ const active = this.active();
559
+ const now = Date.now();
560
+ if (!active || this.hidden(active, now)) {
561
+ this.activeSince = undefined;
562
+ return undefined;
563
+ }
564
+ const stale = now - active.lastActivity > IDLE_MS;
565
+ if (this.appAlive && this.appStartedAt !== undefined) {
566
+ this.activeSince = Math.min(this.appStartedAt, now);
567
+ }
568
+ else if (this.activeSince === undefined) {
569
+ this.activeSince = Math.min(active.startTimestamp, now);
570
+ }
571
+ const status = stale ? "idle" : active.status;
572
+ const agentsRunning = stale ? 0 : active.agents.size;
573
+ const state = {
574
+ planName: this.planName,
575
+ action: actionForStatus(status, active.action, agentsRunning > 0),
576
+ status,
577
+ planMode: !stale && active.permissionMode === "plan",
578
+ agentsRunning,
579
+ agentsIdle: stale ? 0 : Math.min(active.idleAgents, active.agents.size),
580
+ startTimestamp: this.activeSince,
581
+ };
582
+ if (!stale && status === "thinking" && active.thinkingStartedAt !== undefined) {
583
+ state.thinkingSeconds = Math.max(0, Math.floor((now - active.thinkingStartedAt) / 1000));
584
+ }
585
+ const model = !active.hasHookActivity && this.defaultModel ? this.defaultModel : this.effectiveModel(active);
586
+ if (model)
587
+ state.model = model;
588
+ const effort = !active.hasHookActivity && this.defaultEffort ? this.defaultEffort : active.effort ?? this.defaultEffort;
589
+ if (effort)
590
+ state.effort = effort;
591
+ if (active.fastMode !== undefined)
592
+ state.fastMode = active.fastMode;
593
+ // The account usage endpoint is authoritative when available. Some Claude clients emit
594
+ // placeholder zeroes in statusline input, which would otherwise display a false 100% left.
595
+ const limits = mergeLimits(active.statuslineLimits, this.usageLimits, true);
596
+ if (limits)
597
+ state.limits = limits;
598
+ if (active.usage) {
599
+ state.usage = active.usage;
600
+ const byModel = active.usageByModel;
601
+ if (byModel && Object.keys(byModel).length > 0) {
602
+ const breakdown = costForUsageByModel(byModel);
603
+ state.costBreakdown = breakdown;
604
+ state.costUsd = breakdown.total;
605
+ }
606
+ else if (model?.id) {
607
+ const breakdown = costBreakdown(model.id, active.usage);
608
+ state.costBreakdown = breakdown;
609
+ state.costUsd = breakdown.total;
610
+ }
611
+ }
612
+ const monthlyUsage = active.remote ? this.remoteMonthlyUsage : this.localMonthlyUsage;
613
+ if (monthlyUsage)
614
+ state.monthlyUsage = monthlyUsage;
615
+ return state;
616
+ }
617
+ }
@@ -0,0 +1,55 @@
1
+ function basename(input) {
2
+ if (typeof input !== "string" || input.trim() === "")
3
+ return "a file";
4
+ const normalized = input.replace(/\\/g, "/").replace(/\/+$/, "");
5
+ const segment = normalized.slice(normalized.lastIndexOf("/") + 1);
6
+ return segment === "" ? "a file" : segment;
7
+ }
8
+ function filePath(toolInput) {
9
+ if (!toolInput)
10
+ return undefined;
11
+ return toolInput.file_path ?? toolInput.notebook_path ?? toolInput.path;
12
+ }
13
+ const STATIC_LABELS = {
14
+ Grep: "Searching files",
15
+ Glob: "Searching files",
16
+ Bash: "Running a command",
17
+ PowerShell: "Running a command",
18
+ Agent: "Delegating to agents",
19
+ WebSearch: "Searching the web",
20
+ WebFetch: "Browsing the web",
21
+ TaskCreate: "Managing tasks",
22
+ TaskUpdate: "Managing tasks",
23
+ TaskList: "Managing tasks",
24
+ TaskGet: "Managing tasks",
25
+ TaskStop: "Managing tasks",
26
+ TodoWrite: "Managing tasks",
27
+ Skill: "Running a skill",
28
+ SlashCommand: "Running a skill",
29
+ EnterPlanMode: "Planning",
30
+ ExitPlanMode: "Planning",
31
+ AskUserQuestion: "Waiting for input",
32
+ Workflow: "Orchestrating agents",
33
+ Monitor: "Watching background work",
34
+ TaskOutput: "Watching background work",
35
+ };
36
+ export function toolLabel(toolName, toolInput) {
37
+ if (!toolName)
38
+ return "Working";
39
+ if (toolName.startsWith("mcp__")) {
40
+ const server = toolName.split("__")[1];
41
+ return server ? `Using ${server}` : "Working";
42
+ }
43
+ switch (toolName) {
44
+ case "Read":
45
+ return `Reading ${basename(filePath(toolInput))}`;
46
+ case "Edit":
47
+ case "NotebookEdit":
48
+ return `Editing ${basename(filePath(toolInput))}`;
49
+ case "Write":
50
+ return `Writing ${basename(filePath(toolInput))}`;
51
+ default:
52
+ break;
53
+ }
54
+ return STATIC_LABELS[toolName] ?? "Working";
55
+ }