@pikiloom/kernel 0.2.21 → 0.3.1

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.
@@ -1,112 +1,6 @@
1
- import { spawn } from 'node:child_process';
2
- import { discoverCodexNativeSessions } from '../workspace/native.js';
3
- // Minimal newline-delimited JSON-RPC client for `codex app-server` (ported faithfully
4
- // from pikiloom's CodexAppServer; trimmed to what the kernel needs).
5
- class AppServer {
6
- bin;
7
- configOverrides;
8
- env;
9
- proc = null;
10
- buf = '';
11
- nextId = 1;
12
- pending = new Map();
13
- notify;
14
- requestResponder;
15
- constructor(bin, configOverrides, env) {
16
- this.bin = bin;
17
- this.configOverrides = configOverrides;
18
- this.env = env;
19
- }
20
- onNotification(cb) { this.notify = cb; }
21
- onServerRequest(cb) { this.requestResponder = cb; }
22
- async start() {
23
- const args = ['app-server'];
24
- // Do NOT force model_reasoning_summary — codex reasoning summaries stay OFF by default
25
- // (respecting ~/.codex/config.toml, which is the original behavior). A caller that wants
26
- // thinking can pass `model_reasoning_summary=...` via configOverrides; we never inject it.
27
- const overrides = [...this.configOverrides];
28
- if (!overrides.some(c => /^features\.goals\s*=/.test(c)))
29
- overrides.push('features.goals=true');
30
- for (const c of overrides)
31
- args.push('-c', c);
32
- try {
33
- this.proc = spawn(this.bin, args, { stdio: ['pipe', 'pipe', 'pipe'], env: this.env ? { ...process.env, ...this.env } : process.env });
34
- }
35
- catch {
36
- return false;
37
- }
38
- this.proc.stdout.on('data', (chunk) => this.onData(chunk));
39
- this.proc.on('close', () => { for (const cb of this.pending.values())
40
- cb({ error: { message: 'app-server exited' } }); this.pending.clear(); });
41
- this.proc.on('error', () => { });
42
- const init = await this.call('initialize', { clientInfo: { name: '@pikiloom/kernel', version: '0.1.0' }, capabilities: { experimentalApi: true } }, 15_000);
43
- return !init.error;
44
- }
45
- onData(chunk) {
46
- this.buf += chunk.toString('utf8');
47
- const lines = this.buf.split('\n');
48
- this.buf = lines.pop() || '';
49
- for (const line of lines) {
50
- if (!line.trim())
51
- continue;
52
- let msg;
53
- try {
54
- msg = JSON.parse(line);
55
- }
56
- catch {
57
- continue;
58
- }
59
- if (msg.method && msg.id != null) { // server -> client request
60
- const id = msg.id;
61
- Promise.resolve(this.requestResponder ? this.requestResponder(msg.method, msg.params ?? {}, id) : {})
62
- .then((result) => this.respond(id, result ?? {}))
63
- .catch(() => this.respond(id, {}));
64
- }
65
- else if (msg.id != null) { // response to our call
66
- const cb = this.pending.get(msg.id);
67
- if (cb) {
68
- this.pending.delete(msg.id);
69
- cb(msg);
70
- }
71
- }
72
- else if (msg.method) { // notification
73
- this.notify?.(msg.method, msg.params ?? {});
74
- }
75
- }
76
- }
77
- call(method, params, timeoutMs = 60_000) {
78
- return new Promise((resolve) => {
79
- if (!this.proc || this.proc.killed) {
80
- resolve({ error: { message: 'not connected' } });
81
- return;
82
- }
83
- const id = this.nextId++;
84
- const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `RPC '${method}' timed out` } }); }, timeoutMs);
85
- this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
86
- const msg = { jsonrpc: '2.0', id, method };
87
- if (params !== undefined)
88
- msg.params = params;
89
- try {
90
- this.proc.stdin.write(JSON.stringify(msg) + '\n');
91
- }
92
- catch {
93
- clearTimeout(timer);
94
- this.pending.delete(id);
95
- resolve({ error: { message: 'write failed' } });
96
- }
97
- });
98
- }
99
- respond(id, result) {
100
- try {
101
- this.proc?.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
102
- }
103
- catch { /* closed */ }
104
- }
105
- kill() { try {
106
- this.proc?.kill('SIGTERM');
107
- }
108
- catch { /* ignore */ } this.proc = null; }
109
- }
1
+ import { discoverCodexNativeSessions } from './native.js';
2
+ import { StdioRpcClient } from './rpc.js';
3
+ import { attachedFileNote, contextPercent, imageMimeForFile, wireAbort } from './shared.js';
110
4
  function planFromUpdate(params) {
111
5
  const rawSteps = Array.isArray(params?.plan?.steps) ? params.plan.steps : Array.isArray(params?.steps) ? params.steps : Array.isArray(params?.plan) ? params.plan : [];
112
6
  const steps = rawSteps.map((st) => {
@@ -247,16 +141,23 @@ export class CodexDriver {
247
141
  // BYOK provider routing arrives as `-c key=value` overrides; pass them through so the
248
142
  // kernel path keeps third-party models (e.g. glm via OpenRouter) instead of falling back
249
143
  // to the native account.
250
- const config = [...(input.configOverrides || [])];
251
- const srv = new AppServer(this.bin, config, input.env);
144
+ // Do NOT force model_reasoning_summary — codex reasoning summaries stay OFF by default
145
+ // (respecting ~/.codex/config.toml, which is the original behavior). A caller that wants
146
+ // thinking can pass `model_reasoning_summary=...` via configOverrides; we never inject it.
147
+ const overrides = [...(input.configOverrides || [])];
148
+ if (!overrides.some(c => /^features\.goals\s*=/.test(c)))
149
+ overrides.push('features.goals=true');
150
+ const args = ['app-server'];
151
+ for (const c of overrides)
152
+ args.push('-c', c);
153
+ const srv = new StdioRpcClient({ command: this.bin, args, env: input.env, label: 'codex app-server' });
252
154
  const state = { text: '', reasoning: '', streamedReasoning: false, msgs: [], thinkParts: [], sessionId: input.sessionId ?? null, input: null, output: null, cached: null, contextUsed: null, contextWindow: null, status: null, error: null, turnId: null };
253
155
  const phases = new Map();
254
156
  const toolSummaries = new Map();
255
157
  const deltaItems = new Set();
256
158
  let lastTextItemId = null;
257
159
  let steerRegistered = false;
258
- const ok = await srv.start();
259
- if (!ok)
160
+ if (!srv.start())
260
161
  return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
261
162
  let settle = () => { };
262
163
  const turnDone = new Promise((res) => { settle = res; });
@@ -264,20 +165,19 @@ export class CodexDriver {
264
165
  // srv.kill() (SIGTERM) never produces a turn/completed notification, so without this explicit
265
166
  // settle() the `await turnDone` below hangs forever — run() never resolves and the task stays
266
167
  // "running" in the orchestrator even though the codex process is already dead ("停止不掉,但实际上已经停了").
267
- const onAbort = () => {
168
+ wireAbort(ctx.signal, () => {
268
169
  if (state.sessionId && state.turnId) {
269
- srv.call('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
170
+ srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
270
171
  }
271
172
  else {
272
173
  srv.kill();
273
174
  settle();
274
175
  }
275
- };
276
- if (ctx.signal.aborted)
277
- onAbort();
278
- else
279
- ctx.signal.addEventListener('abort', onAbort, { once: true });
176
+ });
280
177
  try {
178
+ const init = await srv.request('initialize', { clientInfo: { name: '@pikiloom/kernel', version: '0.1.0' }, capabilities: { experimentalApi: true } }, 15_000);
179
+ if (init.error)
180
+ return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
281
181
  const threadParams = { cwd: input.workdir, model: input.model || null };
282
182
  if (input.systemPrompt)
283
183
  threadParams.developerInstructions = input.systemPrompt;
@@ -286,8 +186,8 @@ export class CodexDriver {
286
186
  threadParams.sandbox = 'danger-full-access';
287
187
  }
288
188
  const threadResp = input.sessionId
289
- ? await srv.call('thread/resume', { threadId: input.sessionId, ...threadParams })
290
- : await srv.call('thread/start', threadParams);
189
+ ? await srv.request('thread/resume', { threadId: input.sessionId, ...threadParams })
190
+ : await srv.request('thread/start', threadParams);
291
191
  if (threadResp.error)
292
192
  return { ok: false, text: '', error: threadResp.error.message || 'thread/start failed', stopReason: 'error' };
293
193
  const threadId = threadResp.result?.thread?.id ?? input.sessionId ?? null;
@@ -306,7 +206,7 @@ export class CodexDriver {
306
206
  ctx.registerSteer(async (prompt, attachments = []) => {
307
207
  if (!state.sessionId || !state.turnId)
308
208
  return false;
309
- const r = await srv.call('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: buildTurnInput(prompt, attachments) }, 30_000);
209
+ const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: buildTurnInput(prompt, attachments) }, 30_000);
310
210
  if (r.error)
311
211
  return false;
312
212
  state.turnId = r.result?.turnId ?? state.turnId;
@@ -400,22 +300,28 @@ export class CodexDriver {
400
300
  }
401
301
  });
402
302
  // Codex server->client requests: route user-input to the HITL seam (ctx.askUser),
403
- // accept approvals by default (parity with the legacy codex driver).
404
- srv.onServerRequest(async (method, params, id) => {
405
- if (method === 'item/tool/requestUserInput') {
406
- const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
407
- if (!interaction)
408
- return { answers: {} };
409
- const answers = await ctx.askUser(interaction);
410
- return { answers: Object.fromEntries(Object.entries(answers).map(([qid, vals]) => [qid, { answers: vals }])) };
303
+ // accept approvals by default (parity with the legacy codex driver). Never throw —
304
+ // an unanswerable request degrades to an empty response, not a JSON-RPC error.
305
+ srv.onRequest(async (method, params, id) => {
306
+ try {
307
+ if (method === 'item/tool/requestUserInput') {
308
+ const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
309
+ if (!interaction)
310
+ return { answers: {} };
311
+ const answers = await ctx.askUser(interaction);
312
+ return { answers: Object.fromEntries(Object.entries(answers).map(([qid, vals]) => [qid, { answers: vals }])) };
313
+ }
314
+ if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval')
315
+ return { decision: 'accept' };
316
+ if (method === 'item/permissions/requestApproval')
317
+ return { permissions: {}, scope: 'turn' };
318
+ return {};
319
+ }
320
+ catch {
321
+ return {};
411
322
  }
412
- if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval')
413
- return { decision: 'accept' };
414
- if (method === 'item/permissions/requestApproval')
415
- return { permissions: {}, scope: 'turn' };
416
- return {};
417
323
  });
418
- const turnResp = await srv.call('turn/start', {
324
+ const turnResp = await srv.request('turn/start', {
419
325
  threadId: state.sessionId,
420
326
  input: buildTurnInput(input.prompt, input.attachments || []),
421
327
  model: input.model || undefined,
@@ -453,12 +359,10 @@ export class CodexDriver {
453
359
  return discoverCodexNativeSessions(opts.workdir, { limit: opts.limit });
454
360
  }
455
361
  }
456
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
457
362
  function buildTurnInput(prompt, attachments) {
458
363
  const input = [];
459
364
  for (const f of attachments) {
460
- const ext = f.slice(f.lastIndexOf('.')).toLowerCase();
461
- input.push(IMAGE_EXTS.has(ext) ? { type: 'localImage', path: f } : { type: 'text', text: `[Attached file: ${f}]` });
365
+ input.push(imageMimeForFile(f) ? { type: 'localImage', path: f } : { type: 'text', text: attachedFileNote(f) });
462
366
  }
463
367
  input.push({ type: 'text', text: prompt });
464
368
  return input;
@@ -486,7 +390,7 @@ function codexContextUsed(raw) {
486
390
  }
487
391
  // Fold a codex tokenUsage payload into driver state: last-turn counts, context
488
392
  // occupancy, and the model window. Tolerant of nested {info:{…}} and flat shapes.
489
- export function applyCodexTokenUsage(s, rawUsage) {
393
+ function applyCodexTokenUsage(s, rawUsage) {
490
394
  if (!rawUsage || typeof rawUsage !== 'object')
491
395
  return;
492
396
  const info = rawUsage.info && typeof rawUsage.info === 'object' ? rawUsage.info : rawUsage;
@@ -521,18 +425,17 @@ export function applyCodexTokenUsage(s, rawUsage) {
521
425
  if (cw != null && cw > 0)
522
426
  s.contextWindow = cw;
523
427
  }
524
- export function codexUsageOf(s) {
428
+ function codexUsageOf(s) {
525
429
  const fallback = (s.input ?? 0) + (s.cached ?? 0);
526
430
  const used = s.contextUsed ?? (fallback > 0 ? fallback : null);
527
431
  const window = s.contextWindow ?? null;
528
- const contextPercent = used != null && window ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
529
432
  const turnOutput = s.output ?? 0;
530
433
  return {
531
434
  inputTokens: s.input,
532
435
  outputTokens: s.output,
533
436
  cachedInputTokens: s.cached,
534
437
  contextUsedTokens: used,
535
- contextPercent,
438
+ contextPercent: contextPercent(used, window),
536
439
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
537
440
  };
538
441
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { discoverGeminiNativeSessions } from '../workspace/native.js';
2
+ import { discoverGeminiNativeSessions } from './native.js';
3
+ import { createLineBuffer, parseJsonLine, sigterm, wireAbort } from './shared.js';
3
4
  // Native kernel Gemini driver: `gemini --output-format stream-json ... -p <prompt>` and
4
5
  // parse its stream-json events into kernel DriverEvents. Faithful to pikiloom's geminiParse.
5
6
  export class GeminiDriver {
@@ -21,7 +22,7 @@ export class GeminiDriver {
21
22
  if (extra.length)
22
23
  args.push(...extra);
23
24
  args.push('-p', input.prompt);
24
- const s = { text: '', sessionId: input.sessionId ?? null, model: input.model ?? null, input: null, output: null, cached: null, stopReason: null, error: null };
25
+ const s = { text: '', sessionId: input.sessionId ?? null, input: null, output: null, cached: null, stopReason: null, error: null };
25
26
  const tools = new Map();
26
27
  return new Promise((resolve) => {
27
28
  let child;
@@ -32,32 +33,14 @@ export class GeminiDriver {
32
33
  resolve({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
33
34
  return;
34
35
  }
35
- const onAbort = () => { try {
36
- child.kill('SIGTERM');
37
- }
38
- catch { /* ignore */ } };
39
- if (ctx.signal.aborted)
40
- onAbort();
41
- else
42
- ctx.signal.addEventListener('abort', onAbort, { once: true });
43
- let buf = '';
36
+ wireAbort(ctx.signal, () => sigterm(child));
37
+ const nextLines = createLineBuffer();
44
38
  let stderr = '';
45
39
  child.stdout.on('data', (chunk) => {
46
- buf += chunk.toString('utf8');
47
- const lines = buf.split('\n');
48
- buf = lines.pop() || '';
49
- for (const line of lines) {
50
- const trimmed = line.trim();
51
- if (!trimmed)
52
- continue;
53
- let ev;
54
- try {
55
- ev = JSON.parse(trimmed);
56
- }
57
- catch {
58
- continue;
59
- }
60
- parseGeminiEvent(ev, s, tools, ctx.emit);
40
+ for (const line of nextLines(chunk)) {
41
+ const ev = parseJsonLine(line);
42
+ if (ev !== undefined)
43
+ parseGeminiEvent(ev, s, tools, ctx.emit);
61
44
  }
62
45
  });
63
46
  child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
@@ -92,7 +75,6 @@ export function parseGeminiEvent(ev, s, tools, emit) {
92
75
  s.sessionId = ev.session_id;
93
76
  emit({ type: 'session', sessionId: ev.session_id });
94
77
  }
95
- s.model = ev.model ?? s.model;
96
78
  return;
97
79
  }
98
80
  if (t === 'message' && ev.role === 'assistant') {
@@ -1,5 +1,4 @@
1
- import { AcpDriver, applyAcpUpdate } from './acp.js';
1
+ import { AcpDriver } from './acp.js';
2
2
  export declare class HermesDriver extends AcpDriver {
3
3
  constructor(bin?: string);
4
4
  }
5
- export declare const applyHermesUpdate: typeof applyAcpUpdate;
@@ -1,4 +1,4 @@
1
- import { AcpDriver, applyAcpUpdate } from './acp.js';
1
+ import { AcpDriver } from './acp.js';
2
2
  // Hermes = the reference ACP agent, shipped as a thin preset over the generic AcpDriver.
3
3
  // Any other ACP CLI (OpenCode, Gemini-ACP, …) is just `new AcpDriver({ id, command, args })`.
4
4
  export class HermesDriver extends AcpDriver {
@@ -6,6 +6,3 @@ export class HermesDriver extends AcpDriver {
6
6
  super({ id: 'hermes', command: bin, args: ['acp'] });
7
7
  }
8
8
  }
9
- // Back-compat alias: the generic ACP session/update parser handles the identical wire that
10
- // the original hermes-specific parser did. Kept so existing imports/tests resolve.
11
- export const applyHermesUpdate = applyAcpUpdate;
@@ -1,6 +1,7 @@
1
1
  export { EchoDriver } from './echo.js';
2
- export { ClaudeDriver, handleClaudeEvent, todoWriteToPlan } from './claude.js';
2
+ export { ClaudeDriver } from './claude.js';
3
3
  export { CodexDriver } from './codex.js';
4
- export { GeminiDriver, parseGeminiEvent } from './gemini.js';
5
- export { AcpDriver, applyAcpUpdate, toAcpMcpServers, buildAcpPromptBlocks, type AcpDriverConfig } from './acp.js';
6
- export { HermesDriver, applyHermesUpdate } from './hermes.js';
4
+ export { GeminiDriver } from './gemini.js';
5
+ export { AcpDriver, type AcpDriverConfig } from './acp.js';
6
+ export { HermesDriver } from './hermes.js';
7
+ export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, type DiscoverOptions, type NativeSessionInfo, } from './native.js';
@@ -1,6 +1,10 @@
1
+ // The agent axis: one driver per coding-agent CLI, plus native-session discovery (pure
2
+ // readers of each CLI's own on-disk transcript store). White-box parser/settle helpers are
3
+ // deliberately NOT re-exported here — tests import them from their defining module.
1
4
  export { EchoDriver } from './echo.js';
2
- export { ClaudeDriver, handleClaudeEvent, todoWriteToPlan } from './claude.js';
5
+ export { ClaudeDriver } from './claude.js';
3
6
  export { CodexDriver } from './codex.js';
4
- export { GeminiDriver, parseGeminiEvent } from './gemini.js';
5
- export { AcpDriver, applyAcpUpdate, toAcpMcpServers, buildAcpPromptBlocks } from './acp.js';
6
- export { HermesDriver, applyHermesUpdate } from './hermes.js';
7
+ export { GeminiDriver } from './gemini.js';
8
+ export { AcpDriver } from './acp.js';
9
+ export { HermesDriver } from './hermes.js';
10
+ export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, } from './native.js';
@@ -6,7 +6,6 @@ export interface DiscoverOptions {
6
6
  /** A session whose file changed within this window is treated as "running". */
7
7
  runningThresholdMs?: number;
8
8
  }
9
- export declare const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10000;
10
9
  /** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
11
10
  export declare function encodeClaudeProjectDir(workdir: string): string;
12
11
  export declare function discoverClaudeNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- export const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10_000;
4
+ const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10_000;
5
5
  function homeOf(opts) {
6
6
  return opts?.home || os.homedir();
7
7
  }
@@ -0,0 +1,45 @@
1
+ export type RpcMsg = {
2
+ jsonrpc?: string;
3
+ id?: number | string;
4
+ method?: string;
5
+ params?: any;
6
+ result?: any;
7
+ error?: any;
8
+ };
9
+ /** Throw from an onRequest handler to answer with a specific JSON-RPC error code. */
10
+ export declare class RpcError extends Error {
11
+ readonly code: number;
12
+ constructor(code: number, message: string);
13
+ }
14
+ export interface StdioRpcOptions {
15
+ command: string;
16
+ args: string[];
17
+ env?: Record<string, string>;
18
+ cwd?: string;
19
+ label?: string;
20
+ }
21
+ export declare class StdioRpcClient {
22
+ private readonly opts;
23
+ private proc;
24
+ private nextId;
25
+ private readonly pending;
26
+ private notifyCb?;
27
+ private requestCb?;
28
+ private readonly stderrTail;
29
+ private readonly label;
30
+ constructor(opts: StdioRpcOptions);
31
+ onNotification(cb: (method: string, params: any) => void): void;
32
+ /** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
33
+ onRequest(cb: (method: string, params: any, id: number | string) => any | Promise<any>): void;
34
+ /** Rolling tail of the process' stderr — startup/crash diagnostics. */
35
+ stderrText(): string;
36
+ start(): boolean;
37
+ private onLine;
38
+ private handleRequest;
39
+ request(method: string, params?: any, timeoutMs?: number): Promise<RpcMsg>;
40
+ notify(method: string, params?: any): void;
41
+ private respond;
42
+ private respondError;
43
+ private write;
44
+ kill(): void;
45
+ }
@@ -0,0 +1,130 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ /** Throw from an onRequest handler to answer with a specific JSON-RPC error code. */
4
+ export class RpcError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ }
10
+ }
11
+ const STDERR_TAIL_LINES = 20;
12
+ export class StdioRpcClient {
13
+ opts;
14
+ proc = null;
15
+ nextId = 1;
16
+ pending = new Map();
17
+ notifyCb;
18
+ requestCb;
19
+ stderrTail = [];
20
+ label;
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ this.label = opts.label || opts.command;
24
+ }
25
+ onNotification(cb) { this.notifyCb = cb; }
26
+ /** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
27
+ onRequest(cb) { this.requestCb = cb; }
28
+ /** Rolling tail of the process' stderr — startup/crash diagnostics. */
29
+ stderrText() { return this.stderrTail.join('\n'); }
30
+ start() {
31
+ try {
32
+ this.proc = spawn(this.opts.command, this.opts.args, {
33
+ cwd: this.opts.cwd,
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ env: this.opts.env ? { ...process.env, ...this.opts.env } : process.env,
36
+ });
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ const rl = createInterface({ input: this.proc.stdout, crlfDelay: Infinity });
42
+ rl.on('line', (line) => this.onLine(line));
43
+ // Always drain stderr (an unread pipe backpressures the child) and keep a tail for errors.
44
+ this.proc.stderr.on('data', (chunk) => {
45
+ for (const ln of chunk.toString('utf8').split('\n')) {
46
+ const t = ln.trim();
47
+ if (!t)
48
+ continue;
49
+ this.stderrTail.push(t.slice(0, 240));
50
+ if (this.stderrTail.length > STDERR_TAIL_LINES)
51
+ this.stderrTail.shift();
52
+ }
53
+ });
54
+ this.proc.on('close', () => {
55
+ for (const cb of this.pending.values())
56
+ cb({ error: { message: `${this.label} exited` } });
57
+ this.pending.clear();
58
+ });
59
+ this.proc.on('error', () => { });
60
+ return true;
61
+ }
62
+ onLine(line) {
63
+ const t = line.trim();
64
+ if (!t)
65
+ return;
66
+ let m;
67
+ try {
68
+ m = JSON.parse(t);
69
+ }
70
+ catch {
71
+ return;
72
+ } // non-JSON stdout noise
73
+ if (m.method && m.id != null) {
74
+ void this.handleRequest(m);
75
+ return;
76
+ } // peer -> client request
77
+ if (m.id != null) { // response to one of our requests
78
+ const cb = this.pending.get(m.id);
79
+ if (cb) {
80
+ this.pending.delete(m.id);
81
+ cb(m);
82
+ }
83
+ return;
84
+ }
85
+ if (m.method)
86
+ this.notifyCb?.(m.method, m.params ?? {}); // notification
87
+ }
88
+ async handleRequest(m) {
89
+ const id = m.id;
90
+ if (!this.requestCb) {
91
+ this.respondError(id, -32601, `Method not implemented: ${m.method}`);
92
+ return;
93
+ }
94
+ try {
95
+ const result = await this.requestCb(m.method, m.params ?? {}, id);
96
+ this.respond(id, result ?? null);
97
+ }
98
+ catch (e) {
99
+ const code = e instanceof RpcError ? e.code : -32603;
100
+ this.respondError(id, code, e?.message || 'handler error');
101
+ }
102
+ }
103
+ request(method, params, timeoutMs = 60_000) {
104
+ return new Promise((resolve) => {
105
+ if (!this.proc || this.proc.killed) {
106
+ resolve({ error: { message: 'not connected' } });
107
+ return;
108
+ }
109
+ const id = this.nextId++;
110
+ const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `${this.label} '${method}' timed out` } }); }, timeoutMs);
111
+ this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
112
+ this.write({ jsonrpc: '2.0', id, method, params });
113
+ });
114
+ }
115
+ notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
116
+ respond(id, result) { this.write({ jsonrpc: '2.0', id, result }); }
117
+ respondError(id, code, message) { this.write({ jsonrpc: '2.0', id, error: { code, message } }); }
118
+ write(msg) {
119
+ if (!this.proc || this.proc.killed)
120
+ return;
121
+ try {
122
+ this.proc.stdin.write(JSON.stringify(msg) + '\n');
123
+ }
124
+ catch { /* stream closed */ }
125
+ }
126
+ kill() { try {
127
+ this.proc?.kill('SIGTERM');
128
+ }
129
+ catch { /* ignore */ } this.proc = null; }
130
+ }
@@ -0,0 +1,21 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ /** Stateful newline splitter for a child process' stdout: feed chunks, get complete lines. */
3
+ export declare function createLineBuffer(): (chunk: Buffer | string) => string[];
4
+ /** Parse one ndjson line; undefined for blank lines / non-JSON noise. */
5
+ export declare function parseJsonLine(line: string): any | undefined;
6
+ /**
7
+ * Run `fn` when the signal aborts (immediately if it already has). Returns an
8
+ * unsubscribe for drivers that must detach the handler when the turn settles.
9
+ */
10
+ export declare function wireAbort(signal: AbortSignal, fn: () => void): () => void;
11
+ /** SIGTERM a child, swallowing the already-dead race. */
12
+ export declare function sigterm(proc: ChildProcess | null | undefined): void;
13
+ /** Mime type when the file is an inlineable image, else null. */
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;
17
+ /**
18
+ * Context-window occupancy as a display percent (one decimal, capped at 99.9).
19
+ * Pass `used` as null when the caller wants "no data" rather than 0%.
20
+ */
21
+ export declare function contextPercent(used: number | null | undefined, window: number | null | undefined): number | null;