@pikiloom/kernel 0.2.12 → 0.2.14

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.
@@ -35,11 +35,14 @@ export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent
35
35
  export declare function claudeBgHoldCapMs(): number;
36
36
  export declare function claudeBgSettleQuietMs(): number;
37
37
  export declare function claudeModelStallMs(): number;
38
+ export declare const CLAUDE_TRUNCATED_RECOVERY_PROMPT: string;
39
+ export declare function claudeTruncatedRecoveryEnabled(): boolean;
38
40
  export declare function claudeUserEventHasToolResult(ev: any): boolean;
39
41
  export declare function isTerminalTaskStatus(status: unknown): boolean;
40
42
  export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
41
43
  export declare function markClaudeTaskNotificationTerminal(content: any, s: any): void;
42
44
  export declare function pendingClaudeBackgroundTasks(s: any): number;
45
+ export declare function claudeTurnEndedDangling(s: any): boolean;
43
46
  export type ClaudeResultSettleDecision = 'settle' | 'hold' | 'quiet-settle';
44
47
  export declare function decideClaudeResultSettle(input: {
45
48
  hasError: boolean;
@@ -56,6 +56,9 @@ export class ClaudeDriver {
56
56
  args.push(...input.extraArgs);
57
57
  const state = {
58
58
  text: '', reasoning: '', streamedText: false, streamedReasoning: false,
59
+ // Dangling-tool-loop tracking: sawToolResult flips on the first tool_result; textSinceToolResult
60
+ // goes false at every tool_result and true again once the model streams visible text.
61
+ sawToolResult: false, textSinceToolResult: false,
59
62
  sessionId: null, model: null,
60
63
  stopReason: null, error: null,
61
64
  input: null, output: null, cached: null,
@@ -72,6 +75,8 @@ export class ClaudeDriver {
72
75
  return new Promise((resolve) => {
73
76
  let child;
74
77
  let settled = false;
78
+ // One-shot guard for the truncated-turn recovery injection (see the result handler).
79
+ let truncatedRecoveryAttempted = false;
75
80
  // holdCap: hard backstop while a background task is still running (never-completing daemon).
76
81
  // quiet: fires once all known background tasks finished AND Claude has gone quiet, so trailing
77
82
  // wake-up turns can still land before we close (see claudeBgSettleQuietMs).
@@ -230,7 +235,38 @@ export class ClaudeDriver {
230
235
  const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
231
236
  const decision = decideClaudeResultSettle({ hasError, pendingBackground: pending, sawBackground: state.bgStarted.size > 0 });
232
237
  if (decision === 'settle') {
233
- settleResult();
238
+ // A clean result that lands while the tool loop is still dangling means the model's
239
+ // closing round came back empty — the turn "completed" but the reply was never
240
+ // generated. First try to self-heal in-process (stdin is still open): inject one
241
+ // recovery prompt and keep reading; the follow-up round delivers the closing reply
242
+ // and its own result settles the turn normally. If recovery is disabled, already
243
+ // attempted, or the write fails, stamp the turn 'truncated' so the terminal can say
244
+ // so instead of showing a narration that stops mid-sentence as the full answer.
245
+ if (!hasError && claudeTurnEndedDangling(state)) {
246
+ if (claudeTruncatedRecoveryEnabled() && !truncatedRecoveryAttempted) {
247
+ truncatedRecoveryAttempted = true;
248
+ let injected = false;
249
+ try {
250
+ child.stdin.write(claudeUserMessage(CLAUDE_TRUNCATED_RECOVERY_PROMPT) + '\n');
251
+ injected = true;
252
+ }
253
+ catch { /* fall through to settle */ }
254
+ if (injected) {
255
+ armModelStall();
256
+ continue;
257
+ }
258
+ }
259
+ settleResult({ stopReason: 'truncated', kill: false });
260
+ return;
261
+ }
262
+ // kill=false: the CLI persists the turn into the session jsonl AFTER emitting
263
+ // `result`, and on a large session that flush (a whole-file rewrite) takes long
264
+ // enough that an immediate SIGTERM kills the process mid-write — the reply was
265
+ // DELIVERED live but never lands in the transcript, so the next re-render erases
266
+ // it (the "对话结束之后就被吞了" shape, caught by the turn audit). Ending stdin
267
+ // lets the CLI finish writing and exit on its own; the leak-guard SIGTERM is the
268
+ // backstop.
269
+ settleResult({ kill: false });
234
270
  return;
235
271
  }
236
272
  if (decision === 'hold') {
@@ -273,7 +309,12 @@ export class ClaudeDriver {
273
309
  return;
274
310
  }
275
311
  const ok = !state.error && code === 0;
276
- finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason: state.stopReason, sessionId: state.sessionId, usage: usageOf() }, false);
312
+ // A clean exit with no result while the tool loop dangles is the same swallowed-reply
313
+ // shape as the result-event case above — stamp it so it can't pass as a full answer.
314
+ // (state.stopReason is typically the tool round's leftover 'tool_use' here, so the
315
+ // dangling check must win over it.)
316
+ const stopReason = (ok && claudeTurnEndedDangling(state)) ? 'truncated' : state.stopReason;
317
+ finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason, sessionId: state.sessionId, usage: usageOf() }, false);
277
318
  });
278
319
  try {
279
320
  // Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
@@ -428,6 +469,7 @@ export function handleClaudeEvent(ev, s, emit) {
428
469
  if (d.type === 'text_delta' && d.text) {
429
470
  s.text += d.text;
430
471
  s.streamedText = true;
472
+ s.textSinceToolResult = true;
431
473
  emit({ type: 'text', delta: d.text });
432
474
  }
433
475
  else if (d.type === 'thinking_delta' && d.thinking) {
@@ -534,6 +576,7 @@ export function handleClaudeEvent(ev, s, emit) {
534
576
  const tx = contents.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n\n');
535
577
  if (tx) {
536
578
  s.text = tx;
579
+ s.textSinceToolResult = true;
537
580
  emit({ type: 'text', delta: tx });
538
581
  }
539
582
  }
@@ -547,6 +590,13 @@ export function handleClaudeEvent(ev, s, emit) {
547
590
  // (status done/failed + a result detail) so toolCalls is a faithful structured SSOT
548
591
  // and the runtime's activity projection can render the execution trail.
549
592
  const contents = Array.isArray(ev.message?.content) ? ev.message.content : [];
593
+ // A tool_result hands control back to the model; until the model produces visible text
594
+ // again the turn is "dangling" — a result/exit in that window means the closing reply
595
+ // was never generated (see claudeTurnEndedDangling).
596
+ if (contents.some((b) => b?.type === 'tool_result')) {
597
+ s.sawToolResult = true;
598
+ s.textSinceToolResult = false;
599
+ }
550
600
  for (const b of contents) {
551
601
  if (b?.type !== 'tool_result')
552
602
  continue;
@@ -587,10 +637,23 @@ export function handleClaudeEvent(ev, s, emit) {
587
637
  if (t === 'result') {
588
638
  if (ev.session_id)
589
639
  s.sessionId = ev.session_id;
590
- if (ev.is_error && Array.isArray(ev.errors) && ev.errors.length)
591
- s.error = ev.errors.join('; ');
592
- if (ev.result && !s.text.trim()) {
593
- s.text = ev.result;
640
+ // An error result may arrive with an EMPTY errors[] (e.g. subtype error_during_execution).
641
+ // Deriving a message anyway is what keeps the turn from settling as a silent "success" —
642
+ // the exact swallow where a mid-turn narration ends on a hanging colon and nothing follows.
643
+ const subtype = typeof ev.subtype === 'string' ? ev.subtype : '';
644
+ const isErrorResult = !!ev.is_error || subtype.startsWith('error');
645
+ if (isErrorResult && !s.error) {
646
+ const errs = (Array.isArray(ev.errors) ? ev.errors : [])
647
+ .map((x) => typeof x === 'string' ? x : (x?.message || (x ? JSON.stringify(x) : '')))
648
+ .filter((x) => x && x.trim());
649
+ s.error = errs.length ? errs.join('; ')
650
+ : (typeof ev.result === 'string' && ev.result.trim() ? ev.result.trim()
651
+ : `claude ended the turn with an error result${subtype ? ` (${subtype})` : ''}`);
652
+ }
653
+ if (!isErrorResult && typeof ev.result === 'string' && ev.result.trim()) {
654
+ if (!s.text.trim())
655
+ s.text = ev.result;
656
+ s.textSinceToolResult = true; // the closing reply arrived via the result payload
594
657
  }
595
658
  if (ev.stop_reason && !s.stopReason)
596
659
  s.stopReason = ev.stop_reason;
@@ -649,6 +712,20 @@ export function claudeModelStallMs() {
649
712
  const raw = Number(process.env.PIKILOOM_CLAUDE_MODEL_STALL_MS);
650
713
  return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_MODEL_STALL_DEFAULT_MS;
651
714
  }
715
+ // In-process self-heal for a truncated turn: when a clean result lands while the tool loop is
716
+ // still dangling (the model's closing round came back empty), the stdin is still open — inject
717
+ // ONE recovery user message and let the CLI run a follow-up round in the same process, so the
718
+ // closing reply the user is waiting for actually gets delivered instead of just being flagged.
719
+ // Once per turn; the post-tool stall watchdog is the safety net if the CLI never responds.
720
+ // The <pikiloom-recover> tag keeps the injected message out of pikiloom's transcript rendering.
721
+ // Disable with PIKILOOM_CLAUDE_TRUNCATED_RECOVERY=0.
722
+ export const CLAUDE_TRUNCATED_RECOVERY_PROMPT = '<pikiloom-recover>Your previous response ended after a tool call without a closing message. '
723
+ + 'Finish the reply now: state the outcome and anything the user still needs to know. '
724
+ + 'Do not re-run tools unless strictly necessary.</pikiloom-recover>';
725
+ export function claudeTruncatedRecoveryEnabled() {
726
+ const v = String(process.env.PIKILOOM_CLAUDE_TRUNCATED_RECOVERY ?? '').trim().toLowerCase();
727
+ return v !== '0' && v !== 'false' && v !== 'off';
728
+ }
652
729
  // True when a claude `type:'user'` stream event carries at least one tool_result block — i.e. the
653
730
  // tool loop just handed control back to the model. Pure + exported for hermetic testing.
654
731
  export function claudeUserEventHasToolResult(ev) {
@@ -713,6 +790,13 @@ export function pendingClaudeBackgroundTasks(s) {
713
790
  n++;
714
791
  return n;
715
792
  }
793
+ // A turn "ended dangling" when it used tools and no visible text arrived after the last
794
+ // tool_result — the model's closing round produced nothing (empty final response, a broken
795
+ // stream, or the CLI ending the turn early). The reply the user is waiting for never existed,
796
+ // so the settle must say so instead of reading as a normal completion.
797
+ export function claudeTurnEndedDangling(s) {
798
+ return !!s?.sawToolResult && !s?.textSinceToolResult;
799
+ }
716
800
  // At a `result` event, decide how to end the turn:
717
801
  // - error → 'settle' now (hard exit).
718
802
  // - a background task is still running → 'hold' the process open for its wake-up.
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, claudeModelStallMs, claudeUserEventHasToolResult, } from './drivers/claude.js';
12
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, type ClaudeResultSettleDecision, claudeModelStallMs, claudeUserEventHasToolResult, handleClaudeEvent, claudeTurnEndedDangling, claudeTruncatedRecoveryEnabled, CLAUDE_TRUNCATED_RECOVERY_PROMPT, } 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, claudeModelStallMs, claudeUserEventHasToolResult, } from './drivers/claude.js';
22
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, claudeModelStallMs, claudeUserEventHasToolResult, handleClaudeEvent, claudeTurnEndedDangling, claudeTruncatedRecoveryEnabled, CLAUDE_TRUNCATED_RECOVERY_PROMPT, } 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.12",
3
+ "version": "0.2.14",
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",