pikiloom 0.4.71 → 0.4.73
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/package.json +1 -1
- package/packages/kernel/dist/attachments.d.ts +36 -0
- package/packages/kernel/dist/attachments.js +162 -0
- package/packages/kernel/dist/contracts/driver.d.ts +9 -0
- package/packages/kernel/dist/contracts/ports.d.ts +4 -0
- package/packages/kernel/dist/contracts/surface.d.ts +7 -0
- package/packages/kernel/dist/drivers/claude-pool.d.ts +20 -0
- package/packages/kernel/dist/drivers/claude-pool.js +125 -0
- package/packages/kernel/dist/drivers/claude.d.ts +17 -1
- package/packages/kernel/dist/drivers/claude.js +145 -32
- package/packages/kernel/dist/drivers/codex.d.ts +8 -1
- package/packages/kernel/dist/drivers/codex.js +175 -7
- package/packages/kernel/dist/drivers/shared.d.ts +1 -4
- package/packages/kernel/dist/drivers/shared.js +3 -15
- package/packages/kernel/dist/index.d.ts +1 -1
- package/packages/kernel/dist/ports/defaults.d.ts +1 -0
- package/packages/kernel/dist/ports/defaults.js +20 -0
- package/packages/kernel/dist/protocol/index.d.ts +8 -0
- package/packages/kernel/dist/protocol/index.js +1 -1
- package/packages/kernel/dist/runtime/hub.d.ts +7 -0
- package/packages/kernel/dist/runtime/hub.js +76 -2
- package/packages/kernel/dist/runtime/session-runner.js +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Mime type when the file is an inlineable image, else null. */
|
|
2
|
+
export declare function imageMimeForFile(filePath: string): string | null;
|
|
3
|
+
/** The text note substituted for a non-image attachment. */
|
|
4
|
+
export declare function attachedFileNote(filePath: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Anthropic scales any image whose long edge exceeds 1568px before the model sees it, and —
|
|
7
|
+
* once a single request carries MORE THAN 20 images — outright REJECTS any image over 2000px
|
|
8
|
+
* on a side ("dimensions exceed max allowed size for many-image requests"). A long session
|
|
9
|
+
* full of screenshots easily crosses 20 images, at which point one freshly attached (or
|
|
10
|
+
* mid-turn steered) full-resolution screenshot gets the whole image stripped from the turn.
|
|
11
|
+
* Capping the long edge at Anthropic's own downscale threshold loses no detail the model
|
|
12
|
+
* would have kept and makes the many-image rejection unreachable. Other providers only gain
|
|
13
|
+
* from smaller uploads.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAX_IMAGE_EDGE = 1568;
|
|
16
|
+
/**
|
|
17
|
+
* Pixel dimensions parsed from an image buffer's header (PNG / JPEG / GIF / WebP),
|
|
18
|
+
* or null when the bytes aren't one of those formats. Header-only: never decodes.
|
|
19
|
+
*/
|
|
20
|
+
export declare function imageDimensions(buf: Buffer): {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
} | null;
|
|
24
|
+
type Resizer = 'sips' | 'magick' | 'convert' | 'none';
|
|
25
|
+
/** Test seam: force re-probing (and optionally stub) the resize engine. */
|
|
26
|
+
export declare function resetResizerForTest(value?: Resizer | null): void;
|
|
27
|
+
/**
|
|
28
|
+
* Swap oversized raster images (long edge > {@link MAX_IMAGE_EDGE}) for downscaled temp
|
|
29
|
+
* copies, leaving everything else — small images, non-images, unreadable paths, animated
|
|
30
|
+
* gifs — untouched. Best-effort by design: any probe/resize failure keeps the original, so
|
|
31
|
+
* an attachment is never dropped; the worst case is the pre-existing behaviour. Called once
|
|
32
|
+
* per dispatch at the Hub chokepoints (turn start + mid-turn steer), so every driver —
|
|
33
|
+
* built-in or host-plugged — sends model-safe images.
|
|
34
|
+
*/
|
|
35
|
+
export declare function normalizeImageAttachments(attachments: string[] | undefined, log?: (msg: string) => void): Promise<string[] | undefined>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path, { extname } from 'node:path';
|
|
6
|
+
// Attachment vocabulary + normalization, shared by the runtime (Hub) and every driver.
|
|
7
|
+
// NOT part of the public API — nothing here is re-exported by any barrel.
|
|
8
|
+
// Every driver inlines the same image formats (the Anthropic vision set, which the
|
|
9
|
+
// others accept too) and notes non-image files the same way.
|
|
10
|
+
const IMAGE_MIME_BY_EXT = {
|
|
11
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
12
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
13
|
+
};
|
|
14
|
+
/** Mime type when the file is an inlineable image, else null. */
|
|
15
|
+
export function imageMimeForFile(filePath) {
|
|
16
|
+
return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
|
|
17
|
+
}
|
|
18
|
+
/** The text note substituted for a non-image attachment. */
|
|
19
|
+
export function attachedFileNote(filePath) {
|
|
20
|
+
return `[Attached file: ${filePath}]`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Anthropic scales any image whose long edge exceeds 1568px before the model sees it, and —
|
|
24
|
+
* once a single request carries MORE THAN 20 images — outright REJECTS any image over 2000px
|
|
25
|
+
* on a side ("dimensions exceed max allowed size for many-image requests"). A long session
|
|
26
|
+
* full of screenshots easily crosses 20 images, at which point one freshly attached (or
|
|
27
|
+
* mid-turn steered) full-resolution screenshot gets the whole image stripped from the turn.
|
|
28
|
+
* Capping the long edge at Anthropic's own downscale threshold loses no detail the model
|
|
29
|
+
* would have kept and makes the many-image rejection unreachable. Other providers only gain
|
|
30
|
+
* from smaller uploads.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_IMAGE_EDGE = 1568;
|
|
33
|
+
/**
|
|
34
|
+
* Pixel dimensions parsed from an image buffer's header (PNG / JPEG / GIF / WebP),
|
|
35
|
+
* or null when the bytes aren't one of those formats. Header-only: never decodes.
|
|
36
|
+
*/
|
|
37
|
+
export function imageDimensions(buf) {
|
|
38
|
+
if (buf.length >= 24 && buf.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
|
39
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
40
|
+
}
|
|
41
|
+
if (buf.length >= 10 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
|
|
42
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
43
|
+
}
|
|
44
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
45
|
+
// JPEG: walk segments to the first SOF (skips arbitrarily large EXIF/APPn blocks).
|
|
46
|
+
let i = 2;
|
|
47
|
+
while (i + 9 < buf.length) {
|
|
48
|
+
if (buf[i] !== 0xff) {
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const marker = buf[i + 1];
|
|
53
|
+
if (marker === 0xff) {
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
} // fill byte
|
|
57
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
58
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
59
|
+
}
|
|
60
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
61
|
+
i += 2;
|
|
62
|
+
continue;
|
|
63
|
+
} // standalone
|
|
64
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (buf.length >= 30 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
69
|
+
const chunk = buf.subarray(12, 16).toString('latin1');
|
|
70
|
+
if (chunk === 'VP8X')
|
|
71
|
+
return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
|
|
72
|
+
if (chunk === 'VP8L')
|
|
73
|
+
return { width: 1 + (buf.readUInt32LE(21) & 0x3fff), height: 1 + ((buf.readUInt32LE(21) >> 14) & 0x3fff) };
|
|
74
|
+
if (chunk === 'VP8 ')
|
|
75
|
+
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
let resizerCache = null;
|
|
80
|
+
function run(cmd, args) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
execFile(cmd, args, { timeout: 10_000 }, (err) => (err ? reject(err) : resolve()));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function detectResizer() {
|
|
86
|
+
if (resizerCache)
|
|
87
|
+
return resizerCache;
|
|
88
|
+
if (process.platform === 'darwin')
|
|
89
|
+
return (resizerCache = 'sips');
|
|
90
|
+
resizerCache = (async () => {
|
|
91
|
+
for (const cmd of ['magick', 'convert']) {
|
|
92
|
+
try {
|
|
93
|
+
await run(cmd, ['-version']);
|
|
94
|
+
return (resizerCache = cmd);
|
|
95
|
+
}
|
|
96
|
+
catch { /* try next */ }
|
|
97
|
+
}
|
|
98
|
+
return (resizerCache = 'none');
|
|
99
|
+
})();
|
|
100
|
+
return resizerCache;
|
|
101
|
+
}
|
|
102
|
+
/** Test seam: force re-probing (and optionally stub) the resize engine. */
|
|
103
|
+
export function resetResizerForTest(value = null) {
|
|
104
|
+
resizerCache = value;
|
|
105
|
+
}
|
|
106
|
+
// Downscale `src` so its long edge is `maxEdge`, into a fresh temp file; null = keep the
|
|
107
|
+
// original (no engine, or the engine failed/produced nothing). WebP output support varies
|
|
108
|
+
// by sips version, so webp is re-encoded as PNG — the extension switch keeps the drivers'
|
|
109
|
+
// mime mapping truthful.
|
|
110
|
+
async function resizeToTemp(src, maxEdge) {
|
|
111
|
+
const resizer = await detectResizer();
|
|
112
|
+
if (resizer === 'none')
|
|
113
|
+
return null;
|
|
114
|
+
const srcExt = extname(src).toLowerCase();
|
|
115
|
+
const outExt = srcExt === '.webp' ? '.png' : srcExt;
|
|
116
|
+
const out = path.join(os.tmpdir(), `pikiloom-att-${randomUUID().slice(0, 8)}${outExt}`);
|
|
117
|
+
try {
|
|
118
|
+
if (resizer === 'sips') {
|
|
119
|
+
const format = srcExt === '.webp' ? ['-s', 'format', 'png'] : [];
|
|
120
|
+
await run('sips', [...format, '-Z', String(maxEdge), src, '--out', out]);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
await run(resizer, [src, '-resize', `${maxEdge}x${maxEdge}>`, out]);
|
|
124
|
+
}
|
|
125
|
+
return fs.statSync(out).size > 0 ? out : null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
fs.rmSync(out, { force: true });
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Swap oversized raster images (long edge > {@link MAX_IMAGE_EDGE}) for downscaled temp
|
|
134
|
+
* copies, leaving everything else — small images, non-images, unreadable paths, animated
|
|
135
|
+
* gifs — untouched. Best-effort by design: any probe/resize failure keeps the original, so
|
|
136
|
+
* an attachment is never dropped; the worst case is the pre-existing behaviour. Called once
|
|
137
|
+
* per dispatch at the Hub chokepoints (turn start + mid-turn steer), so every driver —
|
|
138
|
+
* built-in or host-plugged — sends model-safe images.
|
|
139
|
+
*/
|
|
140
|
+
export async function normalizeImageAttachments(attachments, log) {
|
|
141
|
+
if (!attachments?.length)
|
|
142
|
+
return attachments;
|
|
143
|
+
const out = [...attachments];
|
|
144
|
+
for (let i = 0; i < out.length; i++) {
|
|
145
|
+
const file = out[i];
|
|
146
|
+
const mime = imageMimeForFile(file);
|
|
147
|
+
if (!mime || mime === 'image/gif')
|
|
148
|
+
continue; // gif: resizing would mangle animation
|
|
149
|
+
try {
|
|
150
|
+
const dims = imageDimensions(fs.readFileSync(file));
|
|
151
|
+
if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_EDGE)
|
|
152
|
+
continue;
|
|
153
|
+
const resized = await resizeToTemp(file, MAX_IMAGE_EDGE);
|
|
154
|
+
if (resized) {
|
|
155
|
+
out[i] = resized;
|
|
156
|
+
log?.(`[attachments] downscaled ${path.basename(file)} ${dims.width}x${dims.height} -> long edge ${MAX_IMAGE_EDGE}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* unreadable — the drivers' own fallback (file note) handles it */ }
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
@@ -6,6 +6,9 @@ export interface AgentTurnInput {
|
|
|
6
6
|
fork?: {
|
|
7
7
|
anchor?: string | null;
|
|
8
8
|
} | null;
|
|
9
|
+
rewind?: {
|
|
10
|
+
anchor?: string | null;
|
|
11
|
+
} | null;
|
|
9
12
|
workdir: string;
|
|
10
13
|
model?: string | null;
|
|
11
14
|
effort?: string | null;
|
|
@@ -55,6 +58,10 @@ export type DriverEvent = {
|
|
|
55
58
|
} | {
|
|
56
59
|
type: 'activity';
|
|
57
60
|
line: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'compaction';
|
|
63
|
+
trigger: 'auto' | 'manual';
|
|
64
|
+
atTokens?: number | null;
|
|
58
65
|
};
|
|
59
66
|
export type SteerFn = (prompt: string, attachments?: string[]) => Promise<boolean>;
|
|
60
67
|
export interface DriverContext {
|
|
@@ -72,6 +79,7 @@ export interface DriverResult {
|
|
|
72
79
|
sessionId?: string | null;
|
|
73
80
|
usage?: UniversalUsage | null;
|
|
74
81
|
anchor?: string | null;
|
|
82
|
+
transport?: 'cold' | 'warm' | null;
|
|
75
83
|
}
|
|
76
84
|
export interface TuiInput {
|
|
77
85
|
workdir: string;
|
|
@@ -106,6 +114,7 @@ export interface AgentDriver {
|
|
|
106
114
|
resume?: boolean;
|
|
107
115
|
tui?: boolean;
|
|
108
116
|
fork?: boolean;
|
|
117
|
+
rewind?: boolean;
|
|
109
118
|
};
|
|
110
119
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
111
120
|
tui?(input: TuiInput): TuiSpec;
|
|
@@ -25,6 +25,9 @@ export interface CoreSessionRecord {
|
|
|
25
25
|
anchor: string | null;
|
|
26
26
|
mode: 'native' | 'seed';
|
|
27
27
|
} | null;
|
|
28
|
+
pendingRewind?: {
|
|
29
|
+
anchor: string | null;
|
|
30
|
+
} | null;
|
|
28
31
|
}
|
|
29
32
|
export interface SessionStore {
|
|
30
33
|
ensure(agent: string, opts: {
|
|
@@ -48,6 +51,7 @@ export interface SessionStore {
|
|
|
48
51
|
reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
|
|
49
52
|
appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
50
53
|
history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
54
|
+
truncateTurns?(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
|
|
51
55
|
}
|
|
52
56
|
export interface ModelInjection {
|
|
53
57
|
model?: string | null;
|
|
@@ -23,6 +23,13 @@ export interface LoomIO {
|
|
|
23
23
|
forkSession(input: ForkSessionInput): Promise<{
|
|
24
24
|
sessionKey: string;
|
|
25
25
|
}>;
|
|
26
|
+
rewindSession(input: {
|
|
27
|
+
sessionKey: string;
|
|
28
|
+
atTaskId: string;
|
|
29
|
+
anchor?: string | null;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
sessionKey: string;
|
|
32
|
+
}>;
|
|
26
33
|
stop(sessionKey: string): boolean;
|
|
27
34
|
steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
|
|
28
35
|
interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
|
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -9,8 +16,14 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
9
16
|
resume: boolean;
|
|
10
17
|
tui: boolean;
|
|
11
18
|
fork: boolean;
|
|
19
|
+
rewind: boolean;
|
|
12
20
|
};
|
|
13
|
-
|
|
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;
|
|
14
27
|
tui(input: TuiInput): TuiSpec;
|
|
15
28
|
listNativeSessions(opts: {
|
|
16
29
|
workdir: string;
|
|
@@ -23,8 +36,11 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
23
36
|
workdir: string;
|
|
24
37
|
}): string | null;
|
|
25
38
|
}
|
|
39
|
+
export declare function claudeProcessFingerprint(bin: string, input: AgentTurnInput): string;
|
|
26
40
|
export declare function claudeResumeArgs(sessionId: string, fork?: {
|
|
27
41
|
anchor?: string | null;
|
|
42
|
+
} | null, rewind?: {
|
|
43
|
+
anchor?: string | null;
|
|
28
44
|
} | null): string[];
|
|
29
45
|
export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
|
|
30
46
|
export declare function claudeBgHoldCapMs(): number;
|