linkshell-cli 0.5.14 → 0.5.16

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.
@@ -5,6 +5,7 @@ import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
5
5
  import { AcpClient } from "./acp-client.js";
6
6
  import { ClaudeSdkClient } from "./claude-sdk-client.js";
7
7
  import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
8
+ import { aggregateUsage } from "../usage-report.js";
8
9
  import { resolveAgentCommand } from "./provider-resolver.js";
9
10
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
10
11
  const MAX_TIMELINE_ITEMS = 200;
@@ -68,6 +69,46 @@ function contextWindowForModel(model) {
68
69
  }
69
70
  return undefined;
70
71
  }
72
+ // Normalize a raw provider usage payload (Claude message.usage / result.usage,
73
+ // Codex thread/tokenUsage/updated) into our wire field names. Provider field
74
+ // names vary, so probe snake_case + camelCase. Shared by the live-turn path and
75
+ // the on-disk-transcript path so the two never drift. Returns undefined when no
76
+ // token field resolves. Cost + updatedAt are layered on by callers.
77
+ function normalizeUsageFields(raw, model) {
78
+ const rec = asRecord(raw);
79
+ if (!rec)
80
+ return undefined;
81
+ const num = (...keys) => {
82
+ for (const k of keys) {
83
+ const v = rec[k];
84
+ if (typeof v === "number" && Number.isFinite(v))
85
+ return v;
86
+ }
87
+ return undefined;
88
+ };
89
+ const inputTokens = num("input_tokens", "inputTokens", "input");
90
+ const outputTokens = num("output_tokens", "outputTokens", "output");
91
+ const cacheReadTokens = num("cache_read_input_tokens", "cacheReadInputTokens", "cache_read_tokens", "cached_input_tokens", "cacheReadTokens");
92
+ let totalTokens = num("total_tokens", "totalTokens", "total");
93
+ if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
94
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
95
+ }
96
+ if (inputTokens == null && outputTokens == null && cacheReadTokens == null && totalTokens == null) {
97
+ return undefined;
98
+ }
99
+ let contextWindow = num("context_window", "contextWindow", "model_context_window", "window") ??
100
+ contextWindowForModel(model);
101
+ // Self-correcting window: tokens-in-context can't exceed the window, so if the
102
+ // measured occupancy (input + cache reads) already overshoots our assumed
103
+ // window, the real window must be larger — escalate to 1M. Fixes Claude 1M
104
+ // sessions, whose transcript records the model as plain "claude-opus-4.8"
105
+ // (no [1m] tag), which would otherwise show a misleading 100%.
106
+ const ctxUsed = (inputTokens ?? 0) + (cacheReadTokens ?? 0);
107
+ if (contextWindow && ctxUsed > contextWindow) {
108
+ contextWindow = ctxUsed > 1_000_000 ? ctxUsed : 1_000_000;
109
+ }
110
+ return { inputTokens, outputTokens, cacheReadTokens, totalTokens, contextWindow };
111
+ }
71
112
  function stringArray(value) {
72
113
  if (!Array.isArray(value))
73
114
  return [];
@@ -1081,6 +1122,7 @@ function parseRemoteSessions(value) {
1081
1122
  model: firstString(source, ["model", "modelId"]),
1082
1123
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1083
1124
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
1125
+ usage: source.usage,
1084
1126
  });
1085
1127
  }
1086
1128
  return result;
@@ -1442,6 +1484,26 @@ export class AgentWorkspaceProxy {
1442
1484
  await this.loadOlderHistory(payload);
1443
1485
  break;
1444
1486
  }
