pikiloom 0.4.48 → 0.4.50

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 (25) hide show
  1. package/dashboard/dist/assets/{AgentTab-B3lWNXnI.js → AgentTab-B8Cm50Mg.js} +1 -1
  2. package/dashboard/dist/assets/{ConnectionModal-BBxZiex0.js → ConnectionModal-CwTYZYp3.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BaPa173n.js → DirBrowser-DbqFsndw.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-CrQgsSUZ.js → ExtensionsTab-DjNMSGca.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-BrjEDiUT.js → IMAccessTab-0_qEQDIp.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-GPR6mupI.js → Modal-Q7hHQ_0P.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-gHblG4YD.js → Modals-CKH0xwVb.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BANiu3wO.js → Select-DtQA2BGu.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-BxHxCMiG.js → SessionPanel-DXWaPkz4.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-Dd1qR0vo.js → SystemTab-BM_f7FwS.js} +1 -1
  11. package/dashboard/dist/assets/index-BIUoh4Hy.js +3 -0
  12. package/dashboard/dist/assets/{index-DWNgWDr7.js → index-CX0Ivr8b.js} +4 -4
  13. package/dashboard/dist/assets/{shared-A44PMrg2.js → shared-DDu6QCT8.js} +1 -1
  14. package/dashboard/dist/index.html +1 -1
  15. package/dist/agent/accounts.js +33 -1
  16. package/dist/agent/drivers/claude.js +86 -19
  17. package/dist/agent/kernel-bridge.js +17 -8
  18. package/dist/agent/utils.js +3 -1
  19. package/dist/bot/commands.js +13 -7
  20. package/dist/dashboard/routes/accounts.js +14 -5
  21. package/package.json +1 -1
  22. package/packages/kernel/dist/drivers/claude.d.ts +2 -0
  23. package/packages/kernel/dist/drivers/claude.js +46 -7
  24. package/packages/kernel/dist/drivers/codex.js +25 -2
  25. package/dashboard/dist/assets/index-YNK_eNh1.js +0 -3
@@ -195,10 +195,25 @@ function recomputeClaudeContextUsed(s) {
195
195
  input: s.inputTokens,
196
196
  cached: s.cachedInputTokens,
197
197
  cacheCreation: s.cacheCreationInputTokens,
198
- output: s.outputTokens,
198
+ // While a message is still streaming, the CLI's live thinking estimate is often the only
199
+ // output signal (subscription accounts stream no plaintext thinking and no usage until the
200
+ // message settles); the real output_tokens supersedes it at message_delta.
201
+ output: Math.max(s.outputTokens ?? 0, s.thinkingEstTokens ?? 0),
199
202
  });
200
203
  s.contextUsedTokens = total > 0 ? total : null;
201
204
  }
205
+ // Accumulate the CLI's live thinking-token estimate (system/thinking_tokens events). Prefer the
206
+ // per-event delta (correct whether the CLI's running total is per-message or per-turn); fall back
207
+ // to a monotonic max of the running total.
208
+ function applyClaudeThinkingEstimate(s, ev) {
209
+ const prev = s.thinkingEstTokens ?? 0;
210
+ const delta = Number(ev?.estimated_tokens_delta);
211
+ const total = Number(ev?.estimated_tokens);
212
+ if (Number.isFinite(delta) && delta > 0)
213
+ s.thinkingEstTokens = prev + delta;
214
+ else if (Number.isFinite(total))
215
+ s.thinkingEstTokens = Math.max(prev, total);
216
+ }
202
217
  const CLAUDE_FILE_READING_TOOLS = new Set(['Read']);
203
218
  function isClaudeFileReadingTool(toolName) {
204
219
  return !!toolName && CLAUDE_FILE_READING_TOOLS.has(toolName);
@@ -410,16 +425,25 @@ export function claudeParse(ev, s) {
410
425
  const advertised = claudeContextWindowFromModel(s.model);
411
426
  s.contextWindow = claudeEffectiveContextWindow(advertised) ?? s.contextWindow;
412
427
  }
428
+ // Live thinking progress: during extended thinking a subscription account streams no
429
+ // plaintext (signature_delta only) and no usage until the message settles — these estimates
430
+ // are the only sign the model is working.
431
+ if (ev.subtype === 'thinking_tokens') {
432
+ applyClaudeThinkingEstimate(s, ev);
433
+ recomputeClaudeContextUsed(s);
434
+ }
413
435
  }
