pikiloom 0.4.61 → 0.4.63

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 (36) hide show
  1. package/README.md +1 -1
  2. package/dist/browser-profile.js +3 -2
  3. package/dist/core/constants.js +4 -0
  4. package/package.json +2 -1
  5. package/packages/kernel/README.md +20 -7
  6. package/packages/kernel/dist/contracts/driver.d.ts +1 -0
  7. package/packages/kernel/dist/drivers/acp.js +30 -163
  8. package/packages/kernel/dist/drivers/claude.d.ts +2 -21
  9. package/packages/kernel/dist/drivers/claude.js +61 -57
  10. package/packages/kernel/dist/drivers/codex.d.ts +0 -10
  11. package/packages/kernel/dist/drivers/codex.js +47 -144
  12. package/packages/kernel/dist/drivers/gemini.js +9 -27
  13. package/packages/kernel/dist/drivers/hermes.d.ts +1 -2
  14. package/packages/kernel/dist/drivers/hermes.js +1 -4
  15. package/packages/kernel/dist/drivers/index.d.ts +5 -4
  16. package/packages/kernel/dist/drivers/index.js +8 -4
  17. package/packages/kernel/dist/{workspace → drivers}/native.d.ts +0 -1
  18. package/packages/kernel/dist/{workspace → drivers}/native.js +212 -45
  19. package/packages/kernel/dist/drivers/rpc.d.ts +45 -0
  20. package/packages/kernel/dist/drivers/rpc.js +130 -0
  21. package/packages/kernel/dist/drivers/shared.d.ts +21 -0
  22. package/packages/kernel/dist/drivers/shared.js +66 -0
  23. package/packages/kernel/dist/index.d.ts +2 -1
  24. package/packages/kernel/dist/index.js +10 -2
  25. package/packages/kernel/dist/protocol/index.d.ts +5 -0
  26. package/packages/kernel/dist/protocol/index.js +10 -0
  27. package/packages/kernel/dist/runtime/hub.js +10 -8
  28. package/packages/kernel/dist/workspace/index.d.ts +1 -2
  29. package/packages/kernel/dist/workspace/index.js +3 -2
  30. package/packages/kernel/dist/workspace/mcp.js +6 -16
  31. package/packages/kernel/dist/workspace/npm-search.d.ts +9 -0
  32. package/packages/kernel/dist/workspace/npm-search.js +21 -0
  33. package/packages/kernel/dist/workspace/sessions.d.ts +3 -0
  34. package/packages/kernel/dist/workspace/sessions.js +28 -14
  35. package/packages/kernel/dist/workspace/skills.d.ts +11 -0
  36. package/packages/kernel/dist/workspace/skills.js +14 -20
@@ -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)
242
- continue;
243
- let ev;
244
- try {
245
- ev = JSON.parse(trimmed);
246
- }
247
- catch {
226
+ const ev = parseJsonLine(line);
227
+ if (ev === undefined)
248
228
  continue;
249
- }
250
229
  state.lastEventAt = Date.now();
251
230
  handleClaudeEvent(ev, state, ctx.emit);
252
231
  const pending = pendingClaudeBackgroundTasks(state);
