pikiloom 0.4.61 → 0.4.62

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.
Files changed (31) hide show
  1. package/package.json +2 -1
  2. package/packages/kernel/README.md +20 -7
  3. package/packages/kernel/dist/drivers/acp.js +30 -163
  4. package/packages/kernel/dist/drivers/claude.d.ts +1 -21
  5. package/packages/kernel/dist/drivers/claude.js +29 -57
  6. package/packages/kernel/dist/drivers/codex.d.ts +0 -10
  7. package/packages/kernel/dist/drivers/codex.js +47 -144
  8. package/packages/kernel/dist/drivers/gemini.js +9 -27
  9. package/packages/kernel/dist/drivers/hermes.d.ts +1 -2
  10. package/packages/kernel/dist/drivers/hermes.js +1 -4
  11. package/packages/kernel/dist/drivers/index.d.ts +5 -4
  12. package/packages/kernel/dist/drivers/index.js +8 -4
  13. package/packages/kernel/dist/{workspace → drivers}/native.d.ts +0 -1
  14. package/packages/kernel/dist/{workspace → drivers}/native.js +1 -1
  15. package/packages/kernel/dist/drivers/rpc.d.ts +45 -0
  16. package/packages/kernel/dist/drivers/rpc.js +130 -0
  17. package/packages/kernel/dist/drivers/shared.d.ts +21 -0
  18. package/packages/kernel/dist/drivers/shared.js +66 -0
  19. package/packages/kernel/dist/index.d.ts +2 -1
  20. package/packages/kernel/dist/index.js +10 -2
  21. package/packages/kernel/dist/protocol/index.d.ts +5 -0
  22. package/packages/kernel/dist/protocol/index.js +10 -0
  23. package/packages/kernel/dist/runtime/hub.js +4 -8
  24. package/packages/kernel/dist/workspace/index.d.ts +1 -2
  25. package/packages/kernel/dist/workspace/index.js +3 -2
  26. package/packages/kernel/dist/workspace/mcp.js +6 -16
  27. package/packages/kernel/dist/workspace/npm-search.d.ts +9 -0
  28. package/packages/kernel/dist/workspace/npm-search.js +21 -0
  29. package/packages/kernel/dist/workspace/sessions.js +6 -9
  30. package/packages/kernel/dist/workspace/skills.d.ts +11 -0
  31. package/packages/kernel/dist/workspace/skills.js +14 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.61",
3
+ "version": "0.4.62",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "dist/",
11
11
  "dashboard/dist/",
12
12
  "packages/kernel/dist/",
13
+ "packages/kernel/examples/console.html",
13
14
  "LICENSE",
14
15
  "README.md"
15
16
  ],
@@ -205,7 +205,8 @@ interrupts only the current turn and the next queued task promotes.
205
205
  | `ClaudeDriver` | `claude` | `claude` CLI, stream-json (+ `--effort`, partial messages) | ✓ | — | ✓ | ✓ |
206
206
  | `CodexDriver` | `codex` | `codex app-server` JSON-RPC (HITL via `requestUserInput`) | ✓ | via askUser | ✓ | ✓ |
207
207
  | `GeminiDriver` | `gemini` | `gemini --output-format stream-json` | — | — | ✓ | ✓ |
208
- | `HermesDriver` | `hermes` | ACP `session/update` | — | | ✓ | — |
208
+ | `AcpDriver` | *(config)* | generic ACP ndjson JSON-RPC — any ACP CLI: `new AcpDriver({ id, command, args })` | — | via askUser | ✓ | — |
209
+ | `HermesDriver` | `hermes` | ACP preset over `AcpDriver` (`hermes acp`) | — | via askUser | ✓ | — |
209
210
  | `EchoDriver` | `echo` | none (hermetic, in-process) | ✓ | ✓ | ✓ | ✓ |
210
211
 
