@pikiloom/kernel 0.2.7 → 0.2.9

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.
@@ -25,13 +25,17 @@ 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;
33
35
  export declare function claudeBgHoldCapMs(): number;
34
36
  export declare function claudeBgSettleQuietMs(): number;
37
+ export declare function claudeModelStallMs(): number;
38
+ export declare function claudeUserEventHasToolResult(ev: any): boolean;
35
39
  export declare function isTerminalTaskStatus(status: unknown): boolean;
36
40
  export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
37
41
  export declare function markClaudeTaskNotificationTerminal(content: any, s: any): 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(),
@@ -77,6 +77,10 @@ export class ClaudeDriver {
77
77
  // wake-up turns can still land before we close (see claudeBgSettleQuietMs).
78
78
  let holdCapTimer = null;
79
79
  let quietTimer = null;
80
+ // modelStall: post-tool watchdog — fires only while the turn is waiting on the MODEL (a
81
+ // tool_result handed control back and no reply is streaming), never while a tool or
82
+ // background task is still running (see armModelStall).
83
+ let modelStallTimer = null;
80
84
  const usageOf = () => this.usage(state);
81
85
  const unref = (tm) => { if (tm && typeof tm.unref === 'function')
82
86
  tm.unref(); };
@@ -88,6 +92,10 @@ export class ClaudeDriver {
88
92
  clearTimeout(quietTimer);
89
93
  quietTimer = null;
90
94
  } };
95
+ const clearModelStall = () => { if (modelStallTimer) {
96
+ clearTimeout(modelStallTimer);
97
+ modelStallTimer = null;
98
+ } };
91
99
  // kill=true SIGTERMs immediately — fast exit, used once nothing is left running in the
92
100
  // background (a normal turn, or a wake-up turn after every background task finished).
93
101
  // kill=false only ends stdin and lets Claude shut down on its own, so any still-running
@@ -99,6 +107,7 @@ export class ClaudeDriver {
99
107
  settled = true;
100
108
  clearHoldCap();
101
109
  clearQuiet();
110
+ clearModelStall();
102
111
  try {
103
112
  child?.stdin?.end();
104
113
  }
@@ -121,7 +130,7 @@ export class ClaudeDriver {
121
130
  resolve(r);
122
131
  };
123
132
  const settleResult = (opts = {}) => finish({
124
- ok: !state.error, text: state.text, reasoning: state.reasoning || undefined,
133
+ ok: opts.ok ?? !state.error, text: state.text, reasoning: state.reasoning || undefined,
125
134
  error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, usage: usageOf(),
126
135
  }, opts.kill ?? true);
127
136
  // Absolute cap while holding for a still-running background task (stopReason marks it as
@@ -141,6 +150,29 @@ export class ClaudeDriver {
141
150
  settleResult({ kill: false }); }, claudeBgSettleQuietMs());
142
151
  unref(quietTimer);
143
152
  };
153
+ // Post-tool model-stall watchdog: after a tool_result hands control back to the model, the
154
+ // model normally streams its next message within a couple of seconds. If instead it goes
155
+ // fully silent — no stream/assistant events — the turn is stuck waiting on the MODEL (a
156
+ // provider stall / rate-limit backoff), NOT on a tool (a running tool has no tool_result
157
+ // yet, so this is never armed then) and NOT on background work (its own hold, re-checked
158
+ // below). Left alone the kernel turn hangs forever with the answer never delivered. Bound
159
+ // it: settle gracefully (no kill) as incomplete with stopReason 'stalled' so the terminal
160
+ // shows a clear "resend to continue" note instead of a dead spinner. Armed on tool_result,
161
+ // cleared the moment the model emits anything.
162
+ const armModelStall = () => {
163
+ clearModelStall();
164
+ modelStallTimer = setTimeout(() => {
165
+ if (settled)
166
+ return;
167
+ // Background work legitimately produces no stream events; keep waiting (the bg hold owns it).
168
+ if (pendingClaudeBackgroundTasks(state) > 0) {
169
+ armModelStall();
170
+ return;
171
+ }
172
+ settleResult({ stopReason: 'stalled', kill: false, ok: false });
173
+ }, claudeModelStallMs());
174
+ unref(modelStallTimer);
175
+ };
144
176
  try {
145
177
  child = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
146
178
  }
