@pikiloom/kernel 0.2.1 → 0.2.3

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.
@@ -0,0 +1,6 @@
1
+ /** Whether this agent's local accounts are switched by injecting a token. */
2
+ export declare function accountTokenSupported(agent: string): boolean;
3
+ /** The env-var name that overrides this agent's subscription identity, or null. */
4
+ export declare function accountTokenEnvVar(agent: string): string | null;
5
+ /** Env to merge at spawn so the agent runs under the given account token. */
6
+ export declare function accountTokenEnv(agent: string, token: string): Record<string, string>;
@@ -0,0 +1,29 @@
1
+ // Multi-account capability (token form): switch between several local subscription accounts
2
+ // of a coding-agent CLI by injecting a long-lived auth token at spawn time. The CLI keeps
3
+ // using its normal single config home (~/.claude etc.) — only the *identity* changes.
4
+ //
5
+ // claude / claude-tui -> CLAUDE_CODE_OAUTH_TOKEN=<token from `claude setup-token`>
6
+ //
7
+ // This replaces the earlier per-account config-directory approach: on macOS claude stores
8
+ // its credential in one shared OS-keychain item (not per CLAUDE_CONFIG_DIR), so the token is
9
+ // what actually isolates an account. codex has no equivalent token, so it is not supported
10
+ // here yet (single account only).
11
+ //
12
+ // Dependency-free (no imports) so any @pikiloom/kernel consumer inherits account switching.
13
+ const ACCOUNT_TOKEN_ENV = {
14
+ claude: 'CLAUDE_CODE_OAUTH_TOKEN',
15
+ 'claude-tui': 'CLAUDE_CODE_OAUTH_TOKEN',
16
+ };
17
+ /** Whether this agent's local accounts are switched by injecting a token. */
18
+ export function accountTokenSupported(agent) {
19
+ return Object.prototype.hasOwnProperty.call(ACCOUNT_TOKEN_ENV, agent);
20
+ }
21
+ /** The env-var name that overrides this agent's subscription identity, or null. */
22
+ export function accountTokenEnvVar(agent) {
23
+ return ACCOUNT_TOKEN_ENV[agent] ?? null;
24
+ }
25
+ /** Env to merge at spawn so the agent runs under the given account token. */
26
+ export function accountTokenEnv(agent, token) {
27
+ const key = accountTokenEnvVar(agent);
28
+ return key && token ? { [key]: token } : {};
29
+ }
@@ -0,0 +1,24 @@
1
+ import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, McpServerSpec } from '../contracts/driver.js';
2
+ export interface AcpDriverConfig {
3
+ id: string;
4
+ command: string;
5
+ args?: string[];
6
+ env?: Record<string, string>;
7
+ fsAccess?: boolean;
8
+ permissionFallback?: 'allow' | 'reject' | 'cancel';
9
+ protocolVersion?: number;
10
+ promptTimeoutMs?: number;
11
+ capabilities?: AgentDriver['capabilities'];
12
+ }
13
+ export declare function toAcpMcpServers(servers?: McpServerSpec[]): any[];
14
+ export declare function buildAcpPromptBlocks(prompt: string, attachments: string[]): any[];
15
+ export declare function applyAcpUpdate(update: any, s: any, tools: Set<string>, emit: (e: DriverEvent) => void): void;
16
+ export declare class AcpDriver implements AgentDriver {
17
+ readonly id: string;
18
+ readonly capabilities: NonNullable<AgentDriver['capabilities']>;
19
+ protected readonly cfg: Required<Omit<AcpDriverConfig, 'capabilities' | 'env'>> & Pick<AcpDriverConfig, 'env' | 'capabilities'>;
20
+ constructor(config: AcpDriverConfig);
21
+ run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
22
+ private resolvePermission;
23
+ private startupError;
24
+ }
@@ -0,0 +1,477 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ 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
+ }
130
+ // ── pikiloom McpServerSpec[] -> ACP mcpServers[] ───────────────────────────────
131
+ export function toAcpMcpServers(servers) {
132
+ if (!servers || !servers.length)
133
+ return [];
134
+ const out = [];
135
+ for (const s of servers) {
136
+ if (!s || !s.name)
137
+ continue;
138
+ if (s.type === 'http' && s.url) {
139
+ out.push({ type: 'http', name: s.name, url: s.url, headers: Object.entries(s.headers || {}).map(([name, value]) => ({ name, value: String(value) })) });
140
+ continue;
141
+ }
142
+ if (s.command) {
143
+ out.push({ name: s.name, command: s.command, args: Array.isArray(s.args) ? s.args.map(String) : [], env: Object.entries(s.env || {}).map(([name, value]) => ({ name, value: String(value) })) });
144
+ }
145
+ }
146
+ return out;
147
+ }
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
+ // ACP prompt content blocks: text + inline base64 images; other attachments noted as text.
159
+ export function buildAcpPromptBlocks(prompt, attachments) {
160
+ const blocks = [];
161
+ for (const f of attachments) {
162
+ const ext = extname(f).toLowerCase();
163
+ if (IMAGE_EXTS.has(ext)) {
164
+ try {
165
+ blocks.push({ type: 'image', mimeType: mimeForExt(ext), data: readFileSync(f).toString('base64') });
166
+ continue;
167
+ }
168
+ catch { /* fall through to a text note */ }
169
+ }
170
+ blocks.push({ type: 'text', text: `[Attached file: ${f}]` });
171
+ }
172
+ blocks.push({ type: 'text', text: prompt });
173
+ return blocks;
174
+ }
175
+ function acpContentText(content) {
176
+ if (content == null)
177
+ return '';
178
+ if (typeof content === 'string')
179
+ return content;
180
+ if (Array.isArray(content))
181
+ return content.map(acpContentText).filter(Boolean).join('\n');
182
+ if (typeof content === 'object') {
183
+ if (typeof content.text === 'string')
184
+ return content.text;
185
+ if (content.type === 'content' && content.content)
186
+ return acpContentText(content.content);
187
+ if (content.type === 'diff' && typeof content.path === 'string')
188
+ return `[diff ${content.path}]`;
189
+ }
190
+ return '';
191
+ }
192
+ function acpToolStatus(status, dflt) {
193
+ if (status === 'completed')
194
+ return 'done';
195
+ if (status === 'failed')
196
+ return 'failed';
197
+ if (status === 'pending' || status === 'in_progress')
198
+ return 'running';
199
+ return dflt;
200
+ }
201
+ function safeJson(v) {
202
+ try {
203
+ return typeof v === 'string' ? v : JSON.stringify(v);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ function truncate(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
210
+ // session/update -> normalized DriverEvents. Exported (and aliased as applyHermesUpdate)
211
+ // so it can be unit-tested against the raw ACP wire without spawning a process.
212
+ export function applyAcpUpdate(update, s, tools, emit) {
213
+ if (!update)
214
+ return;
215
+ switch (update.sessionUpdate) {
216
+ case 'agent_message_chunk': {
217
+ const t = acpContentText(update.content);
218
+ if (t) {
219
+ s.text = (s.text || '') + t;
220
+ emit({ type: 'text', delta: t });
221
+ }
222
+ return;
223
+ }
224
+ case 'agent_thought_chunk': {
225
+ const t = acpContentText(update.content);
226
+ if (t) {
227
+ s.reasoning = (s.reasoning || '') + t;
228
+ emit({ type: 'reasoning', delta: t });
229
+ }
230
+ return;
231
+ }
232
+ case 'tool_call': {
233
+ const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
234
+ if (!id)
235
+ return;
236
+ const name = (typeof update.title === 'string' && update.title.trim()) || (typeof update.kind === 'string' && update.kind) || 'tool';
237
+ tools.add(id);
238
+ emit({ type: 'tool', call: { id, name, summary: name, input: update.rawInput != null ? truncate(safeJson(update.rawInput) || '', 2000) : null, status: acpToolStatus(update.status, 'running') } });
239
+ return;
240
+ }
241
+ case 'tool_call_update': {
242
+ const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
243
+ if (!id)
244
+ return;
245
+ const name = (typeof update.title === 'string' && update.title.trim()) || 'tool';
246
+ const status = acpToolStatus(update.status, 'running');
247
+ const result = update.content != null ? truncate(acpContentText(update.content), 2000) : null;
248
+ emit({ type: 'tool', call: { id, name, summary: name, result: result || null, status } });
249
+ return;
250
+ }
251
+ case 'plan': {
252
+ const entries = Array.isArray(update.entries) ? update.entries : [];
253
+ const steps = entries
254
+ .map((e) => ({ text: String(e?.content ?? '').trim(), status: e?.status === 'in_progress' ? 'inProgress' : e?.status === 'completed' ? 'completed' : 'pending' }))
255
+ .filter((st) => st.text);
256
+ if (steps.length)
257
+ emit({ type: 'plan', plan: { explanation: null, steps } });
258
+ return;
259
+ }
260
+ case 'usage_update': {
261
+ if (typeof update.size === 'number')
262
+ s.contextWindow = update.size;
263
+ if (typeof update.used === 'number')
264
+ s.contextUsed = update.used;
265
+ 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 } });
269
+ }
270
+ return;
271
+ }
272
+ }
273
+ }
274
+ // session/request_permission params -> a normalized UniversalInteraction (kind 'permission').
275
+ // Each choice's `value` IS the ACP optionId, so the answer maps straight back to an outcome.
276
+ function permissionToInteraction(params, promptId) {
277
+ const options = Array.isArray(params?.options) ? params.options : [];
278
+ if (!options.length)
279
+ return null;
280
+ const tc = params?.toolCall || {};
281
+ const what = (typeof tc.title === 'string' && tc.title.trim()) || (typeof tc.kind === 'string' && tc.kind) || 'this action';
282
+ return {
283
+ promptId,
284
+ kind: 'permission',
285
+ title: 'Permission required',
286
+ hint: `The agent wants to: ${what}`,
287
+ questions: [{
288
+ id: 'choice',
289
+ header: 'Permission',
290
+ text: `Allow the agent to ${what}?`,
291
+ type: 'select',
292
+ choices: options.map((o) => ({ label: String(o?.name || o?.optionId || 'option'), value: String(o?.optionId || ''), description: String(o?.kind || '') })),
293
+ allowFreeform: false,
294
+ allowEmpty: true,
295
+ }],
296
+ };
297
+ }
298
+ function pickOptionId(answers, options) {
299
+ const vals = Object.values(answers || {}).flat().map(String);
300
+ for (const v of vals) {
301
+ const byId = options.find((o) => String(o?.optionId) === v);
302
+ if (byId)
303
+ return String(byId.optionId);
304
+ const byName = options.find((o) => String(o?.name) === v);
305
+ if (byName)
306
+ return String(byName.optionId);
307
+ }
308
+ return null;
309
+ }
310
+ function fallbackOptionId(options, fallback) {
311
+ if (fallback === 'cancel')
312
+ return null;
313
+ const order = fallback === 'allow' ? ['allow_once', 'allow_always', 'allow'] : ['reject_once', 'reject_always', 'reject'];
314
+ for (const k of order) {
315
+ const o = options.find((x) => String(x?.kind) === k);
316
+ if (o)
317
+ return String(o.optionId);
318
+ }
319
+ return fallback === 'allow' && options[0] ? String(options[0].optionId) : null;
320
+ }
321
+ function readAcpTextFile(params) {
322
+ const p = String(params?.path || '');
323
+ if (!p || !existsSync(p))
324
+ throw new AcpRpcError(-32602, `file not found: ${p}`);
325
+ let content = readFileSync(p, 'utf8');
326
+ const line = typeof params?.line === 'number' ? params.line : null;
327
+ const limit = typeof params?.limit === 'number' ? params.limit : null;
328
+ if (line != null || limit != null) {
329
+ const lines = content.split('\n');
330
+ const start = line != null ? Math.max(0, line - 1) : 0;
331
+ content = lines.slice(start, limit != null ? start + limit : undefined).join('\n');
332
+ }
333
+ return { content };
334
+ }
335
+ function writeAcpTextFile(params) {
336
+ const p = String(params?.path || '');
337
+ if (!p)
338
+ throw new AcpRpcError(-32602, 'path required');
339
+ try {
340
+ mkdirSync(dirname(p), { recursive: true });
341
+ }
342
+ catch { /* parent may exist */ }
343
+ writeFileSync(p, String(params?.content ?? ''), 'utf8');
344
+ return null;
345
+ }
346
+ function acpUsage(s, raw) {
347
+ let input = null, output = null, cached = null;
348
+ if (raw && typeof raw === 'object') {
349
+ const n = (v) => (typeof v === 'number' ? v : null);
350
+ input = n(raw.inputTokens ?? raw.input_tokens);
351
+ output = n(raw.outputTokens ?? raw.output_tokens);
352
+ cached = n(raw.cachedReadTokens ?? raw.cached_read_tokens ?? raw.cachedInputTokens);
353
+ }
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 };
357
+ }
358
+ export class AcpDriver {
359
+ id;
360
+ capabilities;
361
+ cfg;
362
+ constructor(config) {
363
+ this.id = config.id;
364
+ this.cfg = {
365
+ id: config.id,
366
+ command: config.command,
367
+ args: config.args ?? ['acp'],
368
+ env: config.env,
369
+ fsAccess: config.fsAccess ?? true,
370
+ permissionFallback: config.permissionFallback ?? 'allow',
371
+ protocolVersion: config.protocolVersion ?? 1,
372
+ promptTimeoutMs: config.promptTimeoutMs ?? 7_200_000,
373
+ capabilities: config.capabilities,
374
+ };
375
+ this.capabilities = config.capabilities ?? { steer: false, interact: true, resume: true, tui: false };
376
+ }
377
+ async run(input, ctx) {
378
+ const env = { ...(this.cfg.env || {}), ...(input.env || {}) };
379
+ const client = new AcpClient(this.cfg.command, this.cfg.args, env, input.workdir);
380
+ const state = { text: '', reasoning: '', contextWindow: null, contextUsed: null };
381
+ const tools = new Set();
382
+ let sessionId = input.sessionId ?? null;
383
+ let permSeq = 0;
384
+ if (!client.start())
385
+ 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 });
392
+ client.onNotification((method, params) => {
393
+ if (method === 'session/update')
394
+ applyAcpUpdate(params?.update ?? params, state, tools, ctx.emit);
395
+ });
396
+ client.onRequest(async (method, params) => {
397
+ switch (method) {
398
+ case 'session/request_permission': return this.resolvePermission(params, ctx, `${this.id}-perm-${++permSeq}`);
399
+ case 'fs/read_text_file':
400
+ if (!this.cfg.fsAccess)
401
+ throw new AcpRpcError(-32601, 'fs/read_text_file not supported');
402
+ return readAcpTextFile(params);
403
+ case 'fs/write_text_file':
404
+ if (!this.cfg.fsAccess)
405
+ throw new AcpRpcError(-32601, 'fs/write_text_file not supported');
406
+ return writeAcpTextFile(params);
407
+ default:
408
+ throw new AcpRpcError(-32601, `Method not implemented: ${method}`);
409
+ }
410
+ });
411
+ try {
412
+ const init = await client.request('initialize', {
413
+ protocolVersion: this.cfg.protocolVersion,
414
+ clientCapabilities: { fs: { readTextFile: !!this.cfg.fsAccess, writeTextFile: !!this.cfg.fsAccess }, terminal: false },
415
+ });
416
+ if (init.error)
417
+ return { ok: false, text: '', error: this.startupError(client, init.error.message || 'initialize failed'), stopReason: 'error' };
418
+ const mcpServers = toAcpMcpServers(input.extraMcpServers);
419
+ if (!sessionId) {
420
+ const ns = await client.request('session/new', { cwd: input.workdir, mcpServers });
421
+ if (ns.error)
422
+ return { ok: false, text: '', error: ns.error.message || 'session/new failed', stopReason: 'error' };
423
+ sessionId = ns.result?.sessionId ?? ns.result?.session_id ?? null;
424
+ if (sessionId)
425
+ ctx.emit({ type: 'session', sessionId });
426
+ }
427
+ else {
428
+ await client.request('session/load', { sessionId, cwd: input.workdir, mcpServers }, 30_000).catch(() => ({}));
429
+ ctx.emit({ type: 'session', sessionId });
430
+ }
431
+ if (!sessionId)
432
+ return { ok: false, text: '', error: `${this.id} returned no session id`, stopReason: 'error' };
433
+ if (input.model)
434
+ await client.request('session/set_model', { sessionId, modelId: input.model }, 15_000).catch(() => ({}));
435
+ if (input.effort)
436
+ await client.request('session/set_mode', { sessionId, modeId: input.effort }, 15_000).catch(() => ({}));
437
+ const promptResp = await client.request('session/prompt', {
438
+ sessionId,
439
+ prompt: buildAcpPromptBlocks(input.prompt, input.attachments || []),
440
+ }, this.cfg.promptTimeoutMs);
441
+ const usage = acpUsage(state, promptResp.result?.usage);
442
+ if (ctx.signal.aborted)
443
+ return { ok: false, text: state.text, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId, usage };
444
+ if (promptResp.error)
445
+ return { ok: false, text: state.text, error: promptResp.error.message || 'session/prompt failed', stopReason: 'error', sessionId, usage };
446
+ const stopReason = promptResp.result?.stopReason ?? 'end_turn';
447
+ return { ok: true, text: state.text, reasoning: state.reasoning || undefined, error: null, stopReason, sessionId, usage };
448
+ }
449
+ finally {
450
+ ctx.signal.removeEventListener('abort', onAbort);
451
+ client.kill();
452
+ }
453
+ }
454
+ async resolvePermission(params, ctx, promptId) {
455
+ const options = Array.isArray(params?.options) ? params.options : [];
456
+ if (!options.length)
457
+ return { outcome: { outcome: 'cancelled' } };
458
+ const interaction = permissionToInteraction(params, promptId);
459
+ let answers = {};
460
+ if (interaction) {
461
+ try {
462
+ answers = (await ctx.askUser(interaction)) || {};
463
+ }
464
+ catch {
465
+ answers = {};
466
+ }
467
+ }
468
+ if (ctx.signal.aborted)
469
+ return { outcome: { outcome: 'cancelled' } };
470
+ const picked = pickOptionId(answers, options) ?? fallbackOptionId(options, this.cfg.permissionFallback);
471
+ return picked ? { outcome: { outcome: 'selected', optionId: picked } } : { outcome: { outcome: 'cancelled' } };
472
+ }
473
+ startupError(client, base) {
474
+ const tail = client.stderrText().trim();
475
+ return tail ? `${base} — ${truncate(tail, 300)}` : base;
476
+ }
477
+ }
@@ -30,9 +30,11 @@ export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
30
30
  export declare function claudeContextWindowFromModel(model: unknown): number | null;