211
212
  Write your own by implementing `AgentDriver` and passing it to `createLoom({ drivers })` (or
@@ -311,16 +312,28 @@ node-builtins-only and additive — every existing port/default is unchanged.
311
312
 
312
313
  ## Exports
313
314
 
314
- Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel/drivers`,
315
- `@pikiloom/kernel/surfaces`, `@pikiloom/kernel/protocol`.
315
+ Main entry `@pikiloom/kernel` is the public API, pinned by `test/api-surface.test.ts`.
316
+ Subpaths: `@pikiloom/kernel/drivers`, `@pikiloom/kernel/surfaces`, `@pikiloom/kernel/protocol`.
317
+ Driver-internal parser/settle helpers are exported only from their modules (for white-box
318
+ tests) and are NOT part of the public surface.
316
319
 
317
320
  - Runtime: `createLoom`, `Loom`, `Hub`, `SessionRunner`, `runTurn`, `PtyBridge`, `ptyAvailable`, `attachTui`
318
- - Drivers: `EchoDriver`, `ClaudeDriver`, `CodexDriver`, `GeminiDriver`, `HermesDriver`
321
+ - Drivers: `EchoDriver`, `ClaudeDriver`, `CodexDriver`, `GeminiDriver`, `AcpDriver` (+ `AcpDriverConfig`), `HermesDriver`
322
+ - Native discovery (driver-axis): `discover{Claude,Codex,Gemini}NativeSessions`, `encodeClaudeProjectDir` + type `DiscoverOptions`
319
323
  - Surfaces: `WebSurface`, `CliSurface`
320
324
  - Ports/defaults: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`, `DeferToTerminalInteractionHandler`, `NoopCatalog`, `defaultBaseDir`
321
- - Workspace: `resolveLoomPaths`, `SessionsManager`, `SkillsManager`, `McpRegistry`, `ensureDirSymlink`, `discover{Claude,Codex,Gemini}NativeSessions` + types `LoomPaths`, `LoomScope`, `ManagedSessionInfo`, `NativeSessionInfo`, `SkillInfo`, `McpCatalogEntry`
322
- - Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, all wire/`Client*`/`Server*` message types
323
- - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
325
+ - Workspace: `resolveLoomPaths`, `normalizeStateDirName`, `SessionsManager`, `SkillsManager`, `McpRegistry`, `ensureDirSymlink`, `parseSkillMeta` + types `LoomPaths`, `LoomScope`, `ManagedSessionInfo`, `SkillInfo`, `SkillMeta`, `McpCatalogEntry`
326
+ - Multi-account: `accountTokenSupported`, `accountTokenEnvVar`, `accountTokenEnv` — which env var carries an agent's auth token, so an app can inject a selected account's token per spawn (claude: `CLAUDE_CODE_OAUTH_TOKEN`; storage/selection stay app-side)
327
+ - Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, `makeSessionKey`, `splitSessionKey`, all wire/`Client*`/`Server*` message types
328
+ - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `NativeSessionInfo`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
329
+
330
+ ### Claude driver tuning (env)
331
+
332
+ The claude driver's background-hold / stall / recovery heuristics ship sane defaults and can
333
+ be tuned per deployment: `PIKILOOM_CLAUDE_BG_HOLD_MS`, `PIKILOOM_CLAUDE_BG_AGENT_HOLD_MS`,
334
+ `PIKILOOM_CLAUDE_BG_HOLD_RECHECK_MS`, `PIKILOOM_CLAUDE_BG_SETTLE_QUIET_MS`,
335
+ `PIKILOOM_CLAUDE_MODEL_STALL_MS`, `PIKILOOM_CLAUDE_TRUNCATED_RECOVERY` (=0 disables),
336
+ `PIKILOOM_CLAUDE_RESUME_NOOP_RETRIES`.
324
337
 
325
338
  ---
326
339
 
@@ -1,132 +1,7 @@
1
- import { spawn } from 'node:child_process';
2
- import { createInterface } from 'node:readline';
3
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { dirname, extname } from 'node:path';
5
- class AcpRpcError extends Error {
6
- code;
7
- constructor(code, message) {
8
- super(message);
9
- this.code = code;
10
- }
11
- }
12
- // ── ACP JSON-RPC client over a child process' stdio (ndjson framing) ───────────
13
- class AcpClient {
14
- bin;
15
- args;
16
- env;
17
- cwd;
18
- proc = null;
19
- nextId = 1;
20
- pending = new Map();
21
- notifyCb;
22
- requestCb;
23
- stderrTail = [];
24
- constructor(bin, args, env, cwd) {
25
- this.bin = bin;
26
- this.args = args;
27
- this.env = env;
28
- this.cwd = cwd;
29
- }
30
- onNotification(cb) { this.notifyCb = cb; }
31
- onRequest(cb) { this.requestCb = cb; }
32
- stderrText() { return this.stderrTail.join('\n'); }
33
- start() {
34
- try {
35
- this.proc = spawn(this.bin, this.args, {
36
- cwd: this.cwd,
37
- stdio: ['pipe', 'pipe', 'pipe'],
38
- env: this.env ? { ...process.env, ...this.env } : process.env,
39
- });
40
- }
41
- catch {
42
- return false;
43
- }
44
- const rl = createInterface({ input: this.proc.stdout, crlfDelay: Infinity });
45
- rl.on('line', (line) => this.onLine(line));
46
- this.proc.stderr.on('data', (chunk) => {
47
- for (const ln of chunk.toString('utf8').split('\n')) {
48
- const t = ln.trim();
49
- if (!t)
50
- continue;
51
- this.stderrTail.push(t.slice(0, 240));
52
- if (this.stderrTail.length > 20)
53
- this.stderrTail.shift();
54
- }
55
- });
56
- this.proc.on('close', () => { for (const cb of this.pending.values())
57
- cb({ error: { message: 'acp process exited' } }); this.pending.clear(); });
58
- this.proc.on('error', () => { });
59
- return true;
60
- }
61
- onLine(line) {
62
- const t = line.trim();
63
- if (!t)
64
- return;
65
- let m;
66
- try {
67
- m = JSON.parse(t);
68
- }
69
- catch {
70
- return;
71
- } // non-JSON stdout noise
72
- if (m.method && m.id != null) {
73
- void this.handleRequest(m);
74
- return;
75
- } // agent -> client request
76
- if (m.id != null) { // response to one of our requests
77
- const cb = this.pending.get(m.id);
78
- if (cb) {
79
- this.pending.delete(m.id);
80
- cb(m);
81
- }
82
- return;
83
- }
84
- if (m.method)
85
- this.notifyCb?.(m.method, m.params ?? {}); // notification (session/update)
86
- }
87
- async handleRequest(m) {
88
- const id = m.id;
89
- if (!this.requestCb) {
90
- this.respondError(id, -32601, `Method not implemented: ${m.method}`);
91
- return;
92
- }
93
- try {
94
- const result = await this.requestCb(m.method, m.params ?? {});
95
- this.respond(id, result ?? null);
96
- }
97
- catch (e) {
98
- const code = e instanceof AcpRpcError ? e.code : -32603;
99
- this.respondError(id, code, e?.message || 'handler error');
100
- }
101
- }
102
- request(method, params, timeoutMs = 60_000) {
103
- return new Promise((resolve) => {
104
- if (!this.proc || this.proc.killed) {
105
- resolve({ error: { message: 'not connected' } });
106
- return;
107
- }
108
- const id = this.nextId++;
109
- const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `ACP '${method}' timed out` } }); }, timeoutMs);
110
- this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
111
- this.write({ jsonrpc: '2.0', id, method, params });
112
- });
113
- }
114
- notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
115
- respond(id, result) { this.write({ jsonrpc: '2.0', id, result }); }
116
- respondError(id, code, message) { this.write({ jsonrpc: '2.0', id, error: { code, message } }); }
117
- write(msg) {
118
- if (!this.proc || this.proc.killed)
119
- return;
120
- try {
121
- this.proc.stdin.write(JSON.stringify(msg) + '\n');
122
- }
123
- catch { /* stream closed */ }
124
- }
125
- kill() { try {
126
- this.proc?.kill('SIGTERM');
127
- }
128
- catch { /* ignore */ } this.proc = null; }
129
- }
2
+ import { dirname } from 'node:path';
3
+ import { RpcError, StdioRpcClient } from './rpc.js';
4
+ import { attachedFileNote, contextPercent, imageMimeForFile, wireAbort } from './shared.js';
130
5
  // ── pikiloom McpServerSpec[] -> ACP mcpServers[] ───────────────────────────────