@@ -190,7 +222,11 @@ export class ClaudeDriver {
190
222
  }
191
223
  handleClaudeEvent(ev, state, ctx.emit);
192
224
  const pending = pendingClaudeBackgroundTasks(state);
225
+ // Any model output means the model is alive and streaming — cancel the post-tool stall watchdog.
226
+ if (ev.type === 'stream_event' || ev.type === 'assistant')
227
+ clearModelStall();
193
228
  if (ev.type === 'result') {
229
+ clearModelStall();
194
230
  const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
195
231
  const decision = decideClaudeResultSettle({ hasError, pendingBackground: pending, sawBackground: state.bgStarted.size > 0 });
196
232
  if (decision === 'settle') {
@@ -221,6 +257,10 @@ export class ClaudeDriver {
221
257
  else
222
258
  armQuiet();
223
259
  }
260
+ // A tool_result handed control back to the model with no background pending — arm the
261
+ // post-tool stall watchdog. Cleared above the moment the model's next event streams in.
262
+ if (ev.type === 'user' && pending === 0 && claudeUserEventHasToolResult(ev))
263
+ armModelStall();
224
264
  }
225
265
  });
226
266
  child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
@@ -248,10 +288,16 @@ export class ClaudeDriver {
248
288
  }
249
289
  }
250
290
  export function claudeUsageOf(s) {
251
- 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;
252
298
  const window = s.contextWindow ?? null;
253
299
  const contextPercent = window && used > 0 ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
254
- const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.output ?? 0);
300
+ const turnOutput = (s.turnOutputTokensBase ?? 0) + effOutput;
255
301
  return {
256
302
  inputTokens: s.input,
257
303
  outputTokens: s.output,
@@ -261,6 +307,19 @@ export function claudeUsageOf(s) {
261
307
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
262
308
  };
263
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
+ }
264
323
  // Advertised context window by Claude model id (best-effort; unknown -> null so the
265
324
  // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
266
325
  // (us.anthropic.claude-…) still match.
@@ -319,6 +378,13 @@ export function handleClaudeEvent(ev, s, emit) {
319
378
  }
320
379
  s.model = ev.model ?? s.model;
321
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
+ }
322
388
  return;
323
389
  }
324
390
  if (t === 'stream_event') {
@@ -328,11 +394,17 @@ export function handleClaudeEvent(ev, s, emit) {
328
394
  // Claude emits one message per tool-use round within a single turn. Carry the prior
329
395
  // message's output into the per-turn base, then reset to the new message's prompt size
330
396
  // so contextUsedTokens tracks current occupancy while turnOutputTokens sums the turn.
331
- 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);
332
399
  s.input = u?.input_tokens ?? 0;
333
400
  s.cached = u?.cache_read_input_tokens ?? 0;
334
401
  s.cacheCreation = u?.cache_creation_input_tokens ?? 0;
335
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) });
336
408
  }
