@pikiloom/kernel 0.2.3 → 0.2.5
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 +9 -0
- package/dist/drivers/claude.js +127 -27
- 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
|
@@ -30,6 +30,15 @@ export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
|
|
|
30
30
|
export declare function claudeContextWindowFromModel(model: unknown): number | null;
|
|
31
31
|
export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
|
|
32
32
|
export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
|
|
33
|
+
export declare function claudeBgHoldCapMs(): number;
|
|
34
|
+
export declare function isTerminalTaskStatus(status: unknown): boolean;
|
|
35
|
+
export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
|
|
36
|
+
export declare function pendingClaudeBackgroundTasks(s: any): number;
|
|
37
|
+
export type ClaudeResultSettleDecision = 'settle' | 'hold';
|
|
38
|
+
export declare function decideClaudeResultSettle(input: {
|
|
39
|
+
hasError: boolean;
|
|
40
|
+
pendingBackground: number;
|
|
41
|
+
}): ClaudeResultSettleDecision;
|
|
33
42
|
export declare function claudeUserMessage(text: string, attachments?: string[]): string;
|
|
34
43
|
export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
|
|
35
44
|
export declare function todoWriteToPlan(input: any): UniversalPlan | null;
|
package/dist/drivers/claude.js
CHANGED
|
@@ -30,10 +30,14 @@ export class ClaudeDriver {
|
|
|
30
30
|
}
|
|
31
31
|
run(input, ctx) {
|
|
32
32
|
const steerable = !!input.steerable;
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// Always drive Claude over a stream-json stdin and keep that stdin OPEN for the whole turn.
|
|
34
|
+
// That open stdin is what lets Claude's native "launch detached background work → end the
|
|
35
|
+
// turn → wake itself up and report when the work finishes" flow play out: it only happens
|
|
36
|
+
// while stdin stays open (with it closed, Claude exits at the first `result` and the wake-up
|
|
37
|
+
// — and any in-process background agent/workflow — is lost). Image attachments also need the
|
|
38
|
+
// stream-json user message (a plain text stdin can't carry images). `--replay-user-messages`
|
|
39
|
+
// + registerSteer remain the only mid-turn-steer-specific bits.
|
|
40
|
+
const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--input-format', 'stream-json'];
|
|
37
41
|
if (input.model)
|
|
38
42
|
args.push('--model', input.model);
|
|
39
43
|
if (input.effort)
|
|
@@ -46,8 +50,6 @@ export class ClaudeDriver {
|
|
|
46
50
|
args.push('--mcp-config', input.mcpConfigPath);
|
|
47
51
|
if (input.permissionMode)
|
|
48
52
|
args.push('--permission-mode', input.permissionMode); // parity: keep bypass/accept-edits on the kernel path
|
|
49
|
-
if (useStreamJson)
|
|
50
|
-
args.push('--input-format', 'stream-json');
|
|
51
53
|
if (steerable)
|
|
52
54
|
args.push('--replay-user-messages'); // parity: mid-turn steer
|
|
53
55
|
if (input.extraArgs?.length)
|
|
@@ -64,26 +66,54 @@ export class ClaudeDriver {
|
|
|
64
66
|
taskList: new Map(),
|
|
65
67
|
taskOrder: [],
|
|
66
68
|
pendingTaskCreates: new Map(),
|
|
69
|
+
// run_in_background lifecycle: task ids seen as started vs. reached a terminal status.
|
|
70
|
+
bgStarted: new Set(), bgTerminal: new Set(),
|
|
67
71
|
};
|
|
68
72
|
return new Promise((resolve) => {
|
|
69
73
|
let child;
|
|
70
74
|
let settled = false;
|
|
71
|
-
|
|
75
|
+
let holdCapTimer = null;
|
|
76
|
+
const usageOf = () => this.usage(state);
|
|
77
|
+
const clearHoldCap = () => { if (holdCapTimer) {
|
|
78
|
+
clearTimeout(holdCapTimer);
|
|
79
|
+
holdCapTimer = null;
|
|
80
|
+
} };
|
|
81
|
+
// kill=true SIGTERMs immediately — fast exit, used once nothing is left running in the
|
|
82
|
+
// background (a normal turn, or a wake-up turn after every background task finished).
|
|
83
|
+
// kill=false only ends stdin and lets Claude shut down on its own, so any still-running
|
|
84
|
+
// detached background work survives a clean exit (a hard kill mid-flight is exactly what
|
|
85
|
+
// tore the background — and the wake-up — down before). A leak-guard SIGTERM is the backstop.
|
|
86
|
+
const finish = (r, kill = true) => {
|
|
72
87
|
if (settled)
|
|
73
88
|
return;
|
|
74
89
|
settled = true;
|
|
90
|
+
clearHoldCap();
|
|
75
91
|
try {
|
|
76
92
|
child?.stdin?.end();
|
|
77
93
|
}
|
|
78
94
|
catch { /* ignore */ }
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
if (!child.killed && child.exitCode == null) {
|
|
96
|
+
if (kill) {
|
|
97
|
+
try {
|
|
98
|
+
child.kill('SIGTERM');
|
|
99
|
+
}
|
|
100
|
+
catch { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const guard = setTimeout(() => { try {
|
|
104
|
+
child?.kill('SIGTERM');
|
|
105
|
+
}
|
|
106
|
+
catch { /* ignore */ } }, CLAUDE_EXIT_LEAK_GUARD_MS);
|
|
107
|
+
if (typeof guard.unref === 'function')
|
|
108
|
+
guard.unref();
|
|
82
109
|
}
|
|
83
|
-
|
|
84
|
-
} // replay mode keeps the process alive past the turn
|
|
110
|
+
}
|
|
85
111
|
resolve(r);
|
|
86
112
|
};
|
|
113
|
+
const settleResult = (opts = {}) => finish({
|
|
114
|
+
ok: !state.error, text: state.text, reasoning: state.reasoning || undefined,
|
|
115
|
+
error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, usage: usageOf(),
|
|
116
|
+
}, opts.kill ?? true);
|
|
87
117
|
try {
|
|
88
118
|
child = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
89
119
|
}
|
|
@@ -110,14 +140,17 @@ export class ClaudeDriver {
|
|
|
110
140
|
}
|
|
111
141
|
});
|
|
112
142
|
}
|
|
113
|
-
const usageOf = () => this.usage(state);
|
|
114
143
|
let buf = '';
|
|
115
144
|
let stderr = '';
|
|
116
145
|
child.stdout.on('data', (chunk) => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return; // ignore the process's post-settle shutdown chatter
|
|
117
148
|
buf += chunk.toString('utf8');
|
|
118
149
|
const lines = buf.split('\n');
|
|
119
150
|
buf = lines.pop() || '';
|
|
120
151
|
for (const line of lines) {
|
|
152
|
+
if (settled)
|
|
153
|
+
return;
|
|
121
154
|
const trimmed = line.trim();
|
|
122
155
|
if (!trimmed)
|
|
123
156
|
continue;
|
|
@@ -129,32 +162,42 @@ export class ClaudeDriver {
|
|
|
129
162
|
continue;
|
|
130
163
|
}
|
|
131
164
|
handleClaudeEvent(ev, state, ctx.emit);
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
165
|
+
if (ev.type !== 'result')
|
|
166
|
+
continue;
|
|
167
|
+
const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
|
|
168
|
+
const pending = pendingClaudeBackgroundTasks(state);
|
|
169
|
+
if (decideClaudeResultSettle({ hasError, pendingBackground: pending }) === 'hold') {
|
|
170
|
+
// Background work is still running: do NOT end the turn here. Keep stdin open so Claude
|
|
171
|
+
// wakes itself up and streams a follow-up turn reporting the finished work — we settle
|
|
172
|
+
// on THAT result (pending back to 0). A cap bounds the wait so a never-completing daemon
|
|
173
|
+
// (e.g. a dev server) eventually settles (graceful, no kill) instead of holding forever.
|
|
174
|
+
if (!holdCapTimer) {
|
|
175
|
+
holdCapTimer = setTimeout(() => { if (!settled)
|
|
176
|
+
settleResult({ stopReason: 'background', kill: false }); }, claudeBgHoldCapMs());
|
|
177
|
+
if (typeof holdCapTimer.unref === 'function')
|
|
178
|
+
holdCapTimer.unref();
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
135
181
|
}
|
|
182
|
+
settleResult();
|
|
136
183
|
}
|
|
137
184
|
});
|
|
138
185
|
child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
|
|
139
186
|
child.on('error', (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' }));
|
|
140
187
|
child.on('close', (code) => {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
141
190
|
if (ctx.signal.aborted) {
|
|
142
|
-
finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, usage: usageOf() });
|
|
191
|
+
finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, usage: usageOf() }, false);
|
|
143
192
|
return;
|
|
144
193
|
}
|
|
145
194
|
const ok = !state.error && code === 0;
|
|
146
|
-
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() });
|
|
195
|
+
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);
|
|
147
196
|
});
|
|
148
197
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
child.stdin.end(); // single turn; steer mode keeps stdin open for replay
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
child.stdin.write(input.prompt);
|
|
156
|
-
child.stdin.end();
|
|
157
|
-
}
|
|
198
|
+
// Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
|
|
199
|
+
// closing it makes Claude exit at the first `result`, before any background task finishes.
|
|
200
|
+
child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
|
|
158
201
|
}
|
|
159
202
|
catch { /* ignore */ }
|
|
160
203
|
});
|
|
@@ -228,6 +271,7 @@ export function handleClaudeEvent(ev, s, emit) {
|
|
|
228
271
|
return;
|
|
229
272
|
}
|
|
230
273
|
if (t === 'system') {
|
|
274
|
+
trackClaudeBackgroundTask(ev, s);
|
|
231
275
|
if (ev.session_id && ev.session_id !== s.sessionId) {
|
|
232
276
|
s.sessionId = ev.session_id;
|
|
233
277
|
emit({ type: 'session', sessionId: ev.session_id });
|
|
@@ -442,6 +486,62 @@ export function handleClaudeEvent(ev, s, emit) {
|
|
|
442
486
|
return;
|
|
443
487
|
}
|
|
444
488
|
}
|
|
489
|
+
// ── run_in_background lifecycle (background → wake-up) ───────────────────────────────────
|
|
490
|
+
// Claude streams a structured lifecycle for detached background work as `system` sub-events:
|
|
491
|
+
// { subtype:'task_started', task_id, tool_use_id, description } ← launched
|
|
492
|
+
// { subtype:'task_updated', task_id, patch:{ status } } ← progress / terminal
|
|
493
|
+
// { subtype:'task_notification', task_id, status } ← terminal (completed/killed/…)
|
|
494
|
+
// A task counts as pending from task_started until a terminal task_updated/task_notification.
|
|
495
|
+
// While any are pending the driver keeps the turn alive instead of ending it at `result`, so
|
|
496
|
+
// Claude's own background→wake-up turn (which reports the finished work) can stream in. Pure +
|
|
497
|
+
// exported for hermetic testing.
|
|
498
|
+
// How long, with no settle, to keep holding a turn open for a background task to finish and
|
|
499
|
+
// trigger Claude's wake-up turn, before giving up (so a never-completing daemon doesn't hang
|
|
500
|
+
// the turn forever). Override with PIKILOOM_CLAUDE_BG_HOLD_MS.
|
|
501
|
+
const CLAUDE_BG_HOLD_CAP_DEFAULT_MS = 10 * 60_000;
|
|
502
|
+
export function claudeBgHoldCapMs() {
|
|
503
|
+
const raw = Number(process.env.PIKILOOM_CLAUDE_BG_HOLD_MS);
|
|
504
|
+
return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_HOLD_CAP_DEFAULT_MS;
|
|
505
|
+
}
|
|
506
|
+
// After we settle a held turn gracefully (stdin closed, no kill), force-kill the lingering
|
|
507
|
+
// process only if it hasn't exited on its own within this window. Backstop against leaks.
|
|
508
|
+
const CLAUDE_EXIT_LEAK_GUARD_MS = 15_000;
|
|
509
|
+
export function isTerminalTaskStatus(status) {
|
|
510
|
+
return /^(complete|done|success|succeed|finish|kill|fail|error|stop|cancel|abort|timed?_?out|timeout)/i
|
|
511
|
+
.test(String(status ?? '').trim());
|
|
512
|
+
}
|
|
513
|
+
export function trackClaudeBackgroundTask(ev, s) {
|
|
514
|
+
const subtype = ev?.subtype;
|
|
515
|
+
if (subtype !== 'task_started' && subtype !== 'task_updated' && subtype !== 'task_notification')
|
|
516
|
+
return;
|
|
517
|
+
const id = String(ev?.task_id ?? ev?.tool_use_id ?? '').trim();
|
|
518
|
+
if (!id)
|
|
519
|
+
return;
|
|
520
|
+
if (subtype === 'task_started') {
|
|
521
|
+
(s.bgStarted ||= new Set()).add(id);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (isTerminalTaskStatus(ev?.patch?.status ?? ev?.status))
|
|
525
|
+
(s.bgTerminal ||= new Set()).add(id);
|
|
526
|
+
}
|
|
527
|
+
export function pendingClaudeBackgroundTasks(s) {
|
|
528
|
+
const started = s?.bgStarted;
|
|
529
|
+
if (!started?.size)
|
|
530
|
+
return 0;
|
|
531
|
+
const terminal = s?.bgTerminal;
|
|
532
|
+
let n = 0;
|
|
533
|
+
for (const id of started)
|
|
534
|
+
if (!terminal?.has(id))
|
|
535
|
+
n++;
|
|
536
|
+
return n;
|
|
537
|
+
}
|
|
538
|
+
// At a `result` event: settle the turn normally, unless background work is still running
|
|
539
|
+
// (then hold the process open for the wake-up). Errors always settle.
|
|
540
|
+
export function decideClaudeResultSettle(input) {
|
|
541
|
+
if (input.hasError)
|
|
542
|
+
return 'settle';
|
|
543
|
+
return input.pendingBackground > 0 ? 'hold' : 'settle';
|
|
544
|
+
}
|
|
445
545
|
// Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
|
|
446
546
|
const CLAUDE_IMAGE_MIME = {
|
|
447
547
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
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 } from './drivers/claude.js';
|
|
12
|
+
export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, type ClaudeResultSettleDecision, } 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 } from './drivers/claude.js';
|
|
22
|
+
export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, } 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.5",
|
|
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",
|