414
436
  if (t === 'stream_event') {
415
437
  const inner = ev.event || {};
416
438
  if (inner.type === 'message_start') {
417
439
  const u = inner.message?.usage;
418
- s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
440
+ // A message that never delivered real output_tokens keeps its thinking estimate as the carry.
441
+ s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + Math.max(s.outputTokens ?? 0, s.thinkingEstTokens ?? 0);
419
442
  s.inputTokens = u?.input_tokens ?? 0;
420
443
  s.cachedInputTokens = u?.cache_read_input_tokens ?? 0;
421
444
  s.cacheCreationInputTokens = u?.cache_creation_input_tokens ?? 0;
422
445
  s.outputTokens = 0;
446
+ s.thinkingEstTokens = 0;
423
447
  recomputeClaudeContextUsed(s);
424
448
  }
425
449
  if (inner.type === 'content_block_start') {
@@ -449,8 +473,11 @@ export function claudeParse(ev, s) {
449
473
  s.cachedInputTokens = u.cache_read_input_tokens;
450
474
  if (u.cache_creation_input_tokens != null)
451
475
  s.cacheCreationInputTokens = u.cache_creation_input_tokens;
452
- if (u.output_tokens != null)
476
+ // Real reported output supersedes the live thinking estimate for this message.
477
+ if (u.output_tokens != null) {
453
478
  s.outputTokens = u.output_tokens;
479
+ s.thinkingEstTokens = 0;
480
+ }
454
481
  recomputeClaudeContextUsed(s);
455
482
  }
456
483
  }
@@ -684,6 +711,7 @@ export function createClaudeStreamState(opts) {
684
711
  cachedInputTokens: null,
685
712
  cacheCreationInputTokens: null,
686
713
  turnOutputTokensBase: 0,
714
+ thinkingEstTokens: 0,
687
715
  turnUsageMsgId: null,
688
716
  contextWindow: byokWindow,
689
717
  byokContextWindow: byokWindow,
@@ -719,6 +747,7 @@ function resetClaudeTurnState(s, note) {
719
747
  s.inputTokens = null;
720
748
  s.outputTokens = null;
721
749
  s.turnOutputTokensBase = 0;
750
+ s.thinkingEstTokens = 0;
722
751
  s.turnUsageMsgId = null;
723
752
  s.cachedInputTokens = null;
724
753
  s.cacheCreationInputTokens = null;
@@ -1758,7 +1787,7 @@ function hydrateSubAgentBlocksFromSidecar(richMsgs, subAgentsDir) {
1758
1787
  const CLAUDE_MODELS = [
1759
1788
  { id: 'claude-fable-5', alias: 'fable' },
1760
1789
  { id: 'claude-opus-4-8', alias: 'opus' },
1761
- { id: 'claude-sonnet-4-6', alias: 'sonnet' },
1790
+ { id: 'claude-sonnet-5', alias: 'sonnet' },
1762
1791
  { id: 'claude-haiku-4-5-20251001', alias: 'haiku' },
1763
1792
  ];
1764
1793
  const CLAUDE_USAGE_QUERY_TTL_MS = 5 * 60_000;
@@ -1856,6 +1885,42 @@ async function fetchClaudeUsageFromOAuth(tokenOverride) {
1856
1885
  clearTimeout(timer);
1857
1886
  }
1858
1887
  }
1888
+ // Single read channel for the native / default-login quota (`/api/oauth/usage` with the keychain
1889
+ // token). The read costs no inference tokens but the endpoint RATE-LIMITS aggressive polling
1890
+ // (observed: `rate_limit_error` payloads, which parse to null) — so `fresh` re-reads only after
1891
+ // 15s, and a failed read backs off 60s serving the last good result. Concurrent callers share
1892
+ // one request.
1893
+ const NATIVE_USAGE_FRESH_TTL_MS = 15_000;
1894
+ const NATIVE_USAGE_RETRY_TTL_MS = 60_000;
1895
+ const claudeNativeUsageLive = { at: 0, failedAt: 0, inflight: null };
1896
+ export function claudeNativeUsage(opts) {
1897
+ const maxAge = opts?.fresh ? NATIVE_USAGE_FRESH_TTL_MS : CLAUDE_USAGE_QUERY_TTL_MS;
1898
+ const now = Date.now();
1899
+ if (claudeUsageCache.lastGood && now - claudeNativeUsageLive.at < maxAge) {
1900
+ return Promise.resolve(claudeUsageCache.lastGood);
1901
+ }
1902
+ if (now - claudeNativeUsageLive.failedAt < NATIVE_USAGE_RETRY_TTL_MS) {
1903
+ return Promise.resolve(claudeUsageCache.lastGood);
1904
+ }
1905
+ if (claudeNativeUsageLive.inflight)
1906
+ return claudeNativeUsageLive.inflight;
1907
+ const p = fetchClaudeUsageFromOAuth()
1908
+ .then(fresh => {
1909
+ if (fresh) {
1910
+ claudeUsageCache.lastGood = fresh;
1911
+ claudeUsageCache.lastAttemptAt = Date.now();
1912
+ claudeNativeUsageLive.at = Date.now();
1913
+ claudeNativeUsageLive.failedAt = 0;
1914
+ }
1915
+ else {
1916
+ claudeNativeUsageLive.failedAt = Date.now();
1917
+ }
1918
+ return fresh ?? claudeUsageCache.lastGood;
1919
+ })
1920
+ .finally(() => { claudeNativeUsageLive.inflight = null; });
1921
+ claudeNativeUsageLive.inflight = p;
1922
+ return p;
1923
+ }
1859
1924
  // Per-account usage for a specific account's `claude setup-token`.
1860
1925
  //
1861
1926
  // IMPORTANT: setup-tokens are minted with the `user:inference` scope only — they do NOT carry
@@ -1864,10 +1929,15 @@ async function fetchClaudeUsageFromOAuth(tokenOverride) {
1864
1929
  // there because it's a full login. So for account tokens we read the limit state from the
1865
1930
  // `anthropic-ratelimit-unified-*` *response headers* of a tiny inference call instead — those
1866
1931
  // headers come back on every `/v1/messages` response regardless of scope, and are exactly what
1867
- // Claude Code itself uses to learn 5h / 7d limits. Cost is ~1 output token per probe, cached
1868
- // for 5min per token (60s back-off on failure), with in-flight de-dup so concurrent surfaces
1869
- // (cards + header + IM) share one probe.
1932
+ // Claude Code itself uses to learn 5h / 7d limits. Cost is ~1 output token per probe, so reads
1933
+ // are tiered by how fresh the caller needs to be:
1934
+ // default -> 5min TTL (background warmers, non-interactive surfaces)
1935
+ // fresh -> 20s min re-probe interval (user is actively looking at the numbers)
1936
+ // force -> bypass everything (identity just changed, e.g. account switch)
1937
+ // Failures back off 60s on the default/fresh tiers (force still retries), and in-flight de-dup
1938
+ // makes concurrent surfaces (cards + header + IM) share one probe.
1870
1939
  const TOKEN_USAGE_OK_TTL_MS = CLAUDE_USAGE_QUERY_TTL_MS;
1940
+ const TOKEN_USAGE_FRESH_TTL_MS = 20_000;
1871
1941
  const TOKEN_USAGE_RETRY_TTL_MS = 60_000;
1872
1942
  const CLAUDE_USAGE_PROBE_MODEL = 'claude-haiku-4-5-20251001';
1873
1943
  const claudeTokenUsageCache = new Map();
@@ -1939,7 +2009,9 @@ export function claudeUsageForToken(token, opts) {
1939
2009
  const now = Date.now();
1940
2010
  const cached = claudeTokenUsageCache.get(t);
1941
2011
  if (cached && !opts?.force) {
1942
- const ttl = cached.ok ? TOKEN_USAGE_OK_TTL_MS : TOKEN_USAGE_RETRY_TTL_MS;
2012
+ const ttl = cached.ok
2013
+ ? (opts?.fresh ? TOKEN_USAGE_FRESH_TTL_MS : TOKEN_USAGE_OK_TTL_MS)
2014
+ : TOKEN_USAGE_RETRY_TTL_MS;
1943
2015
  if (now - cached.at < ttl)
1944
2016
  return Promise.resolve(cached.value);
1945
2017
  }
@@ -2230,21 +2302,16 @@ class ClaudeDriver {
2230
2302
  }
2231
2303
  return claudeUsageCache.lastGood ?? telemetry();
2232
2304
  }
2233
- // Live usage for the agent-status surface. Unlike getUsage it ignores the 5-min query TTL and
2234
- // always re-probes, so a freshly-switched login (the keychain default-login token changed) is
2235
- // reflected promptly instead of serving the previous account's frozen `lastGood`. Bounded by
2236
- // the caller's usage timeout, with the cached value as fallback.
2305
+ // Live usage for the agent-status surface. Unlike getUsage it ignores the 5-min query TTL
2306
+ // (modulo the short fresh window that coalesces bursts), so a freshly-switched login (the
2307
+ // keychain default-login token changed) is reflected promptly instead of serving the previous
2308
+ // account's frozen `lastGood`. Bounded by the caller's usage timeout, cached value as fallback.
2237
2309
  async getUsageLive(opts) {
2238
2310
  const home = getHome();
2239
2311
  if (!home)
2240
2312
  return emptyUsage('claude', 'HOME is not set.');
2241
- const fresh = await fetchClaudeUsageFromOAuth();
2242
- if (fresh) {
2243
- claudeUsageCache.lastGood = fresh;
2244
- claudeUsageCache.lastAttemptAt = Date.now();
2245
- return fresh;
2246
- }
2247
- return claudeUsageCache.lastGood
2313
+ const fresh = await claudeNativeUsage({ fresh: true });
2314
+ return fresh
2248
2315
  ?? getClaudeUsageFromTelemetry(home, opts.model)
2249
2316
  ?? emptyUsage('claude', 'No recent Claude usage data found.');
2250
2317
  }
@@ -125,6 +125,21 @@ export function toPikiloomPlan(plan) {
125
125
  .filter((st) => st.step.trim());
126
126
  return steps.length ? { explanation: typeof plan.explanation === 'string' ? plan.explanation : null, steps } : null;
127
127
  }
128
+ // Final-result token projection (kernel UniversalUsage -> StreamResult fields). contextPercent
129
+ // must flow through: the IM final footer (feishu/telegram formatFinalFooter) reads
130
+ // result.contextPercent, so nulling it here showed the % on the live footer (preview meta carries
131
+ // it) but dropped it from the finished message. Pure + exported for regression testing.
132
+ export function kernelUsageToResultFields(u) {
133
+ return {
134
+ inputTokens: u?.inputTokens ?? null,
135
+ outputTokens: u?.outputTokens ?? null,
136
+ cachedInputTokens: u?.cachedInputTokens ?? null,
137
+ cacheCreationInputTokens: null,
138
+ contextWindow: null,
139
+ contextUsedTokens: u?.contextUsedTokens ?? null,
140
+ contextPercent: u?.contextPercent ?? null,
141
+ };
142
+ }
128
143
  let _kernel = null;
129
144
  export async function loadKernel() {
130
145
  if (_kernel)
@@ -240,7 +255,7 @@ export async function kernelStream(opts) {
240
255
  const finalError = (opts.agent === 'codex' && result.error)
241
256
  ? (humanizeCodexError(result.error) ?? result.error)
242
257
  : (result.error ?? null);
243
- const u = result.usage || snapshot.usage || {};
258
+ const usageFields = kernelUsageToResultFields(result.usage || snapshot.usage || {});
244
259
  return {
245
260
  ok: !!result.ok,
246
261
  // Empty-text fallback (mirrors the legacy driver): a clean turn with no prose reads
@@ -261,13 +276,7 @@ export async function kernelStream(opts) {
261
276
  model: input.model ?? null,
262
277
  thinkingEffort: opts.thinkingEffort,
263
278
  elapsedS: (Date.now() - start) / 1000,
264
- inputTokens: u.inputTokens ?? null,
265
- outputTokens: u.outputTokens ?? null,
266
- cachedInputTokens: u.cachedInputTokens ?? null,
267
- cacheCreationInputTokens: null,
268
- contextWindow: null,
269
- contextUsedTokens: u.contextUsedTokens ?? null,
270
- contextPercent: null,
279
+ ...usageFields,
271
280
  codexCumulative: null,
272
281
  error: finalError,
273
282
  stopReason: result.stopReason ?? null,
@@ -218,7 +218,9 @@ export function buildStreamPreviewMeta(s) {
218
218
  cachedInputTokens: s.cachedInputTokens,
219
219
  contextUsedTokens: ctx.contextUsedTokens, contextPercent: ctx.contextPercent,
220
220
  };
221
- const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
221
+ // The live thinking estimate stands in for output while a message is still streaming
222
+ // (silent extended thinking); the real output_tokens supersedes it once reported.
223
+ const turnOutput = (s.turnOutputTokensBase ?? 0) + Math.max(s.outputTokens ?? 0, s.thinkingEstTokens ?? 0);
222
224
  if (turnOutput > 0)
223
225
  meta.turnOutputTokens = turnOutput;
224
226
  if (s.byokProviderName)
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { fmtTokens, fmtUptime, fmtBytes } from './bot.js';
4
4
  import { getProjectSkillPaths, normalizeClaudeModelId, sessionListDisplayTitle, listAllMcpExtensions, listSkills as listAllSkills, } from '../agent/index.js';
5
5
  import { getDriver } from '../agent/driver.js';
6
- import { accountAgentSupported, listAccounts, getActiveAccountId, probeAccountUsage, getCachedAccountUsage, } from '../agent/accounts.js';
6
+ import { accountAgentSupported, listAccounts, getActiveAccountId, getCachedAccountUsage, getAccountsUsageSnapshot, } from '../agent/accounts.js';
7
7
  import { withTimeoutFallback } from '../core/utils.js';
8
8
  import { effortOptionsFor } from '../core/config/runtime-config.js';
9
9
  import { getActiveProfile, getProvider } from '../model/index.js';
@@ -445,12 +445,18 @@ export async function getUsageOverview(bot, chatId) {
445
445
  if (accountAgentSupported(agent)) {
446
446
  const recs = listAccounts(agent);
447
447
  if (recs.length) {
448
- const activeId = getActiveAccountId(agent);
449
- const usages = await Promise.all(recs.map(rec => withTimeoutFallback(probeAccountUsage(agent, rec.id).catch(() => getCachedAccountUsage(agent, rec.id)), USAGE_PROBE_TIMEOUT_MS, getCachedAccountUsage(agent, rec.id))));
450
- recs.forEach((rec, i) => accounts.push({
451
- id: rec.id, label: rec.label, active: rec.id === activeId, usage: usages[i],
452
- }));
453
- accounts.push({ id: null, label: 'Default login', active: activeId === null, usage });
448
+ // Same unified pass as the dashboard popover (fresh tier, debounced in the driver
449
+ // caches); on timeout fall back to whatever each account last probed.
450
+ const snap = await withTimeoutFallback(getAccountsUsageSnapshot(agent, { fresh: true }).catch(() => null), USAGE_PROBE_TIMEOUT_MS, null);
451
+ const activeId = snap?.activeAccountId ?? getActiveAccountId(agent);
452
+ for (const rec of recs) {
453
+ const row = snap?.accounts.find(a => a.id === rec.id);
454
+ accounts.push({
455
+ id: rec.id, label: rec.label, active: rec.id === activeId,
456
+ usage: row?.usage ?? getCachedAccountUsage(agent, rec.id),
457
+ });
458
+ }
459
+ accounts.push({ id: null, label: 'Default login', active: activeId === null, usage: snap?.native ?? usage });
454
460
  }
455
461
  }
456
462
  return { agent, label: agentDisplayLabel(agent), isCurrent: agent === currentAgent, usage, accounts };
@@ -1,6 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { allDriverIds } from '../../agent/index.js';
3
- import { accountAgentSupported, listAccounts, getAccount, addAccount, updateAccount, removeAccount, getActiveAccountId, setActiveAccount, probeAccountUsage, MAX_ACCOUNTS_PER_AGENT, } from '../../agent/accounts.js';
3
+ import { accountAgentSupported, getAccount, addAccount, updateAccount, removeAccount, getActiveAccountId, setActiveAccount, probeAccountUsage, getAccountsUsageSnapshot, MAX_ACCOUNTS_PER_AGENT, } from '../../agent/accounts.js';
4
4
  import { invalidateAgentStatus } from './agents.js';
5
5
  const app = new Hono();
6
6
  function agentError(agent) {
@@ -26,11 +26,20 @@ app.get('/api/agents/:agent/accounts', async (c) => {
26
26
  if (!allDriverIds().includes(agent))
27
27
  return c.json({ ok: false, error: `Unknown agent: ${agent}` }, 400);
28
28
  if (!accountAgentSupported(agent)) {
29
- return c.json({ ok: true, agent, supported: false, accounts: [], activeAccountId: null, max: MAX_ACCOUNTS_PER_AGENT });
29
+ return c.json({ ok: true, agent, supported: false, accounts: [], activeAccountId: null, nativeUsage: null, max: MAX_ACCOUNTS_PER_AGENT });
30
30
  }
31
- const activeId = getActiveAccountId(agent);
32
- const accounts = await Promise.all(listAccounts(agent).map(r => publicAccount(agent, r, activeId)));
33
- return c.json({ ok: true, agent, supported: true, accounts, activeAccountId: activeId, max: MAX_ACCOUNTS_PER_AGENT });
31
+ // `fresh=1` = the user is actively looking (popover open / panel refresh): re-probe past the
32
+ // short fresh window. The min-interval debounce lives in the driver caches, so this is safe to
33
+ // send on every hover. Account rows and the default-login quota come from the same pass.
34
+ const fresh = c.req.query('fresh') === '1';
35
+ const snap = await getAccountsUsageSnapshot(agent, { fresh });
36
+ return c.json({
37
+ ok: true, agent, supported: true,
38
+ accounts: snap.accounts,
39
+ activeAccountId: snap.activeAccountId,
40
+ nativeUsage: snap.native,
41
+ max: MAX_ACCOUNTS_PER_AGENT,
42
+ });
34
43
  });
35
44
  app.post('/api/agents/:agent/accounts', async (c) => {
36
45
  const agent = c.req.param('agent');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.48",
3
+ "version": "0.4.50",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,8 +25,10 @@ export interface ClaudeUsageState {
25
25
  cacheCreation?: number | null;
26
26
  contextWindow?: number | null;
27
27
  turnOutputTokensBase?: number | null;
28
+ thinkingEstTokens?: number | null;
28
29
  }
29
30
  export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
31
+ export declare function applyClaudeThinkingEstimate(s: any, ev: any): boolean;
30
32
  export declare function claudeContextWindowFromModel(model: unknown): number | null;
31
33
  export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
32
34
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
@@ -60,7 +60,7 @@ export class ClaudeDriver {
60
60
  stopReason: null, error: null,
61
61
  input: null, output: null, cached: null,
62
62
  cacheCreation: null,
63
- contextWindow: null, turnOutputTokensBase: 0,
63
+ contextWindow: null, turnOutputTokensBase: 0, thinkingEstTokens: 0,
64
64
  subAgents: new Map(),
65
65
  tools: new Map(),
66
66
  taskList: new Map(),
@@ -288,10 +288,16 @@ export class ClaudeDriver {
288
288
  }
289
289
  }
290
290
  export function claudeUsageOf(s) {
291
- const used = (s.input ?? 0) + (s.cached ?? 0) + (s.cacheCreation ?? 0) + (s.output ?? 0);
291
+ // While a message is still streaming, the CLI's live thinking estimate (system/thinking_tokens)
292
+ // is often the ONLY output signal — subscription accounts stream no plaintext thinking and no
293
+ // usage until the message settles. Fold it into the derived numbers (never into the raw
294
+ // outputTokens) so the row ticks during silent extended thinking; the real per-message
295
+ // output_tokens supersedes it at message_delta.
296
+ const effOutput = Math.max(s.output ?? 0, s.thinkingEstTokens ?? 0);
297
+ const used = (s.input ?? 0) + (s.cached ?? 0) + (s.cacheCreation ?? 0) + effOutput;
292
298
  const window = s.contextWindow ?? null;
293
299
  const contextPercent = window && used > 0 ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
294
- const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.output ?? 0);
300
+ const turnOutput = (s.turnOutputTokensBase ?? 0) + effOutput;
295
301
  return {
296
302
  inputTokens: s.input,
297
303
  outputTokens: s.output,
@@ -301,6 +307,19 @@ export function claudeUsageOf(s) {
301
307
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
302
308
  };
303
309
  }
310
+ // Accumulate the CLI's live thinking-token estimate onto driver state. Prefer the per-event
311
+ // delta (correct whether the CLI's running total is per-message or per-turn); fall back to a
312
+ // monotonic max of the running total. Returns true when the estimate advanced.
313
+ export function applyClaudeThinkingEstimate(s, ev) {
314
+ const prev = s.thinkingEstTokens ?? 0;
315
+ const delta = Number(ev?.estimated_tokens_delta);
316
+ const total = Number(ev?.estimated_tokens);
317
+ if (Number.isFinite(delta) && delta > 0)
318
+ s.thinkingEstTokens = prev + delta;
319
+ else if (Number.isFinite(total))
320
+ s.thinkingEstTokens = Math.max(prev, total);
321
+ return (s.thinkingEstTokens ?? 0) > prev;
322
+ }
304
323
  // Advertised context window by Claude model id (best-effort; unknown -> null so the
305
324
  // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
306
325
  // (us.anthropic.claude-…) still match.
@@ -359,6 +378,13 @@ export function handleClaudeEvent(ev, s, emit) {
359
378
  }
360
379
  s.model = ev.model ?? s.model;
361
380
  s.contextWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(s.model)) ?? s.contextWindow;
381
+ // Live thinking progress (system/thinking_tokens, ~every 1.4s of sustained thinking): during
382
+ // extended thinking a subscription account streams no plaintext (signature_delta only) and no
383
+ // usage until the message settles, so without projecting these the terminal shows a dead
384
+ // spinner for the whole thinking phase.
385
+ if (ev.subtype === 'thinking_tokens' && applyClaudeThinkingEstimate(s, ev)) {
386
+ emit({ type: 'usage', usage: claudeUsageOf(s) });
387
+ }
362
388
  return;
363
389
  }
364
390
  if (t === 'stream_event') {
@@ -368,11 +394,17 @@ export function handleClaudeEvent(ev, s, emit) {
368
394
  // Claude emits one message per tool-use round within a single turn. Carry the prior
369
395
  // message's output into the per-turn base, then reset to the new message's prompt size
370
396
  // so contextUsedTokens tracks current occupancy while turnOutputTokens sums the turn.
371
- s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.output ?? 0);
397
+ // A message that never delivered real output_tokens keeps its thinking estimate as the carry.
398
+ s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + Math.max(s.output ?? 0, s.thinkingEstTokens ?? 0);
372
399
  s.input = u?.input_tokens ?? 0;
373
400
  s.cached = u?.cache_read_input_tokens ?? 0;
374
401
  s.cacheCreation = u?.cache_creation_input_tokens ?? 0;
375
402
  s.output = 0;
403
+ s.thinkingEstTokens = 0;
404
+ // The prompt-side counts are known the moment the model accepts the request — emit them so
405
+ // the context row appears right away instead of only after the first message settles
406
+ // (minutes into a long silent thinking phase, the "looks stuck" report).
407
+ emit({ type: 'usage', usage: claudeUsageOf(s) });
376
408
  }
377
409
  else if (inner.type === 'content_block_start') {
378
410
  // Claude emits multiple text/thinking blocks per turn (one set per tool-use round). Insert a
@@ -407,8 +439,11 @@ export function handleClaudeEvent(ev, s, emit) {
407
439
  else if (inner.type === 'message_delta') {
408
440
  const u = inner.usage;
409
441
  if (u) {
410
- if (u.output_tokens != null)
442
+ // Real reported output supersedes the live thinking estimate for this message.
443
+ if (u.output_tokens != null) {
411
444
  s.output = u.output_tokens;
445
+ s.thinkingEstTokens = 0;
446
+ }
412
447
  if (u.input_tokens != null)
413
448
  s.input = u.input_tokens;
414
449
  if (u.cache_read_input_tokens != null)
@@ -515,8 +550,13 @@ export function handleClaudeEvent(ev, s, emit) {
515
550
  for (const b of contents) {
516
551
  if (b?.type !== 'tool_result')
517
552
  continue;
518
- emitClaudeImages(b.content || [], s, emit);
519
553
  const id = String(b.tool_use_id || '').trim();
554
+ // A Read result is the agent inspecting a file, not a deliverable — surfacing those spammed
555
+ // the chat with every image the agent looked at (the legacy driver has the same exclusion).
556
+ // Images from generating tools (MCP image-gen, screenshots, …) still surface as photos.
557
+ const tool = id ? s.tools?.get(id) : undefined;
558
+ if (tool?.name !== 'Read')
559
+ emitClaudeImages(b.content || [], s, emit);
520
560
  // TaskCreate result: the assigned task id arrives here; register the task and emit the plan.
521
561
  if (id && s.pendingTaskCreates?.has(id)) {
522
562
  const pending = s.pendingTaskCreates.get(id);
@@ -534,7 +574,6 @@ export function handleClaudeEvent(ev, s, emit) {
534
574
  }
535
575
  continue;
536
576
  }
537
- const tool = id ? s.tools?.get(id) : undefined;
538
577
  if (!tool)
539
578
  continue;
540
579
  const isError = !!b.is_error;
@@ -137,6 +137,9 @@ function codexFileChangeSummary(item) {
137
137
  // fallback to `item.type` wrongly turned every item (incl. agentMessage/reasoning) into a bogus
138
138
  // "tool" in the Activity card. Mirrors the legacy driver's isCodexToolCallItem whitelist.
139
139
  const CODEX_TOOL_CALL_TYPES = new Set(['dynamicToolCall', 'mcpToolCall', 'collabAgentToolCall']);
140
+ // Field-less placeholder summaries — a completed item with only one of these never overrides
141
+ // a more specific summary cached at item/started.
142
+ const CODEX_GENERIC_TOOL_SUMMARIES = new Set(['Search web', 'Run shell command', 'Edit files', 'Use tool']);
140
143
  export function codexToolSummary(item) {
141
144
  const id = String(item?.id || '');
142
145
  if (!id)
@@ -147,6 +150,20 @@ export function codexToolSummary(item) {
147
150
  }
148
151
  if (item.type === 'fileChange' || item.type === 'patch')
149
152
  return { id, name: 'edit', summary: codexFileChangeSummary(item) };
153
+ if (item.type === 'webSearch') {
154
+ // v2 webSearch items carry the query at top level and (on newer servers) an action
155
+ // (search / openPage / findInPage, snake_case on older ones). The query is often only
156
+ // known at item/completed — item/started may arrive query-less.
157
+ const action = item.action || {};
158
+ const kind = String(action.type || '');
159
+ const url = codexCommandPreview(action.url);
160
+ if ((kind === 'openPage' || kind === 'open_page') && url)
161
+ return { id, name: 'web_search', summary: `Open ${url}` };
162
+ if ((kind === 'findInPage' || kind === 'find_in_page') && url)
163
+ return { id, name: 'web_search', summary: `Find in ${url}` };
164
+ const query = codexCommandPreview(typeof item.query === 'string' && item.query.trim() ? item.query : action.query);
165
+ return { id, name: 'web_search', summary: query ? `Search web: ${query}` : 'Search web' };
166
+ }
150
167
  if (CODEX_TOOL_CALL_TYPES.has(item.type)) {
151
168
  const raw = typeof item.tool === 'string' && item.tool.trim() ? item.tool.trim()
152
169
  : typeof item.name === 'string' && item.name.trim() ? item.name.trim() : '';
@@ -343,8 +360,14 @@ export class CodexDriver {
343
360
  else if (item.type === 'reasoning')
344
361
  captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
345
362
  const t = codexToolSummary(item);
346
- if (t && toolSummaries.has(t.id))
347
- ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary: toolSummaries.get(t.id) || t.summary, status: item.status === 'failed' ? 'failed' : 'done' } });
363
+ if (t) {
364
+ // Prefer the completed item's summary when it is specific webSearch carries its
365
+ // query only at completion (item/started may be query-less or absent entirely,
366
+ // so no `toolSummaries.has` gate: a completed-only tool still gets its row).
367
+ const summary = !CODEX_GENERIC_TOOL_SUMMARIES.has(t.summary) ? t.summary : (toolSummaries.get(t.id) || t.summary);
368
+ toolSummaries.set(t.id, summary);
369
+ ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary, status: item.status === 'failed' ? 'failed' : 'done' } });
370
+ }
348
371
  break;
349
372
  }
350
373
  case 'rawResponseItem/completed': {