1487
+ case "agent.v2.usage.request": {
1488
+ // Aggregate ALL on-disk transcripts (decoupled from live turns). The
1489
+ // scan is synchronous (~3s over a large corpus); acceptable for an
1490
+ // explicit, on-demand dashboard open. Errors must never break the
1491
+ // session, so swallow + send an empty report.
1492
+ try {
1493
+ const report = aggregateUsage();
1494
+ this.input.send(createEnvelope({
1495
+ type: "agent.v2.usage.report",
1496
+ sessionId: this.input.sessionId,
1497
+ payload: report,
1498
+ }));
1499
+ }
1500
+ catch (error) {
1501
+ if (this.input.verbose) {
1502
+ process.stderr.write(`[agent:v2] usage aggregation failed: ${error instanceof Error ? error.message : String(error)}\n`);
1503
+ }
1504
+ }
1505
+ break;
1506
+ }
1445
1507
  case "agent.v2.prompt": {
1446
1508
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1447
1509
  await this.sendPrompt(payload);
@@ -1695,13 +1757,21 @@ export class AgentWorkspaceProxy {
1695
1757
  const conversationId = existingId ?? makeAgentV2RemoteConversationId(provider, agentSessionId);
1696
1758
  const existing = this.conversations.get(conversationId);
1697
1759
  const cwd = remote.cwd ?? existing?.cwd ?? this.input.cwd;
1760
+ const model = remote.model ?? existing?.model;
1761
+ // Usage read straight from the on-disk transcript (decoupled from any
1762
+ // live LinkShell turn) — so history + externally-started sessions all
1763
+ // carry a context meter. Falls back to any existing live-turn usage.
1764
+ const usageFields = normalizeUsageFields(remote.usage, model);
1765
+ const usage = usageFields
1766
+ ? { ...usageFields, updatedAt: remote.lastActivityAt ?? now }
1767
+ : existing?.usage;
1698
1768
  const conversation = {
1699
1769
  id: conversationId,
1700
1770
  agentSessionId,
1701
1771
  provider,
1702
1772
  cwd,
1703
1773
  title: remote.title ?? existing?.title ?? titleFromCwd(cwd),
1704
- model: remote.model ?? existing?.model,
1774
+ model,
1705
1775
  reasoningEffort: existing?.reasoningEffort,
1706
1776
  permissionMode: existing?.permissionMode,
1707
1777
  collaborationMode: existing?.collaborationMode,
@@ -1710,6 +1780,7 @@ export class AgentWorkspaceProxy {
1710
1780
  lastMessagePreview: existing?.lastMessagePreview,
1711
1781
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1712
1782
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
1783
+ usage,
1713
1784
  };
1714
1785
  this.conversations.set(conversation.id, conversation);
1715
1786
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
@@ -3405,38 +3476,23 @@ export class AgentWorkspaceProxy {
3405
3476
  }
3406
3477
  // Normalize a provider usage payload (Claude result.usage, Codex
3407
3478
  // thread/tokenUsage/updated) into our wire shape, merge onto the conversation,
3408
- // and emit. Field names vary by provider so we probe snake_case + camelCase.
3409
- // Missing fields preserve the previous value (cumulative view). No-ops when
3410
- // nothing meaningful resolves, so a stray empty payload won't blank the meter.
3479
+ // and emit. Missing fields preserve the previous value (cumulative view).
3480
+ // No-ops when nothing meaningful resolves, so a stray empty payload won't
3481
+ // blank the meter.
3411
3482
  applyConversationUsage(conversationId, raw, costUsd) {
3412
3483
  const conversation = this.conversations.get(conversationId);
3413
3484
  if (!conversation)
3414
3485
  return;
3415
- const rec = asRecord(raw);
3416
- const num = (...keys) => {
3417
- for (const k of keys) {
3418
- const v = rec?.[k];
3419
- if (typeof v === "number" && Number.isFinite(v))
3420
- return v;
3421
- }
3422
- return undefined;
3423
- };
3424
- const inputTokens = num("input_tokens", "inputTokens", "input");
3425
- const outputTokens = num("output_tokens", "outputTokens", "output");
3426
- const cacheReadTokens = num("cache_read_input_tokens", "cacheReadInputTokens", "cache_read_tokens", "cached_input_tokens", "cacheReadTokens");
3427
- let totalTokens = num("total_tokens", "totalTokens", "total");
3428
- if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
3429
- totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
3430
- }
3431
- const contextWindow = num("context_window", "contextWindow", "model_context_window", "window") ??
3432
- contextWindowForModel(conversation.model);
3486
+ const fields = normalizeUsageFields(raw, conversation.model);
3487
+ if (!fields && costUsd == null)
3488
+ return;
3433
3489
  const prev = conversation.usage ?? {};
3434
3490
  const next = {
3435
- inputTokens: inputTokens ?? prev.inputTokens,
3436
- outputTokens: outputTokens ?? prev.outputTokens,
3437
- cacheReadTokens: cacheReadTokens ?? prev.cacheReadTokens,
3438
- totalTokens: totalTokens ?? prev.totalTokens,
3439
- contextWindow: contextWindow ?? prev.contextWindow,
3491
+ inputTokens: fields?.inputTokens ?? prev.inputTokens,
3492
+ outputTokens: fields?.outputTokens ?? prev.outputTokens,
3493
+ cacheReadTokens: fields?.cacheReadTokens ?? prev.cacheReadTokens,
3494
+ totalTokens: fields?.totalTokens ?? prev.totalTokens,
3495
+ contextWindow: fields?.contextWindow ?? prev.contextWindow,
3440
3496
  totalCostUsd: costUsd ?? prev.totalCostUsd,
3441
3497
  updatedAt: Date.now(),
3442
3498
  };