131
6
  export function toAcpMcpServers(servers) {
132
7
  if (!servers || !servers.length)
@@ -145,29 +20,19 @@ export function toAcpMcpServers(servers) {
145
20
  }
146
21
  return out;
147
22
  }
148
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
149
- function mimeForExt(ext) {
150
- if (ext === '.jpg' || ext === '.jpeg')
151
- return 'image/jpeg';
152
- if (ext === '.gif')
153
- return 'image/gif';
154
- if (ext === '.webp')
155
- return 'image/webp';
156
- return 'image/png';
157
- }
158
23
  // ACP prompt content blocks: text + inline base64 images; other attachments noted as text.
159
24
  export function buildAcpPromptBlocks(prompt, attachments) {
160
25
  const blocks = [];
161
26
  for (const f of attachments) {
162
- const ext = extname(f).toLowerCase();
163
- if (IMAGE_EXTS.has(ext)) {
27
+ const mime = imageMimeForFile(f);
28
+ if (mime) {
164
29
  try {
165
- blocks.push({ type: 'image', mimeType: mimeForExt(ext), data: readFileSync(f).toString('base64') });
30
+ blocks.push({ type: 'image', mimeType: mime, data: readFileSync(f).toString('base64') });
166
31
  continue;
167
32
  }
168
33
  catch { /* fall through to a text note */ }
169
34
  }
170
- blocks.push({ type: 'text', text: `[Attached file: ${f}]` });
35
+ blocks.push({ type: 'text', text: attachedFileNote(f) });
171
36
  }
172
37
  blocks.push({ type: 'text', text: prompt });
173
38
  return blocks;
@@ -260,12 +125,13 @@ export function applyAcpUpdate(update, s, tools, emit) {
260
125
  case 'usage_update': {
261
126
  if (typeof update.size === 'number')
262
127
  s.contextWindow = update.size;
263
- if (typeof update.used === 'number')
264
- s.contextUsed = update.used;
265
128
  if (typeof update.used === 'number') {
266
- const window = s.contextWindow;
267
- const contextPercent = window && update.used > 0 ? Math.min(99.9, Math.round((update.used / window) * 1000) / 10) : null;
268
- emit({ type: 'usage', usage: { inputTokens: null, outputTokens: null, cachedInputTokens: null, contextUsedTokens: update.used, contextPercent } });
129
+ s.contextUsed = update.used;
130
+ emit({ type: 'usage', usage: {
131
+ inputTokens: null, outputTokens: null, cachedInputTokens: null,
132
+ contextUsedTokens: update.used,
133
+ contextPercent: contextPercent(update.used > 0 ? update.used : null, s.contextWindow),
134
+ } });
269
135
  }
270
136
  return;
271
137
  }
@@ -321,7 +187,7 @@ function fallbackOptionId(options, fallback) {
321
187
  function readAcpTextFile(params) {
322
188
  const p = String(params?.path || '');
323
189
  if (!p || !existsSync(p))
324
- throw new AcpRpcError(-32602, `file not found: ${p}`);
190
+ throw new RpcError(-32602, `file not found: ${p}`);
325
191
  let content = readFileSync(p, 'utf8');
326
192
  const line = typeof params?.line === 'number' ? params.line : null;
327
193
  const limit = typeof params?.limit === 'number' ? params.limit : null;
@@ -335,7 +201,7 @@ function readAcpTextFile(params) {
335
201
  function writeAcpTextFile(params) {
336
202
  const p = String(params?.path || '');
337
203
  if (!p)
338
- throw new AcpRpcError(-32602, 'path required');
204
+ throw new RpcError(-32602, 'path required');
339
205
  try {
340
206
  mkdirSync(dirname(p), { recursive: true });
341
207
  }
@@ -351,9 +217,11 @@ function acpUsage(s, raw) {
351
217
  output = n(raw.outputTokens ?? raw.output_tokens);
352
218
  cached = n(raw.cachedReadTokens ?? raw.cached_read_tokens ?? raw.cachedInputTokens);
353
219
  }
354
- const window = s.contextWindow, used = s.contextUsed;
355
- const contextPercent = window && used ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
356
- return { inputTokens: input, outputTokens: output, cachedInputTokens: cached, contextUsedTokens: used, contextPercent, turnOutputTokens: output };
220
+ const used = s.contextUsed;
221
+ return {
222
+ inputTokens: input, outputTokens: output, cachedInputTokens: cached, contextUsedTokens: used,
223
+ contextPercent: contextPercent(used || null, s.contextWindow), turnOutputTokens: output,
224
+ };
357
225
  }
358
226
  export class AcpDriver {
359
227
  id;
@@ -376,19 +244,18 @@ export class AcpDriver {
376
244
  }
377
245
  async run(input, ctx) {
378
246
  const env = { ...(this.cfg.env || {}), ...(input.env || {}) };
379
- const client = new AcpClient(this.cfg.command, this.cfg.args, env, input.workdir);
247
+ const client = new StdioRpcClient({ command: this.cfg.command, args: this.cfg.args, env, cwd: input.workdir, label: this.id });
380
248
  const state = { text: '', reasoning: '', contextWindow: null, contextUsed: null };
381
249
  const tools = new Set();
382
250
  let sessionId = input.sessionId ?? null;
383
251
  let permSeq = 0;
384
252
  if (!client.start())
385
253
  return { ok: false, text: '', error: `failed to start ${this.cfg.command}`, stopReason: 'error' };
386
- const onAbort = () => { if (sessionId)
387
- client.notify('session/cancel', { sessionId }); client.kill(); };
388
- if (ctx.signal.aborted)
389
- onAbort();
390
- else
391
- ctx.signal.addEventListener('abort', onAbort, { once: true });
254
+ const unwireAbort = wireAbort(ctx.signal, () => {
255
+ if (sessionId)
256
+ client.notify('session/cancel', { sessionId });
257
+ client.kill();
258
+ });
392
259
  client.onNotification((method, params) => {
393
260
  if (method === 'session/update')
394
261
  applyAcpUpdate(params?.update ?? params, state, tools, ctx.emit);
@@ -398,14 +265,14 @@ export class AcpDriver {
398
265
  case 'session/request_permission': return this.resolvePermission(params, ctx, `${this.id}-perm-${++permSeq}`);
399
266
  case 'fs/read_text_file':
400
267
  if (!this.cfg.fsAccess)
401
- throw new AcpRpcError(-32601, 'fs/read_text_file not supported');
268
+ throw new RpcError(-32601, 'fs/read_text_file not supported');
402
269
  return readAcpTextFile(params);
403
270
  case 'fs/write_text_file':
404
271
  if (!this.cfg.fsAccess)
405
- throw new AcpRpcError(-32601, 'fs/write_text_file not supported');
272
+ throw new RpcError(-32601, 'fs/write_text_file not supported');
406
273
  return writeAcpTextFile(params);
407
274
  default:
408
- throw new AcpRpcError(-32601, `Method not implemented: ${method}`);
275
+ throw new RpcError(-32601, `Method not implemented: ${method}`);
409
276
  }
410
277
  });
411
278
  try {
@@ -447,7 +314,7 @@ export class AcpDriver {
447
314
  return { ok: true, text: state.text, reasoning: state.reasoning || undefined, error: null, stopReason, sessionId, usage };
448
315
  }
449
316
  finally {
450
- ctx.signal.removeEventListener('abort', onAbort);
317
+ unwireAbort();
451
318
  client.kill();
452
319
  }
453
320
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
- import type { UniversalUsage, UniversalPlan } from '../protocol/index.js';
2
+ import type { UniversalPlan } from '../protocol/index.js';
3
3
  export declare class ClaudeDriver implements AgentDriver {
4
4
  private readonly bin;
5
5
  readonly id = "claude";
@@ -18,19 +18,6 @@ export declare class ClaudeDriver implements AgentDriver {
18
18
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
19
19
  private usage;
20
20
  }
21
- export interface ClaudeUsageState {
22
- input: number | null;
23
- output: number | null;
24
- cached: number | null;
25
- cacheCreation?: number | null;
26
- contextWindow?: number | null;
27
- turnOutputTokensBase?: number | null;
28
- thinkingEstTokens?: number | null;
29
- }
30
- export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
31
- export declare function applyClaudeThinkingEstimate(s: any, ev: any): boolean;
32
- export declare function claudeContextWindowFromModel(model: unknown): number | null;
33
- export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
34
21
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
35
22
  export declare function claudeBgHoldCapMs(): number;
36
23
  export declare function claudeBgAgentHoldCapMs(): number;
@@ -56,11 +43,4 @@ export declare function decideClaudeResultSettle(input: {
56
43
  sawBackground: boolean;
57
44
  }): ClaudeResultSettleDecision;
58
45
  export declare function claudeUserMessage(text: string, attachments?: string[]): string;
59
- export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
60
46
  export declare function todoWriteToPlan(input: any): UniversalPlan | null;
61
- export declare function readClaudeTaskCreateId(ev: any, block: any): string | null;
62
- export declare function rebuildClaudeTaskPlan(s: any): UniversalPlan | null;
63
- export declare function applyTaskUpdateToTodoPlan(plan: UniversalPlan | null | undefined, taskId: string, rawStatus: string): UniversalPlan | null;
64
- export declare function shortToolValue(value: unknown, max?: number): string;
65
- export declare function summarizeToolUse(name: string, input: any): string;
66
- export declare function firstResultLine(content: any): string;
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
- import { extname } from 'node:path';
4
- import { discoverClaudeNativeSessions } from '../workspace/native.js';
3
+ import { discoverClaudeNativeSessions } from './native.js';
4
+ import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, parseJsonLine, sigterm, wireAbort } from './shared.js';
5
5
  // Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
6
6
  // events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
7
7
  // (system / stream_event{message_start,content_block_delta,message_delta} / assistant / result),
@@ -71,8 +71,11 @@ export class ClaudeDriver {
71
71
  taskList: new Map(),
72
72
  taskOrder: [],
73
73
  pendingTaskCreates: new Map(),
74
- // run_in_background lifecycle: task ids seen as started vs. reached a terminal status.
75
- bgStarted: new Set(), bgTerminal: new Set(),
74
+ todoPlan: null,
75
+ seenImages: new Set(),
76
+ // run_in_background lifecycle: task ids seen as started vs. reached a terminal status;
77
+ // bgAgentTasks marks the sub-agent-backed ones (they earn the longer hold cap).
78
+ bgStarted: new Set(), bgTerminal: new Set(), bgAgentTasks: new Set(),
76
79
  };
77
80
  return new Promise((resolve) => {
78
81
  let child;
@@ -124,17 +127,10 @@ export class ClaudeDriver {
124
127
  }
125
128
  catch { /* ignore */ }
126
129
  if (!child.killed && child.exitCode == null) {
127
- if (kill) {
128
- try {
129
- child.kill('SIGTERM');
130
- }
131
- catch { /* ignore */ }
132
- }
130
+ if (kill)
131
+ sigterm(child);
133
132
  else {
134
- const guard = setTimeout(() => { try {
135
- child?.kill('SIGTERM');
136
- }
137
- catch { /* ignore */ } }, CLAUDE_EXIT_LEAK_GUARD_MS);
133
+ const guard = setTimeout(() => sigterm(child), CLAUDE_EXIT_LEAK_GUARD_MS);
138
134
  unref(guard);
139
135
  }
140
136
  }
@@ -207,14 +203,7 @@ export class ClaudeDriver {
207
203
  finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
208
204
  return;
209
205
  }
210
- const onAbort = () => { try {
211
- child.kill('SIGTERM');
212
- }
213
- catch { /* ignore */ } };
214
- if (ctx.signal.aborted)
215
- onAbort();
216
- else
217
- ctx.signal.addEventListener('abort', onAbort, { once: true });
206
+ wireAbort(ctx.signal, () => sigterm(child));
218
207
  if (steerable) {
219
208
  ctx.registerSteer(async (prompt, attachments) => {
220
209
  try {
@@ -226,27 +215,17 @@ export class ClaudeDriver {
226
215
  }
227
216
  });
228
217
  }
229
- let buf = '';
218
+ const nextLines = createLineBuffer();
230
219
  let stderr = '';
231
220
  child.stdout.on('data', (chunk) => {
232
221
  if (settled)
233
222
  return; // ignore the process's post-settle shutdown chatter
234
- buf += chunk.toString('utf8');
235
- const lines = buf.split('\n');
236
- buf = lines.pop() || '';
237
- for (const line of lines) {
223
+ for (const line of nextLines(chunk)) {
238
224
  if (settled)
239
225
  return;
240
- const trimmed = line.trim();
241
- if (!trimmed)
226
+ const ev = parseJsonLine(line);
227
+ if (ev === undefined)
242
228
  continue;
243
- let ev;
244
- try {
245
- ev = JSON.parse(trimmed);
246
- }
247
- catch {
248
- continue;
249
- }
250
229
  state.lastEventAt = Date.now();
251
230
  handleClaudeEvent(ev, state, ctx.emit);
252
231
  const pending = pendingClaudeBackgroundTasks(state);
@@ -374,7 +353,7 @@ export class ClaudeDriver {
374
353
  return claudeUsageOf(s);
375
354
  }
376
355
  }
377
- export function claudeUsageOf(s) {
356
+ function claudeUsageOf(s) {
378
357
  // While a message is still streaming, the CLI's live thinking estimate (system/thinking_tokens)
379
358
  // is often the ONLY output signal — subscription accounts stream no plaintext thinking and no
380
359
  // usage until the message settles. Fold it into the derived numbers (never into the raw
@@ -382,22 +361,20 @@ export function claudeUsageOf(s) {
382
361
  // output_tokens supersedes it at message_delta.
383
362
  const effOutput = Math.max(s.output ?? 0, s.thinkingEstTokens ?? 0);
384
363
  const used = (s.input ?? 0) + (s.cached ?? 0) + (s.cacheCreation ?? 0) + effOutput;
385
- const window = s.contextWindow ?? null;
386
- const contextPercent = window && used > 0 ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
387
364
  const turnOutput = (s.turnOutputTokensBase ?? 0) + effOutput;
388
365
  return {
389
366
  inputTokens: s.input,
390
367
  outputTokens: s.output,
391
368
  cachedInputTokens: s.cached,
392
369
  contextUsedTokens: used > 0 ? used : null,
393
- contextPercent,
370
+ contextPercent: contextPercent(used > 0 ? used : null, s.contextWindow ?? null),
394
371
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
395
372
  };
396
373
  }
397
374
  // Accumulate the CLI's live thinking-token estimate onto driver state. Prefer the per-event
398
375
  // delta (correct whether the CLI's running total is per-message or per-turn); fall back to a
399
376
  // monotonic max of the running total. Returns true when the estimate advanced.
400
- export function applyClaudeThinkingEstimate(s, ev) {
377
+ function applyClaudeThinkingEstimate(s, ev) {
401
378
  const prev = s.thinkingEstTokens ?? 0;
402
379
  const delta = Number(ev?.estimated_tokens_delta);
403
380
  const total = Number(ev?.estimated_tokens);
@@ -410,7 +387,7 @@ export function applyClaudeThinkingEstimate(s, ev) {
410
387
  // Advertised context window by Claude model id (best-effort; unknown -> null so the
411
388
  // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
412
389
  // (us.anthropic.claude-…) still match.
413
- export function claudeContextWindowFromModel(model) {
390
+ function claudeContextWindowFromModel(model) {
414
391
  const id = String(model ?? '').trim().toLowerCase();
415
392
  if (!id)
416
393
  return null;
@@ -424,7 +401,7 @@ export function claudeContextWindowFromModel(model) {
424
401
  }
425
402
  // Usable window = advertised minus Claude's max-output (20k) + autocompact (13k) reserve.
426
403
  const CLAUDE_USABLE_WINDOW_RESERVE = 33_000;
427
- export function claudeEffectiveContextWindow(advertised) {
404
+ function claudeEffectiveContextWindow(advertised) {
428
405
  if (advertised == null)
429
406
  return null;
430
407
  return advertised <= CLAUDE_USABLE_WINDOW_RESERVE ? advertised : advertised - CLAUDE_USABLE_WINDOW_RESERVE;
@@ -944,11 +921,6 @@ export function decideClaudeResultSettle(input) {
944
921
  return 'hold';
945
922
  return input.sawBackground ? 'quiet-settle' : 'settle';
946
923
  }
947
- // Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
948
- const CLAUDE_IMAGE_MIME = {
949
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
950
- '.gif': 'image/gif', '.webp': 'image/webp',
951
- };
952
924
  // A stream-json user message (for --input-format stream-json; used to send the prompt and to
953
925
  // inject mid-turn steer messages while stdin stays open). Image attachments are inlined as base64
954
926
  // image content blocks so the model actually sees them; other files become a text note. Without
@@ -956,7 +928,7 @@ const CLAUDE_IMAGE_MIME = {
956
928
  export function claudeUserMessage(text, attachments) {
957
929
  const content = [];
958
930
  for (const filePath of attachments || []) {
959
- const mime = CLAUDE_IMAGE_MIME[extname(filePath).toLowerCase()];
931
+ const mime = imageMimeForFile(filePath);
960
932
  if (mime) {
961
933
  try {
962
934
  content.push({ type: 'image', source: { type: 'base64', media_type: mime, data: readFileSync(filePath).toString('base64') } });
@@ -964,13 +936,13 @@ export function claudeUserMessage(text, attachments) {
964
936
  }
965
937
  catch { /* unreadable -> fall through to a text note */ }
966
938
  }
967
- content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
939
+ content.push({ type: 'text', text: attachedFileNote(filePath) });
968
940
  }
969
941
  content.push({ type: 'text', text });
970
942
  return JSON.stringify({ type: 'user', message: { role: 'user', content } });
971
943
  }
972
944
  // Surface base64 image content blocks as artifacts (data URLs), deduped per turn.
973
- export function emitClaudeImages(blocks, s, emit) {
945
+ function emitClaudeImages(blocks, s, emit) {
974
946
  if (!Array.isArray(blocks))
975
947
  return;
976
948
  s.seenImages ||= new Set();
@@ -1014,7 +986,7 @@ export function todoWriteToPlan(input) {
1014
986
  // dashboard's task-list card never renders. Ported from pikiloom's legacy claude driver.
1015
987
  // The assigned task id from a TaskCreate tool_result. Prefer the structured field; fall back
1016
988
  // to parsing "Task #N" from a string result.
1017
- export function readClaudeTaskCreateId(ev, block) {
989
+ function readClaudeTaskCreateId(ev, block) {
1018
990
  const structured = ev?.toolUseResult?.task?.id;
1019
991
  if (structured != null && String(structured).trim())
1020
992
  return String(structured).trim();
@@ -1026,7 +998,7 @@ export function readClaudeTaskCreateId(ev, block) {
1026
998
  }
1027
999
  return null;
1028
1000
  }
1029
- export function rebuildClaudeTaskPlan(s) {
1001
+ function rebuildClaudeTaskPlan(s) {
1030
1002
  if (!Array.isArray(s?.taskOrder) || !s.taskOrder.length)
1031
1003
  return null;
1032
1004
  const steps = [];
@@ -1048,7 +1020,7 @@ export function rebuildClaudeTaskPlan(s) {
1048
1020
  // Used when the id isn't in the TaskCreate store, so an update issued AFTER a TodoWrite still
1049
1021
  // lands on the displayed list — the plan reflects the state after the LAST change, whichever
1050
1022
  // mechanism wrote it. Returns a fresh plan (never mutates) or null when inapplicable.
1051
- export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1023
+ function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1052
1024
  if (!plan || !Array.isArray(plan.steps) || !plan.steps.length)
1053
1025
  return null;
1054
1026
  if (!/^\d+$/.test(taskId))
@@ -1072,7 +1044,7 @@ export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1072
1044
  // Turns a Claude tool_use {name,input} into a one-line human summary. The runtime's
1073
1045
  // activity projector joins these into snapshot.activity; the structured form lives in
1074
1046
  // toolCalls. Kept driver-local: knowing Claude's tool input shapes is the driver's job.
1075
- export function shortToolValue(value, max = 140) {
1047
+ function shortToolValue(value, max = 140) {
1076
1048
  if (value == null)
1077
1049
  return '';
1078
1050
  const text = (typeof value === 'string' ? value : String(value)).replace(/\s+/g, ' ').trim();
@@ -1084,7 +1056,7 @@ function toolInputDetail(name, input) {
1084
1056
  const i = input || {};
1085
1057
  return i.command || i.file_path || i.path || i.pattern || i.query || i.url || i.description || '';
1086
1058
  }
1087
- export function summarizeToolUse(name, input) {
1059
+ function summarizeToolUse(name, input) {
1088
1060
  const tool = String(name || '').trim() || 'Tool';
1089
1061
  const i = input || {};
1090
1062
  const description = shortToolValue(i.description, 120);
@@ -1149,7 +1121,7 @@ export function summarizeToolUse(name, input) {
1149
1121
  }
1150
1122
  }
1151
1123
  // First non-empty line of a tool_result content (string | block[]), for the "summary -> detail" form.
1152
- export function firstResultLine(content) {
1124
+ function firstResultLine(content) {
1153
1125
  let text = '';
1154
1126
  if (typeof content === 'string')
1155
1127
  text = content;
@@ -1,5 +1,4 @@
1
1
  import type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
- import type { UniversalUsage } from '../protocol/index.js';
3
2
  export declare function codexToolSummary(item: any): {
4
3
  id: string;
5
4
  name: string;
@@ -34,12 +33,3 @@ export declare class CodexDriver implements AgentDriver {
34
33
  limit?: number;
35
34
  }): NativeSessionInfo[];
36
35
  }
37
- export interface CodexUsageState {
38
- input: number | null;
39
- output: number | null;
40
- cached: number | null;
41
- contextUsed?: number | null;
42
- contextWindow?: number | null;
43
- }
44
- export declare function applyCodexTokenUsage(s: CodexUsageState, rawUsage: any): void;
45
- export declare function codexUsageOf(s: CodexUsageState): UniversalUsage;