pikiloom 0.4.72 → 0.4.74
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 +2 -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 +14 -1
- package/packages/kernel/dist/drivers/claude.js +133 -29
- package/packages/kernel/dist/drivers/codex.d.ts +8 -1
- package/packages/kernel/dist/drivers/codex.js +157 -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/protocol/index.js +1 -1
- package/packages/kernel/dist/runtime/hub.js +5 -2
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
|
+
}
|
|
@@ -79,11 +79,13 @@ export interface DriverResult {
|
|
|
79
79
|
sessionId?: string | null;
|
|
80
80
|
usage?: UniversalUsage | null;
|
|
81
81
|
anchor?: string | null;
|
|
82
|
+
transport?: 'cold' | 'warm' | null;
|
|
82
83
|
}
|
|
83
84
|
export interface TuiInput {
|
|
84
85
|
workdir: string;
|
|
85
86
|
model?: string | null;
|
|
86
87
|
sessionId?: string | null;
|
|
88
|
+
newSessionId?: string;
|
|
87
89
|
env?: Record<string, string>;
|
|
88
90
|
extraArgs?: string[];
|
|
89
91
|
}
|
|
@@ -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";
|
|
@@ -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?: {
|
|
@@ -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.
|
|
@@ -19,7 +26,12 @@ export class ClaudeDriver {
|
|
|
19
26
|
const args = [];
|
|
20
27
|
if (input.model)
|
|
21
28
|
args.push('--model', input.model);
|
|
22
|
-
|
|
29
|
+
// Fresh-pin wins over resume: `--session-id <uuid>` makes Claude start a NEW session and
|
|
30
|
+
// write its transcript under the given id, so the host already knows the resumable key
|
|
31
|
+
// before spawn (terminal-first new session). Falls back to `--resume` for an existing one.
|
|
32
|
+
if (input.newSessionId)
|
|
33
|
+
args.push('--session-id', input.newSessionId);
|
|
34
|
+
else if (input.sessionId)
|
|
23
35
|
args.push('--resume', input.sessionId);
|
|
24
36
|
if (input.extraArgs?.length)
|
|
25
37
|
args.push(...input.extraArgs);
|
|
@@ -54,6 +66,17 @@ export class ClaudeDriver {
|
|
|
54
66
|
args.push('--replay-user-messages'); // parity: mid-turn steer
|
|
55
67
|
if (input.extraArgs?.length)
|
|
56
68
|
args.push(...input.extraArgs);
|
|
69
|
+
// Warm reuse: a rewind rebranches the session's transcript, so a parked process's
|
|
70
|
+
// in-memory conversation is stale — destroy it. A fork never touches the parent's
|
|
71
|
+
// transcript (the parked parent stays valid); the fork turn itself must cold-spawn
|
|
72
|
+
// for its --fork-session flags. Only a plain continuation may reclaim a process, and
|
|
73
|
+
// only when the spawn fingerprint still matches what a cold spawn would use now.
|
|
74
|
+
const fingerprint = claudeProcessFingerprint(this.bin, input);
|
|
75
|
+
if (input.rewind && input.sessionId)
|
|
76
|
+
this.pool?.evictSession(input.sessionId);
|
|
77
|
+
const pooled = (!input.fork && !input.rewind && input.sessionId && this.pool)
|
|
78
|
+
? this.pool.take(input.sessionId, fingerprint)
|
|
79
|
+
: null;
|
|
57
80
|
const state = {
|
|
58
81
|
text: '', reasoning: '', streamedText: false, streamedReasoning: false,
|
|
59
82
|
// Dangling-tool-loop tracking: sawToolResult flips on the first tool_result; textSinceToolResult
|
|
@@ -82,6 +105,8 @@ export class ClaudeDriver {
|
|
|
82
105
|
};
|
|
83
106
|
return new Promise((resolve) => {
|
|
84
107
|
let child;
|
|
108
|
+
let transport = 'cold';
|
|
109
|
+
let promptDelivered = false;
|
|
85
110
|
let settled = false;
|
|
86
111
|
// One-shot guard for the truncated-turn recovery injection (see the result handler).
|
|
87
112
|
let truncatedRecoveryAttempted = false;
|
|
@@ -101,6 +126,8 @@ export class ClaudeDriver {
|
|
|
101
126
|
const usageOf = () => this.usage(state);
|
|
102
127
|
const unref = (tm) => { if (tm && typeof tm.unref === 'function')
|
|
103
128
|
tm.unref(); };
|
|
129
|
+
let disposeAbort = null;
|
|
130
|
+
let detachTurnListeners = () => { };
|
|
104
131
|
const clearHoldCap = () => { if (holdCapTimer) {
|
|
105
132
|
clearTimeout(holdCapTimer);
|
|
106
133
|
holdCapTimer = null;
|
|
@@ -118,18 +145,29 @@ export class ClaudeDriver {
|
|
|
118
145
|
// kill=false only ends stdin and lets Claude shut down on its own, so any still-running
|
|
119
146
|
// detached background work survives a clean exit (a hard kill mid-flight is exactly what
|
|
120
147
|
// tore the background — and the wake-up — down before). A leak-guard SIGTERM is the backstop.
|
|
121
|
-
const finish = (r, kill = true) => {
|
|
148
|
+
const finish = (r, kill = true, park = false) => {
|
|
122
149
|
if (settled)
|
|
123
150
|
return;
|
|
124
151
|
settled = true;
|
|
125
152
|
clearHoldCap();
|
|
126
153
|
clearQuiet();
|
|
127
154
|
clearModelStall();
|
|
155
|
+
disposeAbort?.();
|
|
156
|
+
// Park instead of kill: only a CLEAN settle qualifies (ok, no error, no abort, the
|
|
157
|
+
// process still healthy, session known) — every other exit keeps today's semantics,
|
|
158
|
+
// so pooling can never leak a wedged or errored process.
|
|
159
|
+
if (park && this.pool && state.sessionId && r.ok && !r.error && !ctx.signal.aborted
|
|
160
|
+
&& child && !child.killed && child.exitCode == null) {
|
|
161
|
+
detachTurnListeners();
|
|
162
|
+
this.pool.put(state.sessionId, fingerprint, child);
|
|
163
|
+
resolve({ ...r, transport });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
128
166
|
try {
|
|
129
167
|
child?.stdin?.end();
|
|
130
168
|
}
|
|
131
169
|
catch { /* ignore */ }
|
|
132
|
-
if (!child.killed && child.exitCode == null) {
|
|
170
|
+
if (child && !child.killed && child.exitCode == null) {
|
|
133
171
|
if (kill)
|
|
134
172
|
sigterm(child);
|
|
135
173
|
else {
|
|
@@ -137,12 +175,12 @@ export class ClaudeDriver {
|
|
|
137
175
|
unref(guard);
|
|
138
176
|
}
|
|
139
177
|
}
|
|
140
|
-
resolve(r);
|
|
178
|
+
resolve({ ...r, transport });
|
|
141
179
|
};
|
|
142
180
|
const settleResult = (opts = {}) => finish({
|
|
143
181
|
ok: opts.ok ?? !state.error, text: state.text, reasoning: state.reasoning || undefined,
|
|
144
182
|
error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf(),
|
|
145
|
-
}, opts.kill ?? true);
|
|
183
|
+
}, opts.kill ?? true, opts.park ?? false);
|
|
146
184
|
// Cap while holding for a still-running background task (stopReason marks it as
|
|
147
185
|
// "still running in the background" so the terminal presentation reads right). Idempotent —
|
|
148
186
|
// the countdown is absolute from the first arm. Sub-agent-backed holds use the longer
|
|
@@ -199,14 +237,32 @@ export class ClaudeDriver {
|
|
|
199
237
|
}, claudeModelStallMs(input.effort));
|
|
200
238
|
unref(modelStallTimer);
|
|
201
239
|
};
|
|
202
|
-
|
|
203
|
-
|
|
240
|
+
// Acquire the process: a reclaimed warm process gets the prompt as its acquisition
|
|
241
|
+
// probe — a dead pipe throws synchronously here and the turn transparently falls
|
|
242
|
+
// back to a cold spawn (whose --resume flags are already in `args`).
|
|
243
|
+
let acquired = null;
|
|
244
|
+
if (pooled) {
|
|
245
|
+
try {
|
|
246
|
+
pooled.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
|
|
247
|
+
acquired = pooled;
|
|
248
|
+
transport = 'warm';
|
|
249
|
+
promptDelivered = true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
sigterm(pooled);
|
|
253
|
+
}
|
|
204
254
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
255
|
+
if (!acquired) {
|
|
256
|
+
try {
|
|
257
|
+
acquired = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
208
263
|
}
|
|
209
|
-
|
|
264
|
+
child = acquired;
|
|
265
|
+
disposeAbort = wireAbort(ctx.signal, () => sigterm(child));
|
|
210
266
|
if (steerable) {
|
|
211
267
|
ctx.registerSteer(async (prompt, attachments) => {
|
|
212
268
|
try {
|
|
@@ -220,7 +276,7 @@ export class ClaudeDriver {
|
|
|
220
276
|
}
|
|
221
277
|
const nextLines = createLineBuffer();
|
|
222
278
|
let stderr = '';
|
|
223
|
-
|
|
279
|
+
const onStdout = (chunk) => {
|
|
224
280
|
if (settled)
|
|
225
281
|
return; // ignore the process's post-settle shutdown chatter
|
|
226
282
|
for (const line of nextLines(chunk)) {
|
|
@@ -299,8 +355,10 @@ export class ClaudeDriver {
|
|
|
299
355
|
// DELIVERED live but never lands in the transcript, so the next re-render erases
|
|
300
356
|
// it (the "对话结束之后就被吞了" shape, caught by the turn audit). Ending stdin
|
|
301
357
|
// lets the CLI finish writing and exit on its own; the leak-guard SIGTERM is the
|
|
302
|
-
// backstop.
|
|
303
|
-
|
|
358
|
+
// backstop. park: this clean settle is the ONE warm-pool-eligible exit — the
|
|
359
|
+
// process (stdin open, transcript flushing in its own time) is parked for the
|
|
360
|
+
// session's next turn instead of being shut down; finish() re-checks health.
|
|
361
|
+
settleResult({ kill: false, park: true });
|
|
304
362
|
return;
|
|
305
363
|
}
|
|
306
364
|
if (decision === 'hold') {
|
|
@@ -332,10 +390,10 @@ export class ClaudeDriver {
|
|
|
332
390
|
if (ev.type === 'user' && pending === 0 && claudeUserEventHasToolResult(ev))
|
|
333
391
|
armModelStall();
|
|
334
392
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
393
|
+
};
|
|
394
|
+
const onStderr = (c) => { stderr += c.toString('utf8'); };
|
|
395
|
+
const onError = (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' });
|
|
396
|
+
const onClose = (code) => {
|
|
339
397
|
if (settled)
|
|
340
398
|
return;
|
|
341
399
|
if (ctx.signal.aborted) {
|
|
@@ -349,13 +407,27 @@ export class ClaudeDriver {
|
|
|
349
407
|
// dangling check must win over it.)
|
|
350
408
|
const stopReason = (ok && claudeTurnEndedDangling(state)) ? 'truncated' : state.stopReason;
|
|
351
409
|
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.
|
|
410
|
+
};
|
|
411
|
+
// Parking hands the process to the pool — these turn-scoped listeners must not
|
|
412
|
+
// outlive the turn (the pool installs its own drain + close bookkeeping).
|
|
413
|
+
detachTurnListeners = () => {
|
|
414
|
+
child.stdout?.off('data', onStdout);
|
|
415
|
+
child.stderr?.off('data', onStderr);
|
|
416
|
+
child.off('error', onError);
|
|
417
|
+
child.off('close', onClose);
|
|
418
|
+
};
|
|
419
|
+
child.stdout.on('data', onStdout);
|
|
420
|
+
child.stderr.on('data', onStderr);
|
|
421
|
+
child.on('error', onError);
|
|
422
|
+
child.on('close', onClose);
|
|
423
|
+
if (!promptDelivered) {
|
|
424
|
+
try {
|
|
425
|
+
// Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
|
|
426
|
+
// closing it makes Claude exit at the first `result`, before any background task finishes.
|
|
427
|
+
child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
|
|
428
|
+
}
|
|
429
|
+
catch { /* ignore */ }
|
|
357
430
|
}
|
|
358
|
-
catch { /* ignore */ }
|
|
359
431
|
});
|
|
360
432
|
}
|
|
361
433
|
usage(s) {
|
|
@@ -367,6 +439,38 @@ export class ClaudeDriver {
|
|
|
367
439
|
return claudeTranscriptTailAnchor(opts.workdir, opts.sessionId);
|
|
368
440
|
}
|
|
369
441
|
}
|
|
442
|
+
// The spawn-time facts that must still match for a parked warm process to serve a
|
|
443
|
+
// continuation turn as if it were a fresh cold `--resume` — any drift (model switch,
|
|
444
|
+
// effort change, new MCP config, different BYOK env, …) destroys the parked process so
|
|
445
|
+
// the new configuration actually applies. `sessionId` is the pool key, not fingerprint
|
|
446
|
+
// material, and `systemPrompt` is deliberately EXCLUDED: it is a first-turn-only input
|
|
447
|
+
// (the parked process already carries it applied; a cold --resume would not re-send it
|
|
448
|
+
// either), so including it would make every continuation miss the pool. Pure + exported
|
|
449
|
+
// for hermetic testing.
|
|
450
|
+
export function claudeProcessFingerprint(bin, input) {
|
|
451
|
+
return JSON.stringify([
|
|
452
|
+
bin, input.workdir, input.model ?? null, input.effort ?? null,
|
|
453
|
+
input.mcpConfigPath ?? null, input.permissionMode ?? null, !!input.steerable,
|
|
454
|
+
fingerprintExtraArgs(input.extraArgs),
|
|
455
|
+
Object.entries(input.env ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
456
|
+
]);
|
|
457
|
+
}
|
|
458
|
+
// `--session-id <uuid>` pins a FRESH session's native id and is contributed on the first
|
|
459
|
+
// turn only (continuations resume instead) — it names the session, it doesn't configure
|
|
460
|
+
// the process. Like sessionId itself it must not be fingerprint material, or every
|
|
461
|
+
// continuation of a session born with it would miss the pool forever.
|
|
462
|
+
function fingerprintExtraArgs(extraArgs) {
|
|
463
|
+
const args = extraArgs ?? [];
|
|
464
|
+
const out = [];
|
|
465
|
+
for (let i = 0; i < args.length; i++) {
|
|
466
|
+
if (args[i] === '--session-id') {
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
out.push(args[i]);
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
370
474
|
// The --resume flag family for one turn: plain resume appends to the session; a fork branches
|
|
371
475
|
// a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
|
|
372
476
|
// uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
|
|
@@ -19,8 +19,15 @@ export declare function captureCodexAgentMessage(item: any, s: CodexContentState
|
|
|
19
19
|
export declare function captureCodexReasoning(text: string, s: CodexContentState, emit: (e: DriverEvent) => void): void;
|
|
20
20
|
export declare function codexFinalText(s: CodexContentState): string;
|
|
21
21
|
export declare function codexFinalReasoning(s: CodexContentState): string;
|
|
22
|
+
export interface CodexLivenessOptions {
|
|
23
|
+
/** Silence after an accepted steer before the turn is healed (interrupt + replay). */
|
|
24
|
+
steerStallMs?: number;
|
|
25
|
+
/** Idle silence (no running tool, no pending HITL request) before the turn is force-closed. */
|
|
26
|
+
turnStallMs?: number;
|
|
27
|
+
}
|
|
22
28
|
export declare class CodexDriver implements AgentDriver {
|
|
23
29
|
private readonly bin;
|
|
30
|
+
private readonly liveness;
|
|
24
31
|
readonly id = "codex";
|
|
25
32
|
readonly capabilities: {
|
|
26
33
|
steer: boolean;
|
|
@@ -29,7 +36,7 @@ export declare class CodexDriver implements AgentDriver {
|
|
|
29
36
|
tui: boolean;
|
|
30
37
|
fork: boolean;
|
|
31
38
|
};
|
|
32
|
-
constructor(bin?: string);
|
|
39
|
+
constructor(bin?: string, liveness?: CodexLivenessOptions);
|
|
33
40
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
34
41
|
tui(input: TuiInput): TuiSpec;
|
|
35
42
|
listNativeSessions(opts: {
|
|
@@ -234,12 +234,53 @@ export function codexFinalText(s) {
|
|
|
234
234
|
export function codexFinalReasoning(s) {
|
|
235
235
|
return s.reasoning.trim() ? s.reasoning : s.thinkParts.join('\n\n');
|
|
236
236
|
}
|
|
237
|
+
// ── Turn liveness (steer race + silent-stall recovery) ──────────────────────────
|
|
238
|
+
// codex app-server has a turn-boundary race: a `turn/steer` that lands in the instant an
|
|
239
|
+
// item completes is ACCEPTED (recorded into the rollout) but never dispatched — the agent
|
|
240
|
+
// loop goes idle, no further notifications arrive, and `turn/completed` never fires, so the
|
|
241
|
+
// turn hangs forever while the process sits at 0% CPU (observed on codex-cli 0.144.x;
|
|
242
|
+
// same family as openai/codex#15714 / #23807). Two defenses below:
|
|
243
|
+
//
|
|
244
|
+
// 1. A successful steer is treated as ACCEPTED, NOT CONSUMED: it stays pending until a
|
|
245
|
+
// progress notification proves the loop picked it up. If the turn instead goes silent
|
|
246
|
+
// (or completes without consuming it), the driver heals in place — interrupt the wedged
|
|
247
|
+
// turn and restart it with the same input. The steered text is already in the thread
|
|
248
|
+
// history, so the worst false-positive cost is one duplicated user message; the turn
|
|
249
|
+
// keeps running under the same run()/task, invisible to upper layers.
|
|
250
|
+
// 2. A generic silence backstop: a turn with no notifications for a long stretch while
|
|
251
|
+
// nothing is visibly in flight (no running tool call, no server->client request awaiting
|
|
252
|
+
// a human) is declared stalled and force-closed, so the session ends as a visible error
|
|
253
|
+
// instead of spinning forever.
|
|
254
|
+
// Thresholds are calibrated against measured rollouts: with reasoning summaries off, a
|
|
255
|
+
// HEALTHY turn shows fully silent thinking stretches of up to ~320s (clustered just above
|
|
256
|
+
// codex core's own 300s stream watchdog, whose retry produces the next event). 600s has
|
|
257
|
+
// never been reached intra-turn in days of observed sessions, so a heal at that point is
|
|
258
|
+
// near-certainly a real wedge — and even a misfire only costs one duplicated user message
|
|
259
|
+
// plus the in-flight thinking (completed items live in the thread history).
|
|
260
|
+
const CODEX_STEER_STALL_MS = 600_000;
|
|
261
|
+
const CODEX_TURN_STALL_MS = 900_000;
|
|
262
|
+
// Notifications that prove the agent loop is making forward progress AFTER a steer.
|
|
263
|
+
// item/completed and tokenUsage are deliberately excluded — they can be the trailing edge
|
|
264
|
+
// of work that finished before the steer landed (the exact race signature). Progress that
|
|
265
|
+
// arrives within CODEX_STEER_CONSUMED_MS of the steer only refreshes the pending marker's
|
|
266
|
+
// clock rather than clearing it: the app-server can flush pre-acceptance stream output in
|
|
267
|
+
// the same write as the steer response, so near-simultaneous events are not proof the
|
|
268
|
+
// loop survived the injection. Progress beyond that window is.
|
|
269
|
+
const CODEX_STEER_CONSUMED_MS = 2_000;
|
|
270
|
+
const CODEX_PROGRESS_METHODS = new Set([
|
|
271
|
+
'turn/started', 'item/started', 'item/agentMessage/delta',
|
|
272
|
+
'item/reasoning/textDelta', 'item/reasoning/summaryTextDelta',
|
|
273
|
+
'item/commandExecution/outputDelta', 'item/fileChange/outputDelta',
|
|
274
|
+
'item/fileChange/patchUpdated', 'turn/plan/updated', 'rawResponseItem/completed',
|
|
275
|
+
]);
|
|
237
276
|
export class CodexDriver {
|
|
238
277
|
bin;
|
|
278
|
+
liveness;
|
|
239
279
|
id = 'codex';
|
|
240
280
|
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
|
|
241
|
-
constructor(bin = 'codex') {
|
|
281
|
+
constructor(bin = 'codex', liveness = {}) {
|
|
242
282
|
this.bin = bin;
|
|
283
|
+
this.liveness = liveness;
|
|
243
284
|
}
|
|
244
285
|
async run(input, ctx) {
|
|
245
286
|
// BYOK provider routing arrives as `-c key=value` overrides; pass them through so the
|
|
@@ -261,6 +302,15 @@ export class CodexDriver {
|
|
|
261
302
|
const deltaItems = new Set();
|
|
262
303
|
let lastTextItemId = null;
|
|
263
304
|
let steerRegistered = false;
|
|
305
|
+
// Liveness state (see the CODEX_STEER_STALL_MS block comment above).
|
|
306
|
+
const steerStallMs = this.liveness.steerStallMs ?? CODEX_STEER_STALL_MS;
|
|
307
|
+
const turnStallMs = this.liveness.turnStallMs ?? CODEX_TURN_STALL_MS;
|
|
308
|
+
let lastEventAt = Date.now();
|
|
309
|
+
let pendingSteer = null;
|
|
310
|
+
let pendingServerRequests = 0;
|
|
311
|
+
let healing = false;
|
|
312
|
+
let stalled = false;
|
|
313
|
+
let livenessTimer = null;
|
|
264
314
|
if (!srv.start())
|
|
265
315
|
return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
|
|
266
316
|
let settled = false;
|
|
@@ -320,23 +370,78 @@ export class CodexDriver {
|
|
|
320
370
|
state.sessionId = threadId;
|
|
321
371
|
ctx.emit({ type: 'session', sessionId: threadId });
|
|
322
372
|
}
|
|
373
|
+
// Heal a wedged/lost steer in place: (optionally) interrupt the dead turn, then restart
|
|
374
|
+
// it with the same input. Runs under the SAME run()/turnDone, so upper layers just see
|
|
375
|
+
// the turn continue. `healing` suppresses the interrupt's own turn/completed echo.
|
|
376
|
+
const heal = async (opts) => {
|
|
377
|
+
if (settled || healing || !pendingSteer)
|
|
378
|
+
return;
|
|
379
|
+
healing = true;
|
|
380
|
+
const replayInput = pendingSteer.input;
|
|
381
|
+
pendingSteer = null;
|
|
382
|
+
if (opts.interrupt && state.sessionId && state.turnId) {
|
|
383
|
+
await srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000);
|
|
384
|
+
}
|
|
385
|
+
if (settled) {
|
|
386
|
+
healing = false;
|
|
387
|
+
return;
|
|
388
|
+
} // raced with abort / process death
|
|
389
|
+
const resp = await srv.request('turn/start', {
|
|
390
|
+
threadId: state.sessionId,
|
|
391
|
+
input: replayInput,
|
|
392
|
+
model: input.model || undefined,
|
|
393
|
+
effort: input.effort || undefined,
|
|
394
|
+
});
|
|
395
|
+
if (settled) {
|
|
396
|
+
healing = false;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (resp.error) {
|
|
400
|
+
state.status = 'error';
|
|
401
|
+
state.error = `steer recovery failed: ${resp.error.message || 'turn/start failed'}`;
|
|
402
|
+
healing = false;
|
|
403
|
+
settle();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
state.turnId = resp.result?.turn?.id ?? state.turnId;
|
|
407
|
+
lastEventAt = Date.now();
|
|
408
|
+
healing = false;
|
|
409
|
+
};
|
|
323
410
|
// Codex emits no explicit compaction event, so track peak occupancy: a sharp
|
|
324
411
|
// mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
|
|
325
412
|
let compactPeakTokens = 0;
|
|
326
413
|
srv.onNotification((method, params) => {
|
|
327
414
|
if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
|
|
328
415
|
return;
|
|
416
|
+
lastEventAt = Date.now();
|
|
417
|
+
if (pendingSteer && CODEX_PROGRESS_METHODS.has(method)) {
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
if (now - pendingSteer.at >= CODEX_STEER_CONSUMED_MS)
|
|
420
|
+
pendingSteer = null; // loop provably alive post-steer
|
|
421
|
+
else
|
|
422
|
+
pendingSteer.progressAt = now; // maybe pre-acceptance flush — keep watching
|
|
423
|
+
}
|
|
329
424
|
switch (method) {
|
|
330
425
|
case 'turn/started':
|
|
331
426
|
state.turnId = params?.turn?.id ?? null;
|
|
332
427
|
if (!steerRegistered && state.turnId) {
|
|
333
428
|
steerRegistered = true;
|
|
334
429
|
ctx.registerSteer(async (prompt, attachments = []) => {
|
|
335
|
-
if (!state.sessionId || !state.turnId)
|
|
430
|
+
if (settled || !state.sessionId || !state.turnId)
|
|
336
431
|
return false;
|
|
337
|
-
const
|
|
338
|
-
|
|
432
|
+
const steerInput = buildTurnInput(prompt, attachments);
|
|
433
|
+
// Arm BEFORE sending — accepted, not yet consumed. Arming after the response
|
|
434
|
+
// would race the response's own microtask against progress notifications the
|
|
435
|
+
// server flushed in the same write, mis-arming against already-consumed steers.
|
|
436
|
+
// Back-to-back steers accumulate so a heal replays everything swallowed.
|
|
437
|
+
const armed = { input: [...(pendingSteer?.input ?? []), ...steerInput], at: Date.now(), progressAt: null };
|
|
438
|
+
pendingSteer = armed;
|
|
439
|
+
const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: steerInput }, 30_000);
|
|
440
|
+
if (r.error) {
|
|
441
|
+
if (pendingSteer === armed)
|
|
442
|
+
pendingSteer = null; // rejected — nothing to watch
|
|
339
443
|
return false;
|
|
444
|
+
}
|
|
340
445
|
state.turnId = r.result?.turnId ?? state.turnId;
|
|
341
446
|
return true;
|
|
342
447
|
});
|
|
@@ -466,11 +571,21 @@ export class CodexDriver {
|
|
|
466
571
|
}
|
|
467
572
|
case 'turn/completed': {
|
|
468
573
|
const turn = params?.turn || {};
|
|
574
|
+
applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
|
|
575
|
+
ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
|
|
576
|
+
if (healing)
|
|
577
|
+
break; // completion echo of the turn heal() just interrupted
|
|
578
|
+
if (pendingSteer && !pendingSteer.progressAt && !ctx.signal.aborted && (turn.status ?? 'completed') === 'completed') {
|
|
579
|
+
// The other face of the steer race: the turn finished without ever consuming
|
|
580
|
+
// the injected input. Replay it as a fresh turn instead of settling — the
|
|
581
|
+
// upper layers already dequeued the message on steer-ok, so settling here
|
|
582
|
+
// would silently drop it.
|
|
583
|
+
void heal({ interrupt: false });
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
469
586
|
state.status = turn.status ?? 'completed';
|
|
470
587
|
if (turn.error)
|
|
471
588
|
state.error = turn.error.message || turn.error.code || 'turn error';
|
|
472
|
-
applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
|
|
473
|
-
ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
|
|
474
589
|
settle();
|
|
475
590
|
break;
|
|
476
591
|
}
|
|
@@ -480,6 +595,7 @@ export class CodexDriver {
|
|
|
480
595
|
// accept approvals by default (parity with the legacy codex driver). Never throw —
|
|
481
596
|
// an unanswerable request degrades to an empty response, not a JSON-RPC error.
|
|
482
597
|
srv.onRequest(async (method, params, id) => {
|
|
598
|
+
pendingServerRequests++; // a request awaiting a human legitimately silences the turn
|
|
483
599
|
try {
|
|
484
600
|
if (method === 'item/tool/requestUserInput') {
|
|
485
601
|
const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
|
|
@@ -497,6 +613,9 @@ export class CodexDriver {
|
|
|
497
613
|
catch {
|
|
498
614
|
return {};
|
|
499
615
|
}
|
|
616
|
+
finally {
|
|
617
|
+
pendingServerRequests--;
|
|
618
|
+
}
|
|
500
619
|
});
|
|
501
620
|
const turnResp = await srv.request('turn/start', {
|
|
502
621
|
threadId: state.sessionId,
|
|
@@ -506,6 +625,35 @@ export class CodexDriver {
|
|
|
506
625
|
});
|
|
507
626
|
if (turnResp.error)
|
|
508
627
|
return { ok: false, text: state.text, error: turnResp.error.message || 'turn/start failed', stopReason: 'error', sessionId: state.sessionId };
|
|
628
|
+
// Liveness checker: heals a stalled steer, force-closes a silently dead turn. The
|
|
629
|
+
// silence clock only accumulates while nothing is visibly in flight — a running tool
|
|
630
|
+
// call or a server->client request parked on a human keeps the turn alive forever.
|
|
631
|
+
const checkEveryMs = Math.max(25, Math.min(5_000, Math.floor(Math.min(steerStallMs, turnStallMs) / 4)));
|
|
632
|
+
livenessTimer = setInterval(() => {
|
|
633
|
+
if (settled || healing)
|
|
634
|
+
return;
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
if (pendingSteer) {
|
|
637
|
+
if (now - (pendingSteer.progressAt ?? pendingSteer.at) >= steerStallMs)
|
|
638
|
+
void heal({ interrupt: true });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const busy = pendingServerRequests > 0 || [...toolCalls.values()].some(c => c.status === 'running');
|
|
642
|
+
if (busy) {
|
|
643
|
+
lastEventAt = now;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (now - lastEventAt < turnStallMs)
|
|
647
|
+
return;
|
|
648
|
+
stalled = true;
|
|
649
|
+
state.error = state.error || `codex app-server went silent mid-turn (no events for ${Math.round(turnStallMs / 1000)}s); closing the turn`;
|
|
650
|
+
if (state.sessionId && state.turnId) {
|
|
651
|
+
srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
|
|
652
|
+
}
|
|
653
|
+
else
|
|
654
|
+
settle();
|
|
655
|
+
}, checkEveryMs);
|
|
656
|
+
livenessTimer.unref?.();
|
|
509
657
|
await turnDone;
|
|
510
658
|
const usage = codexUsageOf(state);
|
|
511
659
|
const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
|
|
@@ -515,7 +663,7 @@ export class CodexDriver {
|
|
|
515
663
|
text: codexFinalText(state),
|
|
516
664
|
reasoning: finalReasoning || undefined,
|
|
517
665
|
error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
|
|
518
|
-
stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
|
|
666
|
+
stopReason: ctx.signal.aborted ? 'interrupted' : stalled ? 'stalled' : (state.status || 'end_turn'),
|
|
519
667
|
sessionId: state.sessionId,
|
|
520
668
|
// Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
|
|
521
669
|
anchor: state.turnId,
|
|
@@ -523,6 +671,8 @@ export class CodexDriver {
|
|
|
523
671
|
};
|
|
524
672
|
}
|
|
525
673
|
finally {
|
|
674
|
+
if (livenessTimer)
|
|
675
|
+
clearInterval(livenessTimer);
|
|
526
676
|
srv.kill();
|
|
527
677
|
}
|
|
528
678
|
}
|
|
@@ -10,10 +10,7 @@ export declare function parseJsonLine(line: string): any | undefined;
|
|
|
10
10
|
export declare function wireAbort(signal: AbortSignal, fn: () => void): () => void;
|
|
11
11
|
/** SIGTERM a child, swallowing the already-dead race. */
|
|
12
12
|
export declare function sigterm(proc: ChildProcess | null | undefined): void;
|
|
13
|
-
|
|
14
|
-
export declare function imageMimeForFile(filePath: string): string | null;
|
|
15
|
-
/** The text note substituted for a non-image attachment. */
|
|
16
|
-
export declare function attachedFileNote(filePath: string): string;
|
|
13
|
+
export { imageMimeForFile, attachedFileNote } from '../attachments.js';
|
|
17
14
|
/**
|
|
18
15
|
* Context-window occupancy as a display percent (one decimal, capped at 99.9).
|
|
19
16
|
* Pass `used` as null when the caller wants "no data" rather than 0%.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { extname } from 'node:path';
|
|
2
1
|
// Driver-internal helpers shared by the concrete drivers (claude/codex/gemini/acp).
|
|
3
2
|
// NOT part of the public API — nothing here is re-exported by any barrel. Each helper
|
|
4
3
|
// exists because the same code appeared verbatim in 3+ drivers.
|
|
@@ -43,20 +42,9 @@ export function sigterm(proc) {
|
|
|
43
42
|
}
|
|
44
43
|
catch { /* ignore */ }
|
|
45
44
|
}
|
|
46
|
-
// Attachment vocabulary
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
50
|
-
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
51
|
-
};
|
|
52
|
-
/** Mime type when the file is an inlineable image, else null. */
|
|
53
|
-
export function imageMimeForFile(filePath) {
|
|
54
|
-
return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
|
|
55
|
-
}
|
|
56
|
-
/** The text note substituted for a non-image attachment. */
|
|
57
|
-
export function attachedFileNote(filePath) {
|
|
58
|
-
return `[Attached file: ${filePath}]`;
|
|
59
|
-
}
|
|
45
|
+
// Attachment vocabulary lives in ../attachments.ts (the Hub also normalizes oversized
|
|
46
|
+
// images there); re-exported so drivers keep one import site for driver-internal helpers.
|
|
47
|
+
export { imageMimeForFile, attachedFileNote } from '../attachments.js';
|
|
60
48
|
/**
|
|
61
49
|
* Context-window occupancy as a display percent (one decimal, capped at 99.9).
|
|
62
50
|
* Pass `used` as null when the caller wants "no data" rather than 0%.
|
|
@@ -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';
|
|
@@ -16,7 +16,7 @@ export function emptySnapshot() {
|
|
|
16
16
|
return { phase: 'idle', updatedAt: 0 };
|
|
17
17
|
}
|
|
18
18
|
const APPEND_FIELDS = ['text', 'reasoning'];
|
|
19
|
-
const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
|
|
19
|
+
const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued', 'compaction'];
|
|
20
20
|
const SCALAR_FIELDS = ['phase', 'taskId', 'sessionId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt', 'anchor'];
|
|
21
21
|
export function diffSnapshot(prev, next) {
|
|
22
22
|
const patch = {};
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { diffSnapshot, emptySnapshot, makeSessionKey, splitSessionKey, } from '../protocol/index.js';
|
|
3
3
|
import { SessionRunner } from './session-runner.js';
|
|
4
4
|
import { buildForkSeed } from './fork.js';
|
|
5
|
+
import { normalizeImageAttachments } from '../attachments.js';
|
|
5
6
|
export class Hub {
|
|
6
7
|
deps;
|
|
7
8
|
sessions = new Map();
|
|
@@ -262,7 +263,9 @@ export class Hub {
|
|
|
262
263
|
}
|
|
263
264
|
const turnInput = {
|
|
264
265
|
prompt: driverPrompt,
|
|
265
|
-
|
|
266
|
+
// Oversized images are downscaled here and in steer() — the two dispatch chokepoints —
|
|
267
|
+
// so every driver (built-in or host-plugged) sends model-safe attachments.
|
|
268
|
+
attachments: await normalizeImageAttachments(input.attachments, this.deps.log),
|
|
266
269
|
sessionId: driverSessionId,
|
|
267
270
|
fork: driverFork,
|
|
268
271
|
rewind: driverRewind,
|
|
@@ -467,7 +470,7 @@ export class Hub {
|
|
|
467
470
|
}
|
|
468
471
|
async steer(taskId, prompt, attachments) {
|
|
469
472
|
const r = this.runnersByTask.get(taskId);
|
|
470
|
-
return r ? r.steer(prompt, attachments) : false;
|
|
473
|
+
return r ? r.steer(prompt, await normalizeImageAttachments(attachments, this.deps.log)) : false;
|
|
471
474
|
}
|
|
472
475
|
interact(promptId, action, value) {
|
|
473
476
|
for (const r of this.runnersByTask.values()) {
|