@pikiloom/kernel 0.2.11 → 0.2.13
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.
- package/dist/drivers/claude.d.ts +3 -0
- package/dist/drivers/claude.js +90 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/drivers/claude.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.13",
|
|
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",
|