31
31
  export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
32
32
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
33
- export declare function claudeUserMessage(text: string): string;
33
+ export declare function claudeUserMessage(text: string, attachments?: string[]): string;
34
34
  export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
35
35
  export declare function todoWriteToPlan(input: any): UniversalPlan | null;
36
+ export declare function readClaudeTaskCreateId(ev: any, block: any): string | null;
37
+ export declare function rebuildClaudeTaskPlan(s: any): UniversalPlan | null;
36
38
  export declare function shortToolValue(value: unknown, max?: number): string;
37
39
  export declare function summarizeToolUse(name: string, input: any): string;
38
40
  export declare function firstResultLine(content: any): string;
@@ -1,4 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { extname } from 'node:path';
2
4
  import { discoverClaudeNativeSessions } from '../workspace/native.js';
3
5
  // Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
4
6
  // events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
@@ -28,6 +30,9 @@ export class ClaudeDriver {
28
30
  }
29
31
  run(input, ctx) {
30
32
  const steerable = !!input.steerable;
33
+ // Image attachments must ride a stream-json user message (text-only stdin can't carry images),
34
+ // so enable that input mode whenever there are attachments, not just for mid-turn steering.
35
+ const useStreamJson = steerable || !!input.attachments?.length;
31
36
  const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
32
37
  if (input.model)
33
38
  args.push('--model', input.model);
@@ -41,8 +46,10 @@ export class ClaudeDriver {
41
46
  args.push('--mcp-config', input.mcpConfigPath);
42
47
  if (input.permissionMode)
43
48
  args.push('--permission-mode', input.permissionMode); // parity: keep bypass/accept-edits on the kernel path
49
+ if (useStreamJson)
50
+ args.push('--input-format', 'stream-json');
44
51
  if (steerable)
45
- args.push('--input-format', 'stream-json', '--replay-user-messages'); // parity: mid-turn steer
52
+ args.push('--replay-user-messages'); // parity: mid-turn steer
46
53
  if (input.extraArgs?.length)
47
54
  args.push(...input.extraArgs);
48
55
  const state = {
@@ -54,6 +61,9 @@ export class ClaudeDriver {
54
61
  contextWindow: null, turnOutputTokensBase: 0,
55
62
  subAgents: new Map(),
56
63
  tools: new Map(),
64
+ taskList: new Map(),
65
+ taskOrder: [],
66
+ pendingTaskCreates: new Map(),
57
67
  };
58
68
  return new Promise((resolve) => {
59
69
  let child;
@@ -90,9 +100,9 @@ export class ClaudeDriver {
90
100
  else
91
101
  ctx.signal.addEventListener('abort', onAbort, { once: true });
92
102
  if (steerable) {
93
- ctx.registerSteer(async (prompt) => {
103
+ ctx.registerSteer(async (prompt, attachments) => {
94
104
  try {
95
- child.stdin.write(claudeUserMessage(prompt) + '\n');
105
+ child.stdin.write(claudeUserMessage(prompt, attachments) + '\n');
96
106
  return true;
97
107
  }
98
108
  catch {
@@ -136,8 +146,11 @@ export class ClaudeDriver {
136
146
  finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason: state.stopReason, sessionId: state.sessionId, usage: usageOf() });
137
147
  });
138
148
  try {
139
- if (steerable)
140
- child.stdin.write(claudeUserMessage(input.prompt) + '\n'); // keep stdin open for steering
149
+ if (useStreamJson) {
150
+ child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
151
+ if (!steerable)
152
+ child.stdin.end(); // single turn; steer mode keeps stdin open for replay
153
+ }
141
154
  else {
142
155
  child.stdin.write(input.prompt);
143
156
  child.stdin.end();
@@ -301,6 +314,34 @@ export function handleClaudeEvent(ev, s, emit) {
301
314
  emit({ type: 'plan', plan });
302
315
  continue;
303
316
  }
317
+ // Task list (current Claude mechanism): stash the subject; the tool_result assigns the id.
318
+ // Plan-only — like the legacy driver these never surface as Activity rows.
319
+ if (name === 'TaskCreate') {
320
+ const subject = typeof b.input?.subject === 'string' ? b.input.subject.trim() : '';
321
+ if (subject)
322
+ (s.pendingTaskCreates ||= new Map()).set(id, { subject });
323
+ continue;
324
+ }
325
+ if (name === 'TaskUpdate') {
326
+ const taskId = String(b.input?.taskId ?? '').trim();
327
+ const rawStatus = String(b.input?.status ?? '').trim().toLowerCase();
328
+ if (taskId) {
329
+ if (rawStatus === 'deleted') {
330
+ s.taskList?.delete(taskId);
331
+ if (Array.isArray(s.taskOrder))
332
+ s.taskOrder = s.taskOrder.filter((x) => x !== taskId);
333
+ }
334
+ else if (rawStatus) {
335
+ const existing = s.taskList?.get(taskId);
336
+ if (existing)
337
+ existing.status = rawStatus;
338
+ }
339
+ const plan = rebuildClaudeTaskPlan(s);
340
+ if (plan)
341
+ emit({ type: 'plan', plan });
342
+ }
343
+ continue;
344
+ }
304
345
  if (name === 'Task' || name === 'Agent') {
305
346
  const input = b.input || {};
306
347
  const sub = {
@@ -348,6 +389,23 @@ export function handleClaudeEvent(ev, s, emit) {
348
389
  continue;
349
390
  emitClaudeImages(b.content || [], s, emit);
350
391
  const id = String(b.tool_use_id || '').trim();
392
+ // TaskCreate result: the assigned task id arrives here; register the task and emit the plan.
393
+ if (id && s.pendingTaskCreates?.has(id)) {
394
+ const pending = s.pendingTaskCreates.get(id);
395
+ const assignedId = readClaudeTaskCreateId(ev, b);
396
+ if (pending && assignedId) {
397
+ s.pendingTaskCreates.delete(id);
398
+ (s.taskList ||= new Map());
399
+ (s.taskOrder ||= []);
400
+ if (!s.taskList.has(assignedId))
401
+ s.taskOrder.push(assignedId);
402
+ s.taskList.set(assignedId, { subject: pending.subject, status: 'pending' });
403
+ const plan = rebuildClaudeTaskPlan(s);
404
+ if (plan)
405
+ emit({ type: 'plan', plan });
406
+ }
407
+ continue;
408
+ }
351
409
  const tool = id ? s.tools?.get(id) : undefined;
352
410
  if (!tool)
353
411
  continue;
@@ -384,10 +442,30 @@ export function handleClaudeEvent(ev, s, emit) {
384
442
  return;
385
443
  }
386
444
  }
387
- // A stream-json user message (for --input-format stream-json; used to send the prompt and
388
- // to inject mid-turn steer messages while stdin stays open).
389
- export function claudeUserMessage(text) {
390
- return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } });
445
+ // Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
446
+ const CLAUDE_IMAGE_MIME = {
447
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
448
+ '.gif': 'image/gif', '.webp': 'image/webp',
449
+ };
450
+ // A stream-json user message (for --input-format stream-json; used to send the prompt and to
451
+ // inject mid-turn steer messages while stdin stays open). Image attachments are inlined as base64
452
+ // image content blocks so the model actually sees them; other files become a text note. Without
453
+ // this the kernel path sent text only and silently dropped pasted/attached images.
454
+ export function claudeUserMessage(text, attachments) {
455
+ const content = [];
456
+ for (const filePath of attachments || []) {
457
+ const mime = CLAUDE_IMAGE_MIME[extname(filePath).toLowerCase()];
458
+ if (mime) {
459
+ try {
460
+ content.push({ type: 'image', source: { type: 'base64', media_type: mime, data: readFileSync(filePath).toString('base64') } });
461
+ continue;
462
+ }
463
+ catch { /* unreadable -> fall through to a text note */ }
464
+ }
465
+ content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
466
+ }
467
+ content.push({ type: 'text', text });
468
+ return JSON.stringify({ type: 'user', message: { role: 'user', content } });
391
469
  }
392
470
  // Surface base64 image content blocks as artifacts (data URLs), deduped per turn.
393
471
  export function emitClaudeImages(blocks, s, emit) {
@@ -425,6 +503,45 @@ export function todoWriteToPlan(input) {
425
503
  }
426
504
  return steps.length ? { explanation: null, steps } : null;
427
505
  }
506
+ // ── Claude task-list (TaskCreate / TaskUpdate) -> UniversalPlan ──────────────────────
507
+ // Current Claude Code drives its task list through TaskCreate/TaskUpdate, NOT TodoWrite
508
+ // (TodoWrite is the legacy mechanism). TaskCreate carries the subject; its tool_result then
509
+ // assigns a stable task id (toolUseResult.task.id, or "Task #N" in the text). TaskUpdate flips
510
+ // a task's status by id. We accumulate the list in driver state and re-emit the whole plan on
511
+ // each change. Without this the kernel path never emits a plan event for Claude, so the
512
+ // dashboard's task-list card never renders. Ported from pikiloom's legacy claude driver.
513
+ // The assigned task id from a TaskCreate tool_result. Prefer the structured field; fall back
514
+ // to parsing "Task #N" from a string result.
515
+ export function readClaudeTaskCreateId(ev, block) {
516
+ const structured = ev?.toolUseResult?.task?.id;
517
+ if (structured != null && String(structured).trim())
518
+ return String(structured).trim();
519
+ const content = block?.content;
520
+ if (typeof content === 'string') {
521
+ const m = content.match(/Task #(\d+)/);
522
+ if (m)
523
+ return m[1];
524
+ }
525
+ return null;
526
+ }
527
+ export function rebuildClaudeTaskPlan(s) {
528
+ if (!Array.isArray(s?.taskOrder) || !s.taskOrder.length)
529
+ return null;
530
+ const steps = [];
531
+ for (const id of s.taskOrder) {
532
+ const task = s.taskList?.get(id);
533
+ if (!task)
534
+ continue;
535
+ const lowered = String(task.status || '').toLowerCase();
536
+ const status = lowered === 'completed' ? 'completed'
537
+ : (lowered === 'in_progress' || lowered === 'inprogress') ? 'inProgress'
538
+ : 'pending';
539
+ const text = String(task.subject || '').trim();
540
+ if (text)
541
+ steps.push({ text, status });
542
+ }
543
+ return steps.length ? { explanation: null, steps } : null;
544
+ }
428
545
  // ── Tool-call summarization (ported from pikiloom's summarizeClaudeToolUse) ──────────
429
546
  // Turns a Claude tool_use {name,input} into a one-line human summary. The runtime's
430
547
  // activity projector joins these into snapshot.activity; the structured form lives in
@@ -185,13 +185,11 @@ export function codexReasoningItemText(item) {
185
185
  ];
186
186
  return parts.map((p) => (typeof p === 'string' ? p : p?.text || '')).filter(Boolean).join('\n').trim();
187
187
  }
188
- // A completed final_answer agentMessage that did NOT stream deltas: append + emit it live.
189
- // deltaItems holds the ids already streamed, so a completed item echoing a streamed one is
190
- // not double-counted (matches the legacy driver's deltaSeenForItem guard).
188
+ // A completed agentMessage (commentary preamble OR final_answer) that did NOT stream deltas:
189
+ // append + emit it live. deltaItems holds the ids already streamed, so a completed item echoing
190
+ // a streamed one is not double-counted (matches the legacy driver's deltaSeenForItem guard).
191
+ // Both phases are surfaced — codex's commentary preambles are part of the visible "中间过程".
191
192
  export function captureCodexAgentMessage(item, s, deltaItems, phases, emit) {
192
- const phase = item?.phase || (item?.id ? phases.get(item.id) : null) || 'final_answer';
193
- if (phase !== 'final_answer')
194
- return;
195
193
  const text = typeof item?.text === 'string' ? item.text.trim() : '';
196
194
  if (!text)
197
195
  return;
@@ -238,11 +236,26 @@ export class CodexDriver {
238
236
  const phases = new Map();
239
237
  const toolSummaries = new Map();
240
238
  const deltaItems = new Set();
239
+ let lastTextItemId = null;
241
240
  let steerRegistered = false;
242
241
  const ok = await srv.start();
243
242
  if (!ok)
244
243
  return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
245
- const onAbort = () => srv.kill();
244
+ let settle = () => { };
245
+ const turnDone = new Promise((res) => { settle = res; });
246
+ // On abort: gracefully interrupt the running turn, then settle turnDone OURSELVES. A bare
247
+ // srv.kill() (SIGTERM) never produces a turn/completed notification, so without this explicit
248
+ // settle() the `await turnDone` below hangs forever — run() never resolves and the task stays
249
+ // "running" in the orchestrator even though the codex process is already dead ("停止不掉,但实际上已经停了").
250
+ const onAbort = () => {
251
+ if (state.sessionId && state.turnId) {
252
+ srv.call('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
253
+ }
254
+ else {
255
+ srv.kill();
256
+ settle();
257
+ }
258
+ };
246
259
  if (ctx.signal.aborted)
247
260
  onAbort();
248
261
  else
@@ -265,8 +278,6 @@ export class CodexDriver {
265
278
  state.sessionId = threadId;
266
279
  ctx.emit({ type: 'session', sessionId: threadId });
267
280
  }
268
- let settle = () => { };
269
- const turnDone = new Promise((res) => { settle = res; });
270
281
  srv.onNotification((method, params) => {
271
282
  if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
272
283
  return;
@@ -298,13 +309,22 @@ export class CodexDriver {
298
309
  break;
299
310
  }
300
311
  case 'item/agentMessage/delta': {
301
- const phase = params?.itemId ? (phases.get(params.itemId) || 'final_answer') : 'final_answer';
302
- if (phase === 'final_answer' && params?.delta) {
303
- state.text += params.delta;
304
- if (params.itemId)
305
- deltaItems.add(params.itemId);
306
- ctx.emit({ type: 'text', delta: params.delta });
312
+ if (!params?.delta)
313
+ break;
314
+ // Surface BOTH commentary (preamble) and final_answer messages live. Codex narrates
315
+ // what it is about to do via phase=commentary agentMessages before tool calls;
316
+ // gating on final_answer dropped them, leaving the "中间过程" invisible. Separate
317
+ // distinct message items with a blank line so preamble and answer don't run together.
318
+ if (params.itemId && params.itemId !== lastTextItemId && state.text) {
319
+ state.text += '\n\n';
320
+ ctx.emit({ type: 'text', delta: '\n\n' });
321
+ }
322
+ if (params.itemId) {
323
+ lastTextItemId = params.itemId;
324
+ deltaItems.add(params.itemId);
307
325
  }
326
+ state.text += params.delta;
327
+ ctx.emit({ type: 'text', delta: params.delta });
308
328
  break;
309
329
  }
310
330
  case 'item/reasoning/textDelta':
@@ -1,14 +1,5 @@
1
- import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent } from '../contracts/driver.js';
2
- export declare class HermesDriver implements AgentDriver {
3
- private readonly bin;
4
- readonly id = "hermes";
5
- readonly capabilities: {
6
- steer: boolean;
7
- interact: boolean;
8
- resume: boolean;
9
- tui: boolean;
10
- };
1
+ import { AcpDriver, applyAcpUpdate } from './acp.js';
2
+ export declare class HermesDriver extends AcpDriver {
11
3
  constructor(bin?: string);
12
- run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
13
4
  }
14
- export declare function applyHermesUpdate(u: any, s: any, tools: Set<string>, emit: (e: DriverEvent) => void): void;
5
+ export declare const applyHermesUpdate: typeof applyAcpUpdate;
@@ -1,194 +1,11 @@
1
- import { spawn } from 'node:child_process';
2
- // Minimal ACP (Agent Client Protocol) ndjson JSON-RPC client same wire as codex
3
- // app-server. Used by the Hermes driver to drive any-model agents over ACP.
4
- class AcpClient {
5
- bin;
6
- args;
7
- env;
8
- proc = null;
9
- buf = '';
10
- nextId = 1;
11
- pending = new Map();
12
- notify;
13
- constructor(bin, args, env) {
14
- this.bin = bin;
15
- this.args = args;
16
- this.env = env;
17
- }
18
- onNotification(cb) { this.notify = cb; }
19
- start() {
20
- try {
21
- this.proc = spawn(this.bin, this.args, { stdio: ['pipe', 'pipe', 'pipe'], env: this.env ? { ...process.env, ...this.env } : process.env });
22
- }
23
- catch {
24
- return false;
25
- }
26
- this.proc.stdout.on('data', (chunk) => this.onData(chunk));
27
- this.proc.on('close', () => { for (const cb of this.pending.values())
28
- cb({ error: { message: 'acp exited' } }); this.pending.clear(); });
29
- this.proc.on('error', () => { });
30
- return true;
31
- }
32
- onData(chunk) {
33
- this.buf += chunk.toString('utf8');
34
- const lines = this.buf.split('\n');
35
- this.buf = lines.pop() || '';
36
- for (const line of lines) {
37
- if (!line.trim())
38
- continue;
39
- let m;
40
- try {
41
- m = JSON.parse(line);
42
- }
43
- catch {
44
- continue;
45
- }
46
- if (m.method && m.id != null) {
47
- this.respond(m.id, {});
48
- } // agent->client request: ack
49
- else if (m.id != null) {
50
- const cb = this.pending.get(m.id);
51
- if (cb) {
52
- this.pending.delete(m.id);
53
- cb(m);
54
- }
55
- }
56
- else if (m.method)
57
- this.notify?.(m.method, m.params ?? {});
58
- }
59
- }
60
- request(method, params, timeoutMs = 60_000) {
61
- return new Promise((resolve) => {
62
- if (!this.proc || this.proc.killed) {
63
- resolve({ error: { message: 'not connected' } });
64
- return;
65
- }
66
- const id = this.nextId++;
67
- const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `ACP '${method}' timed out` } }); }, timeoutMs);
68
- this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
69
- try {
70
- this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
71
- }
72
- catch {
73
- clearTimeout(timer);
74
- this.pending.delete(id);
75
- resolve({ error: { message: 'write failed' } });
76
- }
77
- });
78
- }
79
- respond(id, result) { try {
80
- this.proc?.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
81
- }
82
- catch { /* closed */ } }
83
- kill() { try {
84
- this.proc?.kill('SIGTERM');
85
- }
86
- catch { /* ignore */ } this.proc = null; }
87
- }
88
- export class HermesDriver {
89
- bin;
90
- id = 'hermes';
91
- capabilities = { steer: false, interact: false, resume: true, tui: false };
1
+ import { AcpDriver, applyAcpUpdate } from './acp.js';
2
+ // Hermes = the reference ACP agent, shipped as a thin preset over the generic AcpDriver.
3
+ // Any other ACP CLI (OpenCode, Gemini-ACP, …) is just `new AcpDriver({ id, command, args })`.
4
+ export class HermesDriver extends AcpDriver {
92
5
  constructor(bin = 'hermes') {
93
- this.bin = bin;
94
- }
95
- async run(input, ctx) {
96
- const client = new AcpClient(this.bin, ['acp'], input.env);
97
- const s = { text: '', reasoning: '', sessionId: input.sessionId ?? null, contextWindow: null, contextUsed: null, error: null };
98
- const tools = new Set();
99
- if (!client.start())
100
- return { ok: false, text: '', error: 'failed to start hermes acp', stopReason: 'error' };
101
- const onAbort = () => { try {
102
- client.request('session/cancel', { sessionId: s.sessionId });
103
- }
104
- catch { /* ignore */ } client.kill(); };
105
- if (ctx.signal.aborted)
106
- onAbort();
107
- else
108
- ctx.signal.addEventListener('abort', onAbort, { once: true });
109
- client.onNotification((method, params) => {
110
- if (method !== 'session/update')
111
- return;
112
- applyHermesUpdate(params?.update ?? params, s, tools, ctx.emit);
113
- });
114
- try {
115
- const init = await client.request('initialize', { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } });
116
- if (init.error)
117
- return { ok: false, text: '', error: init.error.message || 'initialize failed', stopReason: 'error' };
118
- if (!s.sessionId) {
119
- const ns = await client.request('session/new', { cwd: input.workdir, mcpServers: [] });
120
- if (ns.error)
121
- return { ok: false, text: '', error: ns.error.message || 'session/new failed', stopReason: 'error' };
122
- s.sessionId = ns.result?.sessionId || ns.result?.session_id || null;
123
- if (s.sessionId)
124
- ctx.emit({ type: 'session', sessionId: s.sessionId });
125
- }
126
- else {
127
- await client.request('session/load', { sessionId: s.sessionId, cwd: input.workdir, mcpServers: [] }, 30_000).catch(() => ({}));
128
- }
129
- if (!s.sessionId)
130
- return { ok: false, text: '', error: 'hermes returned no session id', stopReason: 'error' };
131
- if (input.model)
132
- await client.request('session/set_model', { sessionId: s.sessionId, modelId: input.model }, 15_000).catch(() => ({}));
133
- const promptResp = await client.request('session/prompt', {
134
- sessionId: s.sessionId,
135
- prompt: [{ type: 'text', text: input.prompt }],
136
- }, 7_200_000);
137
- const usage = { inputTokens: null, outputTokens: null, cachedInputTokens: null, contextUsedTokens: s.contextUsed, contextPercent: null };
138
- if (ctx.signal.aborted)
139
- return { ok: false, text: s.text, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: s.sessionId, usage };
140
- if (promptResp.error)
141
- return { ok: false, text: s.text, error: promptResp.error.message || 'session/prompt failed', stopReason: 'error', sessionId: s.sessionId, usage };
142
- const stopReason = promptResp.result?.stopReason ?? 'end_turn';
143
- return { ok: !s.error, text: s.text, reasoning: s.reasoning || undefined, error: s.error, stopReason, sessionId: s.sessionId, usage };
144
- }
145
- finally {
146
- client.kill();
147
- }
148
- }
149
- }
150
- export function applyHermesUpdate(u, s, tools, emit) {
151
- if (!u)
152
- return;
153
- switch (u.sessionUpdate) {
154
- case 'agent_message_chunk': {
155
- const t = u.content?.text;
156
- if (typeof t === 'string' && t) {
157
- s.text += t;
158
- emit({ type: 'text', delta: t });
159
- }
160
- return;
161
- }
162
- case 'agent_thought_chunk': {
163
- const t = u.content?.text;
164
- if (typeof t === 'string' && t) {
165
- s.reasoning += t;
166
- emit({ type: 'reasoning', delta: t });
167
- }
168
- return;
169
- }
170
- case 'tool_call': {
171
- const id = typeof u.toolCallId === 'string' ? u.toolCallId : '';
172
- const title = (typeof u.title === 'string' && u.title.trim()) || 'tool';
173
- if (id && !tools.has(id)) {
174
- tools.add(id);
175
- emit({ type: 'tool', call: { id, name: title, summary: title, status: 'running' } });
176
- }
177
- return;
178
- }
179
- case 'tool_call_update': {
180
- const id = typeof u.toolCallId === 'string' ? u.toolCallId : '';
181
- const title = (typeof u.title === 'string' && u.title.trim()) || 'tool';
182
- if (id && (u.status === 'completed' || u.status === 'failed'))
183
- emit({ type: 'tool', call: { id, name: title, summary: title, status: u.status === 'failed' ? 'failed' : 'done' } });
184
- return;
185
- }
186
- case 'usage_update': {
187
- if (typeof u.used === 'number') {
188
- s.contextUsed = u.used;
189
- emit({ type: 'usage', usage: { inputTokens: null, outputTokens: null, cachedInputTokens: null, contextUsedTokens: u.used, contextPercent: null } });
190
- }
191
- return;
192
- }
6
+ super({ id: 'hermes', command: bin, args: ['acp'] });
193
7
  }
194
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;
@@ -2,4 +2,5 @@ export { EchoDriver } from './echo.js';
2
2
  export { ClaudeDriver, handleClaudeEvent, todoWriteToPlan } from './claude.js';
3
3
  export { CodexDriver } from './codex.js';
4
4
  export { GeminiDriver, parseGeminiEvent } from './gemini.js';
5
+ export { AcpDriver, applyAcpUpdate, toAcpMcpServers, buildAcpPromptBlocks, type AcpDriverConfig } from './acp.js';
5
6
  export { HermesDriver, applyHermesUpdate } from './hermes.js';
@@ -2,4 +2,5 @@ export { EchoDriver } from './echo.js';
2
2
  export { ClaudeDriver, handleClaudeEvent, todoWriteToPlan } from './claude.js';
3
3
  export { CodexDriver } from './codex.js';
4
4
  export { GeminiDriver, parseGeminiEvent } from './gemini.js';
5
+ export { AcpDriver, applyAcpUpdate, toAcpMcpServers, buildAcpPromptBlocks } from './acp.js';
5
6
  export { HermesDriver, applyHermesUpdate } from './hermes.js';
package/dist/index.d.ts CHANGED
@@ -12,8 +12,10 @@ export { EchoDriver } from './drivers/echo.js';
12
12
  export { ClaudeDriver } from './drivers/claude.js';
13
13
  export { CodexDriver } from './drivers/codex.js';
14
14
  export { GeminiDriver } from './drivers/gemini.js';
15
+ export { AcpDriver, type AcpDriverConfig } from './drivers/acp.js';
15
16
  export { HermesDriver } from './drivers/hermes.js';
16
17
  export { WebSurface, type WebSurfaceOptions } from './surfaces/web.js';
17
18
  export { CliSurface } from './surfaces/cli.js';
18
19
  export * from './workspace/index.js';
20
+ export * from './accounts.js';
19
21
  export * from './protocol/index.js';
package/dist/index.js CHANGED
@@ -22,10 +22,13 @@ export { EchoDriver } from './drivers/echo.js';
22
22
  export { ClaudeDriver } from './drivers/claude.js';
23
23
  export { CodexDriver } from './drivers/codex.js';
24
24
  export { GeminiDriver } from './drivers/gemini.js';
25
+ export { AcpDriver } from './drivers/acp.js';
25
26
  export { HermesDriver } from './drivers/hermes.js';
26
27
  export { WebSurface } from './surfaces/web.js';
27
28
  export { CliSurface } from './surfaces/cli.js';
28
29
  // Workspace: unified top-level directory + session/skill/mcp management (loom.paths/sessions/skills/mcp)
29
30
  export * from './workspace/index.js';
31
+ // Multi-account: per-account isolated config dirs (CLAUDE_CONFIG_DIR / CODEX_HOME)
32
+ export * from './accounts.js';
30
33
  // Protocol (the wire vocabulary; shared with transports)
31
34
  export * from './protocol/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",