@@ -291,7 +270,13 @@ export class ClaudeDriver {
291
270
  // through its repair to a real answer within this one turn. Bounded (the repair can
292
271
  // no-op more than once); the post-tool stall watchdog is the backstop if the CLI never
293
272
  // engages. Scoped to resumes (input.sessionId) — a fresh session has no dangling turn.
273
+ // Excludes local slash commands (isClaudeSlashCommand): /compact, /clear & friends are
274
+ // LEGITIMATELY output-empty (their effect is a local action, not an assistant reply), so
275
+ // the emptiness that flags a dropped send is normal for them. Re-issuing one is never a
276
+ // repair — for /compact it just fires a second compaction that reports "Not enough
277
+ // messages to compact." A real dropped send is a plain prompt and still self-heals.
294
278
  if (!hasError && !!input.sessionId && !claudeProducedRealOutput(state)
279
+ && !isClaudeSlashCommand(input.prompt)
295
280
  && noopResumeRetries < claudeResumeNoopRetryLimit()) {
296
281
  noopResumeRetries++;
297
282
  let injected = false;
@@ -374,7 +359,7 @@ export class ClaudeDriver {
374
359
  return claudeUsageOf(s);
375
360
  }
376
361
  }
377
- export function claudeUsageOf(s) {
362
+ function claudeUsageOf(s) {
378
363
  // While a message is still streaming, the CLI's live thinking estimate (system/thinking_tokens)
379
364
  // is often the ONLY output signal — subscription accounts stream no plaintext thinking and no
380
365
  // usage until the message settles. Fold it into the derived numbers (never into the raw
@@ -382,22 +367,20 @@ export function claudeUsageOf(s) {
382
367
  // output_tokens supersedes it at message_delta.
383
368
  const effOutput = Math.max(s.output ?? 0, s.thinkingEstTokens ?? 0);
384
369
  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
370
  const turnOutput = (s.turnOutputTokensBase ?? 0) + effOutput;
388
371
  return {
389
372
  inputTokens: s.input,
390
373
  outputTokens: s.output,
391
374
  cachedInputTokens: s.cached,
392
375
  contextUsedTokens: used > 0 ? used : null,
393
- contextPercent,
376
+ contextPercent: contextPercent(used > 0 ? used : null, s.contextWindow ?? null),
394
377
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
395
378
  };
396
379
  }
397
380
  // Accumulate the CLI's live thinking-token estimate onto driver state. Prefer the per-event
398
381
  // delta (correct whether the CLI's running total is per-message or per-turn); fall back to a
399
382
  // monotonic max of the running total. Returns true when the estimate advanced.
400
- export function applyClaudeThinkingEstimate(s, ev) {
383
+ function applyClaudeThinkingEstimate(s, ev) {
401
384
  const prev = s.thinkingEstTokens ?? 0;
402
385
  const delta = Number(ev?.estimated_tokens_delta);
403
386
  const total = Number(ev?.estimated_tokens);
@@ -410,7 +393,7 @@ export function applyClaudeThinkingEstimate(s, ev) {
410
393
  // Advertised context window by Claude model id (best-effort; unknown -> null so the
411
394
  // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
412
395
  // (us.anthropic.claude-…) still match.
413
- export function claudeContextWindowFromModel(model) {
396
+ function claudeContextWindowFromModel(model) {
414
397
  const id = String(model ?? '').trim().toLowerCase();
415
398
  if (!id)
416
399
  return null;
@@ -424,7 +407,7 @@ export function claudeContextWindowFromModel(model) {
424
407
  }
425
408
  // Usable window = advertised minus Claude's max-output (20k) + autocompact (13k) reserve.
426
409
  const CLAUDE_USABLE_WINDOW_RESERVE = 33_000;
427
- export function claudeEffectiveContextWindow(advertised) {
410
+ function claudeEffectiveContextWindow(advertised) {
428
411
  if (advertised == null)
429
412
  return null;
430
413
  return advertised <= CLAUDE_USABLE_WINDOW_RESERVE ? advertised : advertised - CLAUDE_USABLE_WINDOW_RESERVE;
@@ -557,6 +540,23 @@ export function handleClaudeEvent(ev, s, emit) {
557
540
  // as the reply nor counts as real output (the no-op-resume recovery keys off that emptiness).
558
541
  if (ev.message?.model === '<synthetic>' && isClaudeSyntheticResumeNoise(claudeContentText(ev.message?.content)))
559
542
  return;
543
+ // API-error message: Claude surfaces a failed model call (401 auth, overloaded, quota, …) not as a
544
+ // result code but as a synthetic assistant message — model '<synthetic>', a lone text block carrying
545
+ // the human-readable error ("Failed to authenticate. API Error: 401 …"), and a TOP-LEVEL `error` tag
546
+ // on the event (e.g. "authentication_failed"; the persisted transcript also stamps `isApiErrorMessage`).
547
+ // It is NOT model output. Routing its text through the normal path below would make the error render
548
+ // as the assistant's reply body (原文). Send it to `s.error` — the run-end notice, same slot as the
549
+ // `result{is_error}` branch below — and never to `s.text`. The trailing `result` also flags the error
550
+ // (and would set `s.error` too), but claiming it here keeps a narration-less turn from double-rendering
551
+ // (body + notice), and preserves any real narration already streamed before the call failed.
552
+ const apiErrorTag = typeof ev.error === 'string' ? ev.error.trim() : (ev.isApiErrorMessage ? 'api_error' : '');
553
+ if (apiErrorTag) {
554
+ if (!s.error) {
555
+ const msg = claudeContentText(ev.message?.content).trim();
556
+ s.error = msg || `Claude reported an API error (${apiErrorTag})`;
557
+ }
558
+ return;
559
+ }
560
560
  const contents = ev.message?.content || [];
561
561
  for (const b of contents) {
562
562
  if (b?.type !== 'tool_use')
@@ -832,6 +832,15 @@ export function isClaudeSyntheticResumeNoise(text) {
832
832
  const t = (text || '').trim().toLowerCase();
833
833
  return t === 'no response requested.' || t === 'no response requested';
834
834
  }
835
+ // A prompt whose first token is a Claude slash command (/compact, /clear, /cost, /model …, a custom
836
+ // /namespace:command). Such a prompt is a LOCAL action, not a request for a model reply, so an
837
+ // output-empty result is expected — not the dropped-send signature the no-op-resume repair targets.
838
+ // The first token must be a bare command name (letters/digits/_/- with an optional :namespace) ending
839
+ // at whitespace or end-of-string, so a filesystem-style path (/Users/foo) is NOT matched. Pure +
840
+ // exported for hermetic testing.
841
+ export function isClaudeSlashCommand(prompt) {
842
+ return /^\/[a-z0-9][\w-]*(?::[\w-]+)?(?:\s|$)/i.test((prompt || '').trimStart());
843
+ }
835
844
  // True once the turn produced ANY real model output — streamed or whole-message text/reasoning, a
836
845
  // tool use, or a spawned sub-agent. False for a pure no-op (a synthetic resume-repair result that
837
846
  // ran none of the prompt), which is exactly what the no-op-resume recovery keys off. Pure +
@@ -944,11 +953,6 @@ export function decideClaudeResultSettle(input) {
944
953
  return 'hold';
945
954
  return input.sawBackground ? 'quiet-settle' : 'settle';
946
955
  }
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
956
  // A stream-json user message (for --input-format stream-json; used to send the prompt and to
953
957
  // inject mid-turn steer messages while stdin stays open). Image attachments are inlined as base64
954
958
  // image content blocks so the model actually sees them; other files become a text note. Without
@@ -956,7 +960,7 @@ const CLAUDE_IMAGE_MIME = {
956
960
  export function claudeUserMessage(text, attachments) {
957
961
  const content = [];
958
962
  for (const filePath of attachments || []) {
959
- const mime = CLAUDE_IMAGE_MIME[extname(filePath).toLowerCase()];
963
+ const mime = imageMimeForFile(filePath);
960
964
  if (mime) {
961
965
  try {
962
966
  content.push({ type: 'image', source: { type: 'base64', media_type: mime, data: readFileSync(filePath).toString('base64') } });
@@ -964,13 +968,13 @@ export function claudeUserMessage(text, attachments) {
964
968
  }
965
969
  catch { /* unreadable -> fall through to a text note */ }
966
970
  }
967
- content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
971
+ content.push({ type: 'text', text: attachedFileNote(filePath) });
968
972
  }
969
973
  content.push({ type: 'text', text });
970
974
  return JSON.stringify({ type: 'user', message: { role: 'user', content } });
971
975
  }
972
976
  // Surface base64 image content blocks as artifacts (data URLs), deduped per turn.
973
- export function emitClaudeImages(blocks, s, emit) {
977
+ function emitClaudeImages(blocks, s, emit) {
974
978
  if (!Array.isArray(blocks))
975
979
  return;
976
980
  s.seenImages ||= new Set();
@@ -1014,7 +1018,7 @@ export function todoWriteToPlan(input) {
1014
1018
  // dashboard's task-list card never renders. Ported from pikiloom's legacy claude driver.
1015
1019
  // The assigned task id from a TaskCreate tool_result. Prefer the structured field; fall back
1016
1020
  // to parsing "Task #N" from a string result.
1017
- export function readClaudeTaskCreateId(ev, block) {
1021
+ function readClaudeTaskCreateId(ev, block) {
1018
1022
  const structured = ev?.toolUseResult?.task?.id;
1019
1023
  if (structured != null && String(structured).trim())
1020
1024
  return String(structured).trim();
@@ -1026,7 +1030,7 @@ export function readClaudeTaskCreateId(ev, block) {
1026
1030
  }
1027
1031
  return null;
1028
1032
  }
1029
- export function rebuildClaudeTaskPlan(s) {
1033
+ function rebuildClaudeTaskPlan(s) {
1030
1034
  if (!Array.isArray(s?.taskOrder) || !s.taskOrder.length)
1031
1035
  return null;
1032
1036
  const steps = [];
@@ -1048,7 +1052,7 @@ export function rebuildClaudeTaskPlan(s) {
1048
1052
  // Used when the id isn't in the TaskCreate store, so an update issued AFTER a TodoWrite still
1049
1053
  // lands on the displayed list — the plan reflects the state after the LAST change, whichever
1050
1054
  // mechanism wrote it. Returns a fresh plan (never mutates) or null when inapplicable.
1051
- export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1055
+ function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1052
1056
  if (!plan || !Array.isArray(plan.steps) || !plan.steps.length)
1053
1057
  return null;
1054
1058
  if (!/^\d+$/.test(taskId))
@@ -1072,7 +1076,7 @@ export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1072
1076
  // Turns a Claude tool_use {name,input} into a one-line human summary. The runtime's
1073
1077
  // activity projector joins these into snapshot.activity; the structured form lives in
1074
1078
  // toolCalls. Kept driver-local: knowing Claude's tool input shapes is the driver's job.
1075
- export function shortToolValue(value, max = 140) {
1079
+ function shortToolValue(value, max = 140) {
1076
1080
  if (value == null)
1077
1081
  return '';
1078
1082
  const text = (typeof value === 'string' ? value : String(value)).replace(/\s+/g, ' ').trim();
@@ -1084,7 +1088,7 @@ function toolInputDetail(name, input) {
1084
1088
  const i = input || {};
1085
1089
  return i.command || i.file_path || i.path || i.pattern || i.query || i.url || i.description || '';
1086
1090
  }
1087
- export function summarizeToolUse(name, input) {
1091
+ function summarizeToolUse(name, input) {
1088
1092
  const tool = String(name || '').trim() || 'Tool';
1089
1093
  const i = input || {};
1090
1094
  const description = shortToolValue(i.description, 120);
@@ -1149,7 +1153,7 @@ export function summarizeToolUse(name, input) {
1149
1153
  }
1150
1154
  }
1151
1155
  // First non-empty line of a tool_result content (string | block[]), for the "summary -> detail" form.
1152
- export function firstResultLine(content) {
1156
+ function firstResultLine(content) {
1153
1157
  let text = '';
1154
1158
  if (typeof content === 'string')
1155
1159
  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;
@@ -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;