@pikiloom/kernel 0.3.17 → 0.3.18
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/contracts/driver.d.ts +1 -0
- package/dist/drivers/claude-pool.d.ts +20 -0
- package/dist/drivers/claude-pool.js +125 -0
- package/dist/drivers/claude.d.ts +14 -1
- package/dist/drivers/claude.js +127 -28
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
export declare function claudeWarmIdleTtlMs(): number;
|
|
3
|
+
export declare function claudeWarmMaxProcesses(): number;
|
|
4
|
+
export declare class ClaudeWarmPool {
|
|
5
|
+
private readonly parked;
|
|
6
|
+
size(): number;
|
|
7
|
+
/**
|
|
8
|
+
* Reclaim the parked process for `sessionId`, or null when there is none, it died while
|
|
9
|
+
* parked, or its spawn fingerprint no longer matches (that entry is destroyed — the turn
|
|
10
|
+
* must go cold so the new model/effort/config actually applies).
|
|
11
|
+
*/
|
|
12
|
+
take(sessionId: string, fingerprint: string): ChildProcess | null;
|
|
13
|
+
/** Park a process after a clean settle. The caller has already detached its turn listeners. */
|
|
14
|
+
put(sessionId: string, fingerprint: string, child: ChildProcess): void;
|
|
15
|
+
/** Destroy the parked process for a session (rewind made its in-memory context stale, TTL, cap). */
|
|
16
|
+
evictSession(sessionId: string): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
/** Remove bookkeeping without deciding the child's fate. */
|
|
19
|
+
private unpark;
|
|
20
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { sigterm } from './shared.js';
|
|
2
|
+
// ── Warm Claude process pool ─────────────────────────────────────────────────────────
|
|
3
|
+
// A `claude -p --input-format stream-json` process stays alive after `result` for as long
|
|
4
|
+
// as its stdin is open, and a user message written to that stdin runs a NEW turn in the
|
|
5
|
+
// same conversation (the steer path has always relied on this). Every cold spawn costs
|
|
6
|
+
// ~4s of CLI init before the first model request — the single largest local overhead of a
|
|
7
|
+
// Claude turn — so after a clean settle the driver parks the process here instead of
|
|
8
|
+
// killing it, and the session's next continuation turn skips the spawn entirely.
|
|
9
|
+
//
|
|
10
|
+
// Discipline (mirrors mirasim's Codex warm pool):
|
|
11
|
+
// - keyed by native session id; one process per session, serial turns only.
|
|
12
|
+
// - a process is reused only when its spawn-time fingerprint (model/effort/workdir/…)
|
|
13
|
+
// still matches what a cold spawn would use; any drift destroys it and goes cold.
|
|
14
|
+
// - idle processes are destroyed after a TTL; the pool is size-capped (oldest-idle out).
|
|
15
|
+
// - only CLEAN settles pool (no error / abort / timeout / background hold); everything
|
|
16
|
+
// else keeps today's kill semantics, so pooling can never leak a wedged process.
|
|
17
|
+
// Idle TTL before a parked process is destroyed. Override with PIKILOOM_CLAUDE_WARM_IDLE_MS.
|
|
18
|
+
const CLAUDE_WARM_IDLE_TTL_DEFAULT_MS = 10 * 60_000;
|
|
19
|
+
export function claudeWarmIdleTtlMs() {
|
|
20
|
+
const raw = Number(process.env.PIKILOOM_CLAUDE_WARM_IDLE_MS);
|
|
21
|
+
return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_WARM_IDLE_TTL_DEFAULT_MS;
|
|
22
|
+
}
|
|
23
|
+
// Max parked processes (each holds a full CLI + its MCP servers — roughly 0.5–1 GB).
|
|
24
|
+
// Override with PIKILOOM_CLAUDE_WARM_MAX.
|
|
25
|
+
const CLAUDE_WARM_MAX_DEFAULT = 4;
|
|
26
|
+
export function claudeWarmMaxProcesses() {
|
|
27
|
+
const raw = Number(process.env.PIKILOOM_CLAUDE_WARM_MAX);
|
|
28
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : CLAUDE_WARM_MAX_DEFAULT;
|
|
29
|
+
}
|
|
30
|
+
// After ending a parked process's stdin, force-kill only if it hasn't exited on its own
|
|
31
|
+
// within this window — same leak-guard shape as a graceful turn settle.
|
|
32
|
+
const CLAUDE_WARM_DESTROY_GUARD_MS = 15_000;
|
|
33
|
+
/** End a no-longer-poolable process the same way a graceful settle does. */
|
|
34
|
+
function destroyClaudeChild(child) {
|
|
35
|
+
try {
|
|
36
|
+
child.stdin?.end();
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
if (child.exitCode != null || child.killed)
|
|
40
|
+
return;
|
|
41
|
+
const guard = setTimeout(() => sigterm(child), CLAUDE_WARM_DESTROY_GUARD_MS);
|
|
42
|
+
if (typeof guard.unref === 'function')
|
|
43
|
+
guard.unref();
|
|
44
|
+
}
|
|
45
|
+
export class ClaudeWarmPool {
|
|
46
|
+
parked = new Map();
|
|
47
|
+
size() {
|
|
48
|
+
return this.parked.size;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reclaim the parked process for `sessionId`, or null when there is none, it died while
|
|
52
|
+
* parked, or its spawn fingerprint no longer matches (that entry is destroyed — the turn
|
|
53
|
+
* must go cold so the new model/effort/config actually applies).
|
|
54
|
+
*/
|
|
55
|
+
take(sessionId, fingerprint) {
|
|
56
|
+
const entry = this.parked.get(sessionId);
|
|
57
|
+
if (!entry)
|
|
58
|
+
return null;
|
|
59
|
+
this.unpark(sessionId, entry);
|
|
60
|
+
if (entry.child.exitCode != null || entry.child.killed)
|
|
61
|
+
return null;
|
|
62
|
+
if (entry.fingerprint !== fingerprint) {
|
|
63
|
+
destroyClaudeChild(entry.child);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return entry.child;
|
|
67
|
+
}
|
|
68
|
+
/** Park a process after a clean settle. The caller has already detached its turn listeners. */
|
|
69
|
+
put(sessionId, fingerprint, child) {
|
|
70
|
+
if (child.exitCode != null || child.killed || !child.stdin || child.stdin.destroyed) {
|
|
71
|
+
destroyClaudeChild(child);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.evictSession(sessionId); // never two processes for one session
|
|
75
|
+
while (this.parked.size >= Math.max(claudeWarmMaxProcesses(), 0)) {
|
|
76
|
+
const oldest = [...this.parked.entries()].sort((a, b) => a[1].parkedAt - b[1].parkedAt)[0];
|
|
77
|
+
if (!oldest)
|
|
78
|
+
break;
|
|
79
|
+
this.evictSession(oldest[0]);
|
|
80
|
+
}
|
|
81
|
+
if (claudeWarmMaxProcesses() <= 0) {
|
|
82
|
+
destroyClaudeChild(child);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const onClose = () => {
|
|
86
|
+
const cur = this.parked.get(sessionId);
|
|
87
|
+
if (cur?.child === child) {
|
|
88
|
+
this.unpark(sessionId, cur);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// Keep both pipes flowing while parked so the CLI can never block on a full pipe.
|
|
92
|
+
const drain = () => { };
|
|
93
|
+
child.stdout?.on('data', drain);
|
|
94
|
+
child.stderr?.on('data', drain);
|
|
95
|
+
child.on('close', onClose);
|
|
96
|
+
const idleTimer = setTimeout(() => {
|
|
97
|
+
const cur = this.parked.get(sessionId);
|
|
98
|
+
if (cur?.child === child)
|
|
99
|
+
this.evictSession(sessionId);
|
|
100
|
+
}, claudeWarmIdleTtlMs());
|
|
101
|
+
if (typeof idleTimer.unref === 'function')
|
|
102
|
+
idleTimer.unref();
|
|
103
|
+
this.parked.set(sessionId, { child, fingerprint, parkedAt: Date.now(), idleTimer, onClose, drain });
|
|
104
|
+
}
|
|
105
|
+
/** Destroy the parked process for a session (rewind made its in-memory context stale, TTL, cap). */
|
|
106
|
+
evictSession(sessionId) {
|
|
107
|
+
const entry = this.parked.get(sessionId);
|
|
108
|
+
if (!entry)
|
|
109
|
+
return;
|
|
110
|
+
this.unpark(sessionId, entry);
|
|
111
|
+
destroyClaudeChild(entry.child);
|
|
112
|
+
}
|
|
113
|
+
dispose() {
|
|
114
|
+
for (const sessionId of [...this.parked.keys()])
|
|
115
|
+
this.evictSession(sessionId);
|
|
116
|
+
}
|
|
117
|
+
/** Remove bookkeeping without deciding the child's fate. */
|
|
118
|
+
unpark(sessionId, entry) {
|
|
119
|
+
clearTimeout(entry.idleTimer);
|
|
120
|
+
entry.child.stdout?.off('data', entry.drain);
|
|
121
|
+
entry.child.stderr?.off('data', entry.drain);
|
|
122
|
+
entry.child.off('close', entry.onClose);
|
|
123
|
+
this.parked.delete(sessionId);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/drivers/claude.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
|
|
2
2
|
import type { UniversalPlan } from '../protocol/index.js';
|
|
3
|
+
export interface ClaudeDriverOptions {
|
|
4
|
+
/** Keep the CLI process alive after a clean turn and reuse it for the session's next
|
|
5
|
+
* continuation turn (skips the ~4s spawn+init). Off by default: a parked process holds
|
|
6
|
+
* real memory and keeps the event loop alive, which a one-shot embedder must not inherit
|
|
7
|
+
* silently. Long-lived hosts opt in and call dispose() on shutdown. */
|
|
8
|
+
warmPool?: boolean;
|
|
9
|
+
}
|
|
3
10
|
export declare class ClaudeDriver implements AgentDriver {
|
|
4
11
|
private readonly bin;
|
|
5
12
|
readonly id = "claude";
|
|
@@ -11,7 +18,12 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
11
18
|
fork: boolean;
|
|
12
19
|
rewind: boolean;
|
|
13
20
|
};
|
|
14
|
-
|
|
21
|
+
private readonly pool;
|
|
22
|
+
constructor(bin?: string, opts?: ClaudeDriverOptions);
|
|
23
|
+
/** Destroy every parked warm process. Long-lived hosts call this on shutdown. */
|
|
24
|
+
dispose(): void;
|
|
25
|
+
/** Parked warm processes right now (tests + telemetry). */
|
|
26
|
+
warmPoolSize(): number;
|
|
15
27
|
tui(input: TuiInput): TuiSpec;
|
|
16
28
|
listNativeSessions(opts: {
|
|
17
29
|
workdir: string;
|
|
@@ -24,6 +36,7 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
24
36
|
workdir: string;
|
|
25
37
|
}): string | null;
|
|
26
38
|
}
|
|
39
|
+
export declare function claudeProcessFingerprint(bin: string, input: AgentTurnInput): string;
|
|
27
40
|
export declare function claudeResumeArgs(sessionId: string, fork?: {
|
|
28
41
|
anchor?: string | null;
|
|
29
42
|
} | null, rewind?: {
|
package/dist/drivers/claude.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { ClaudeWarmPool } from './claude-pool.js';
|
|
3
4
|
import { claudeTranscriptTailAnchor, discoverClaudeNativeSessions } from './native.js';
|
|
4
5
|
import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, parseJsonLine, sigterm, wireAbort } from './shared.js';
|
|
5
|
-
// Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
|
|
6
|
-
// events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
|
|
7
|
-
// (system / stream_event{message_start,content_block_delta,message_delta} / assistant / result),
|
|
8
|
-
// but fully self-contained. Proves "下层 Claude 不变".
|
|
9
6
|
export class ClaudeDriver {
|
|
10
7
|
bin;
|
|
11
8
|
id = 'claude';
|
|
12
9
|
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true, rewind: true };
|
|
13
|
-
|
|
10
|
+
pool;
|
|
11
|
+
constructor(bin = 'claude', opts = {}) {
|
|
14
12
|
this.bin = bin;
|
|
13
|
+
this.pool = opts.warmPool ? new ClaudeWarmPool() : null;
|
|
14
|
+
}
|
|
15
|
+
/** Destroy every parked warm process. Long-lived hosts call this on shutdown. */
|
|
16
|
+
dispose() {
|
|
17
|
+
this.pool?.dispose();
|
|
18
|
+
}
|
|
19
|
+
/** Parked warm processes right now (tests + telemetry). */
|
|
20
|
+
warmPoolSize() {
|
|
21
|
+
return this.pool?.size() ?? 0;
|
|
15
22
|
}
|
|
16
23
|
// Interactive Claude Code TUI (no -p): the kernel spawns this in a PTY and passes
|
|
17
24
|
// the terminal through. Model/resume/BYOK-env come from the kernel's resolution.
|
|
@@ -54,6 +61,17 @@ export class ClaudeDriver {
|
|
|
54
61
|
args.push('--replay-user-messages'); // parity: mid-turn steer
|
|
55
62
|
if (input.extraArgs?.length)
|
|
56
63
|
args.push(...input.extraArgs);
|
|
64
|
+
// Warm reuse: a rewind rebranches the session's transcript, so a parked process's
|
|
65
|
+
// in-memory conversation is stale — destroy it. A fork never touches the parent's
|
|
66
|
+
// transcript (the parked parent stays valid); the fork turn itself must cold-spawn
|
|
67
|
+
// for its --fork-session flags. Only a plain continuation may reclaim a process, and
|
|
68
|
+
// only when the spawn fingerprint still matches what a cold spawn would use now.
|
|
69
|
+
const fingerprint = claudeProcessFingerprint(this.bin, input);
|
|
70
|
+
if (input.rewind && input.sessionId)
|
|
71
|
+
this.pool?.evictSession(input.sessionId);
|
|
72
|
+
const pooled = (!input.fork && !input.rewind && input.sessionId && this.pool)
|
|
73
|
+
? this.pool.take(input.sessionId, fingerprint)
|
|
74
|
+
: null;
|
|
57
75
|
const state = {
|
|
58
76
|
text: '', reasoning: '', streamedText: false, streamedReasoning: false,
|
|
59
77
|
// Dangling-tool-loop tracking: sawToolResult flips on the first tool_result; textSinceToolResult
|
|
@@ -82,6 +100,8 @@ export class ClaudeDriver {
|
|
|
82
100
|
};
|
|
83
101
|
return new Promise((resolve) => {
|
|
84
102
|
let child;
|
|
103
|
+
let transport = 'cold';
|
|
104
|
+
let promptDelivered = false;
|
|
85
105
|
let settled = false;
|
|
86
106
|
// One-shot guard for the truncated-turn recovery injection (see the result handler).
|
|
87
107
|
let truncatedRecoveryAttempted = false;
|
|
@@ -101,6 +121,8 @@ export class ClaudeDriver {
|
|
|
101
121
|
const usageOf = () => this.usage(state);
|
|
102
122
|
const unref = (tm) => { if (tm && typeof tm.unref === 'function')
|
|
103
123
|
tm.unref(); };
|
|
124
|
+
let disposeAbort = null;
|
|
125
|
+
let detachTurnListeners = () => { };
|
|
104
126
|
const clearHoldCap = () => { if (holdCapTimer) {
|
|
105
127
|
clearTimeout(holdCapTimer);
|
|
106
128
|
holdCapTimer = null;
|
|
@@ -118,18 +140,29 @@ export class ClaudeDriver {
|
|
|
118
140
|
// kill=false only ends stdin and lets Claude shut down on its own, so any still-running
|
|
119
141
|
// detached background work survives a clean exit (a hard kill mid-flight is exactly what
|
|
120
142
|
// tore the background — and the wake-up — down before). A leak-guard SIGTERM is the backstop.
|
|
121
|
-
const finish = (r, kill = true) => {
|
|
143
|
+
const finish = (r, kill = true, park = false) => {
|
|
122
144
|
if (settled)
|
|
123
145
|
return;
|
|
124
146
|
settled = true;
|
|
125
147
|
clearHoldCap();
|
|
126
148
|
clearQuiet();
|
|
127
149
|
clearModelStall();
|
|
150
|
+
disposeAbort?.();
|
|
151
|
+
// Park instead of kill: only a CLEAN settle qualifies (ok, no error, no abort, the
|
|
152
|
+
// process still healthy, session known) — every other exit keeps today's semantics,
|
|
153
|
+
// so pooling can never leak a wedged or errored process.
|
|
154
|
+
if (park && this.pool && state.sessionId && r.ok && !r.error && !ctx.signal.aborted
|
|
155
|
+
&& child && !child.killed && child.exitCode == null) {
|
|
156
|
+
detachTurnListeners();
|
|
157
|
+
this.pool.put(state.sessionId, fingerprint, child);
|
|
158
|
+
resolve({ ...r, transport });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
128
161
|
try {
|
|
129
162
|
child?.stdin?.end();
|
|
130
163
|
}
|
|
131
164
|
catch { /* ignore */ }
|
|
132
|
-
if (!child.killed && child.exitCode == null) {
|
|
165
|
+
if (child && !child.killed && child.exitCode == null) {
|
|
133
166
|
if (kill)
|
|
134
167
|
sigterm(child);
|
|
135
168
|
else {
|
|
@@ -137,12 +170,12 @@ export class ClaudeDriver {
|
|
|
137
170
|
unref(guard);
|
|
138
171
|
}
|
|
139
172
|
}
|
|
140
|
-
resolve(r);
|
|
173
|
+
resolve({ ...r, transport });
|
|
141
174
|
};
|
|
142
175
|
const settleResult = (opts = {}) => finish({
|
|
143
176
|
ok: opts.ok ?? !state.error, text: state.text, reasoning: state.reasoning || undefined,
|
|
144
177
|
error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf(),
|
|
145
|
-
}, opts.kill ?? true);
|
|
178
|
+
}, opts.kill ?? true, opts.park ?? false);
|
|
146
179
|
// Cap while holding for a still-running background task (stopReason marks it as
|
|
147
180
|
// "still running in the background" so the terminal presentation reads right). Idempotent —
|
|
148
181
|
// the countdown is absolute from the first arm. Sub-agent-backed holds use the longer
|
|
@@ -199,14 +232,32 @@ export class ClaudeDriver {
|
|
|
199
232
|
}, claudeModelStallMs(input.effort));
|
|
200
233
|
unref(modelStallTimer);
|
|
201
234
|
};
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
// Acquire the process: a reclaimed warm process gets the prompt as its acquisition
|
|
236
|
+
// probe — a dead pipe throws synchronously here and the turn transparently falls
|
|
237
|
+
// back to a cold spawn (whose --resume flags are already in `args`).
|
|
238
|
+
let acquired = null;
|
|
239
|
+
if (pooled) {
|
|
240
|
+
try {
|
|
241
|
+
pooled.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
|
|
242
|
+
acquired = pooled;
|
|
243
|
+
transport = 'warm';
|
|
244
|
+
promptDelivered = true;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
sigterm(pooled);
|
|
248
|
+
}
|
|
204
249
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
if (!acquired) {
|
|
251
|
+
try {
|
|
252
|
+
acquired = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
208
258
|
}
|
|
209
|
-
|
|
259
|
+
child = acquired;
|
|
260
|
+
disposeAbort = wireAbort(ctx.signal, () => sigterm(child));
|
|
210
261
|
if (steerable) {
|
|
211
262
|
ctx.registerSteer(async (prompt, attachments) => {
|
|
212
263
|
try {
|
|
@@ -220,7 +271,7 @@ export class ClaudeDriver {
|
|
|
220
271
|
}
|
|
221
272
|
const nextLines = createLineBuffer();
|
|
222
273
|
let stderr = '';
|
|
223
|
-
|
|
274
|
+
const onStdout = (chunk) => {
|
|
224
275
|
if (settled)
|
|
225
276
|
return; // ignore the process's post-settle shutdown chatter
|
|
226
277
|
for (const line of nextLines(chunk)) {
|
|
@@ -299,8 +350,10 @@ export class ClaudeDriver {
|
|
|
299
350
|
// DELIVERED live but never lands in the transcript, so the next re-render erases
|
|
300
351
|
// it (the "对话结束之后就被吞了" shape, caught by the turn audit). Ending stdin
|
|
301
352
|
// lets the CLI finish writing and exit on its own; the leak-guard SIGTERM is the
|
|
302
|
-
// backstop.
|
|
303
|
-
|
|
353
|
+
// backstop. park: this clean settle is the ONE warm-pool-eligible exit — the
|
|
354
|
+
// process (stdin open, transcript flushing in its own time) is parked for the
|
|
355
|
+
// session's next turn instead of being shut down; finish() re-checks health.
|
|
356
|
+
settleResult({ kill: false, park: true });
|
|
304
357
|
return;
|
|
305
358
|
}
|
|
306
359
|
if (decision === 'hold') {
|
|
@@ -332,10 +385,10 @@ export class ClaudeDriver {
|
|
|
332
385
|
if (ev.type === 'user' && pending === 0 && claudeUserEventHasToolResult(ev))
|
|
333
386
|
armModelStall();
|
|
334
387
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
388
|
+
};
|
|
389
|
+
const onStderr = (c) => { stderr += c.toString('utf8'); };
|
|
390
|
+
const onError = (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' });
|
|
391
|
+
const onClose = (code) => {
|
|
339
392
|
if (settled)
|
|
340
393
|
return;
|
|
341
394
|
if (ctx.signal.aborted) {
|
|
@@ -349,13 +402,27 @@ export class ClaudeDriver {
|
|
|
349
402
|
// dangling check must win over it.)
|
|
350
403
|
const stopReason = (ok && claudeTurnEndedDangling(state)) ? 'truncated' : state.stopReason;
|
|
351
404
|
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, anchor: state.anchor, usage: usageOf() }, false);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
child.
|
|
405
|
+
};
|
|
406
|
+
// Parking hands the process to the pool — these turn-scoped listeners must not
|
|
407
|
+
// outlive the turn (the pool installs its own drain + close bookkeeping).
|
|
408
|
+
detachTurnListeners = () => {
|
|
409
|
+
child.stdout?.off('data', onStdout);
|
|
410
|
+
child.stderr?.off('data', onStderr);
|
|
411
|
+
child.off('error', onError);
|
|
412
|
+
child.off('close', onClose);
|
|
413
|
+
};
|
|
414
|
+
child.stdout.on('data', onStdout);
|
|
415
|
+
child.stderr.on('data', onStderr);
|
|
416
|
+
child.on('error', onError);
|
|
417
|
+
child.on('close', onClose);
|
|
418
|
+
if (!promptDelivered) {
|
|
419
|
+
try {
|
|
420
|
+
// Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
|
|
421
|
+
// closing it makes Claude exit at the first `result`, before any background task finishes.
|
|
422
|
+
child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
|
|
423
|
+
}
|
|
424
|
+
catch { /* ignore */ }
|
|
357
425
|
}
|
|
358
|
-
catch { /* ignore */ }
|
|
359
426
|
});
|
|
360
427
|
}
|
|
361
428
|
usage(s) {
|
|
@@ -367,6 +434,38 @@ export class ClaudeDriver {
|
|
|
367
434
|
return claudeTranscriptTailAnchor(opts.workdir, opts.sessionId);
|
|
368
435
|
}
|
|
369
436
|
}
|
|
437
|
+
// The spawn-time facts that must still match for a parked warm process to serve a
|
|
438
|
+
// continuation turn as if it were a fresh cold `--resume` — any drift (model switch,
|
|
439
|
+
// effort change, new MCP config, different BYOK env, …) destroys the parked process so
|
|
440
|
+
// the new configuration actually applies. `sessionId` is the pool key, not fingerprint
|
|
441
|
+
// material, and `systemPrompt` is deliberately EXCLUDED: it is a first-turn-only input
|
|
442
|
+
// (the parked process already carries it applied; a cold --resume would not re-send it
|
|
443
|
+
// either), so including it would make every continuation miss the pool. Pure + exported
|
|
444
|
+
// for hermetic testing.
|
|
445
|
+
export function claudeProcessFingerprint(bin, input) {
|
|
446
|
+
return JSON.stringify([
|
|
447
|
+
bin, input.workdir, input.model ?? null, input.effort ?? null,
|
|
448
|
+
input.mcpConfigPath ?? null, input.permissionMode ?? null, !!input.steerable,
|
|
449
|
+
fingerprintExtraArgs(input.extraArgs),
|
|
450
|
+
Object.entries(input.env ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
// `--session-id <uuid>` pins a FRESH session's native id and is contributed on the first
|
|
454
|
+
// turn only (continuations resume instead) — it names the session, it doesn't configure
|
|
455
|
+
// the process. Like sessionId itself it must not be fingerprint material, or every
|
|
456
|
+
// continuation of a session born with it would miss the pool forever.
|
|
457
|
+
function fingerprintExtraArgs(extraArgs) {
|
|
458
|
+
const args = extraArgs ?? [];
|
|
459
|
+
const out = [];
|
|
460
|
+
for (let i = 0; i < args.length; i++) {
|
|
461
|
+
if (args[i] === '--session-id') {
|
|
462
|
+
i++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
out.push(args[i]);
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
370
469
|
// The --resume flag family for one turn: plain resume appends to the session; a fork branches
|
|
371
470
|
// a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
|
|
372
471
|
// uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
|
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, type ClaudeDriverOptions } 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikiloom/kernel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
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",
|