337
409
  else if (inner.type === 'content_block_start') {
338
410
  // Claude emits multiple text/thinking blocks per turn (one set per tool-use round). Insert a
@@ -367,8 +439,11 @@ export function handleClaudeEvent(ev, s, emit) {
367
439
  else if (inner.type === 'message_delta') {
368
440
  const u = inner.usage;
369
441
  if (u) {
370
- if (u.output_tokens != null)
442
+ // Real reported output supersedes the live thinking estimate for this message.
443
+ if (u.output_tokens != null) {
371
444
  s.output = u.output_tokens;
445
+ s.thinkingEstTokens = 0;
446
+ }
372
447
  if (u.input_tokens != null)
373
448
  s.input = u.input_tokens;
374
449
  if (u.cache_read_input_tokens != null)
@@ -475,8 +550,13 @@ export function handleClaudeEvent(ev, s, emit) {
475
550
  for (const b of contents) {
476
551
  if (b?.type !== 'tool_result')
477
552
  continue;
478
- emitClaudeImages(b.content || [], s, emit);
479
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);
480
560
  // TaskCreate result: the assigned task id arrives here; register the task and emit the plan.
481
561
  if (id && s.pendingTaskCreates?.has(id)) {
482
562
  const pending = s.pendingTaskCreates.get(id);
@@ -494,7 +574,6 @@ export function handleClaudeEvent(ev, s, emit) {
494
574
  }
495
575
  continue;
496
576
  }
497
- const tool = id ? s.tools?.get(id) : undefined;
498
577
  if (!tool)
499
578
  continue;
500
579
  const isError = !!b.is_error;
@@ -559,6 +638,23 @@ export function claudeBgSettleQuietMs() {
559
638
  const raw = Number(process.env.PIKILOOM_CLAUDE_BG_SETTLE_QUIET_MS);
560
639
  return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_SETTLE_QUIET_DEFAULT_MS;
561
640
  }
641
+ // How long the model may stay COMPLETELY silent after a tool_result (control handed back to it,
642
+ // no background pending) before the driver gives up and settles the turn as 'stalled'. Deliberately
643
+ // generous: legitimate silent extended-thinking (subscription accounts stream no thinking text) and
644
+ // slow providers must not trip it, and a still-running tool never trips it (it has no tool_result
645
+ // yet). This is the backstop for a turn that would otherwise hang forever with the answer never
646
+ // delivered. Override with PIKILOOM_CLAUDE_MODEL_STALL_MS.
647
+ const CLAUDE_MODEL_STALL_DEFAULT_MS = 120_000;
648
+ export function claudeModelStallMs() {
649
+ const raw = Number(process.env.PIKILOOM_CLAUDE_MODEL_STALL_MS);
650
+ return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_MODEL_STALL_DEFAULT_MS;
651
+ }
652
+ // True when a claude `type:'user'` stream event carries at least one tool_result block — i.e. the
653
+ // tool loop just handed control back to the model. Pure + exported for hermetic testing.
654
+ export function claudeUserEventHasToolResult(ev) {
655
+ const c = ev?.message?.content;
656
+ return Array.isArray(c) && c.some((b) => b?.type === 'tool_result');
657
+ }
562
658
  // After we settle a held turn gracefully (stdin closed, no kill), force-kill the lingering
563
659
  // process only if it hasn't exited on its own within this window. Backstop against leaks.
564
660
  const CLAUDE_EXIT_LEAK_GUARD_MS = 15_000;
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, To
9
9
  export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, SpawnContribution, } from './contracts/surface.js';
10
10
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
11
11
  export { EchoDriver } from './drivers/echo.js';
12
- export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, type ClaudeResultSettleDecision, } from './drivers/claude.js';
12
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, type ClaudeResultSettleDecision, claudeModelStallMs, claudeUserEventHasToolResult, } from './drivers/claude.js';
13
13
  export { CodexDriver } from './drivers/codex.js';
14
14
  export { GeminiDriver } from './drivers/gemini.js';
15
15
  export { AcpDriver, type AcpDriverConfig } from './drivers/acp.js';
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export { attachTui } from './runtime/tui.js';
19
19
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
20
20
  // Drivers & surfaces (also available via subpath exports)
21
21
  export { EchoDriver } from './drivers/echo.js';
22
- export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, } from './drivers/claude.js';
22
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, claudeModelStallMs, claudeUserEventHasToolResult, } from './drivers/claude.js';
23
23
  export { CodexDriver } from './drivers/codex.js';
24
24
  export { GeminiDriver } from './drivers/gemini.js';
25
25
  export { AcpDriver } from './drivers/acp.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",