pikiloom 0.4.60 → 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 +4 -21
  5. package/packages/kernel/dist/drivers/claude.js +88 -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
@@ -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,14 +71,21 @@ 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;
79
82
  let settled = false;
80
83
  // One-shot guard for the truncated-turn recovery injection (see the result handler).
81
84
  let truncatedRecoveryAttempted = false;
85
+ // Bounded counter for the no-op-resume recovery re-injection (see the result handler): a
86
+ // resume of a session left incomplete by a prior turn can no-op several times before the
87
+ // CLI's repair clears and it runs our prompt for real.
88
+ let noopResumeRetries = 0;
82
89
  // holdCap: hard backstop while a background task is still running (never-completing daemon).
83
90
  // quiet: fires once all known background tasks finished AND Claude has gone quiet, so trailing
84
91
  // wake-up turns can still land before we close (see claudeBgSettleQuietMs).
@@ -120,17 +127,10 @@ export class ClaudeDriver {
120
127
  }
121
128
  catch { /* ignore */ }
122
129
  if (!child.killed && child.exitCode == null) {
123
- if (kill) {
124
- try {
125
- child.kill('SIGTERM');
126
- }
127
- catch { /* ignore */ }
128
- }
130
+ if (kill)
131
+ sigterm(child);
129
132
  else {
130
- const guard = setTimeout(() => { try {
131
- child?.kill('SIGTERM');
132
- }
133
- catch { /* ignore */ } }, CLAUDE_EXIT_LEAK_GUARD_MS);
133
+ const guard = setTimeout(() => sigterm(child), CLAUDE_EXIT_LEAK_GUARD_MS);
134
134
  unref(guard);
135
135
  }
136
136
  }
@@ -203,14 +203,7 @@ export class ClaudeDriver {
203
203
  finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
204
204
  return;
205
205
  }
206
- const onAbort = () => { try {
207
- child.kill('SIGTERM');
208
- }
209
- catch { /* ignore */ } };
210
- if (ctx.signal.aborted)
211
- onAbort();
212
- else
213
- ctx.signal.addEventListener('abort', onAbort, { once: true });
206
+ wireAbort(ctx.signal, () => sigterm(child));
214
207
  if (steerable) {
215
208
  ctx.registerSteer(async (prompt, attachments) => {
216
209
  try {
@@ -222,27 +215,17 @@ export class ClaudeDriver {
222
215
  }
223
216
  });
224
217
  }
225
- let buf = '';
218
+ const nextLines = createLineBuffer();
226
219
  let stderr = '';
227
220
  child.stdout.on('data', (chunk) => {
228
221
  if (settled)
229
222
  return; // ignore the process's post-settle shutdown chatter
230
- buf += chunk.toString('utf8');
231
- const lines = buf.split('\n');
232
- buf = lines.pop() || '';
233
- for (const line of lines) {
223
+ for (const line of nextLines(chunk)) {
234
224
  if (settled)
235
225
  return;
236
- const trimmed = line.trim();
237
- if (!trimmed)
238
- continue;
239
- let ev;
240
- try {
241
- ev = JSON.parse(trimmed);
242
- }
243
- catch {
226
+ const ev = parseJsonLine(line);
227
+ if (ev === undefined)
244
228
  continue;
245
- }
246
229
  state.lastEventAt = Date.now();
247
230
  handleClaudeEvent(ev, state, ctx.emit);
248
231
  const pending = pendingClaudeBackgroundTasks(state);
@@ -278,6 +261,29 @@ export class ClaudeDriver {
278
261
  settleResult({ stopReason: 'truncated', kill: false });
279
262
  return;
280
263
  }
264
+ // No-op resume repair: resuming a session whose previous turn was left incomplete (a
265
+ // background hold that reclaimed its sub-agents/workflow — the ultra "no response"
266
+ // report — an interrupt, a stall) makes the CLI answer with a synthetic "No response
267
+ // requested." no-op that ran NONE of our prompt (no model turn at all), instead of
268
+ // processing the message. Settling here delivers a silent "(no textual response)" and
269
+ // drops the user's send. stdin is still open: re-issue the prompt so the CLI drives
270
+ // through its repair to a real answer within this one turn. Bounded (the repair can
271
+ // no-op more than once); the post-tool stall watchdog is the backstop if the CLI never
272
+ // engages. Scoped to resumes (input.sessionId) — a fresh session has no dangling turn.
273
+ if (!hasError && !!input.sessionId && !claudeProducedRealOutput(state)
274
+ && noopResumeRetries < claudeResumeNoopRetryLimit()) {
275
+ noopResumeRetries++;
276
+ let injected = false;
277
+ try {
278
+ child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
279
+ injected = true;
280
+ }
281
+ catch { /* fall through to settle */ }
282
+ if (injected) {
283
+ armModelStall();
284
+ continue;
285
+ }
286
+ }
281
287
  // kill=false: the CLI persists the turn into the session jsonl AFTER emitting
282
288
  // `result`, and on a large session that flush (a whole-file rewrite) takes long
283
289
  // enough that an immediate SIGTERM kills the process mid-write — the reply was
@@ -347,7 +353,7 @@ export class ClaudeDriver {
347
353
  return claudeUsageOf(s);
348
354
  }
349
355
  }
350
- export function claudeUsageOf(s) {
356
+ function claudeUsageOf(s) {
351
357
  // While a message is still streaming, the CLI's live thinking estimate (system/thinking_tokens)
352
358
  // is often the ONLY output signal — subscription accounts stream no plaintext thinking and no
353
359
  // usage until the message settles. Fold it into the derived numbers (never into the raw
@@ -355,22 +361,20 @@ export function claudeUsageOf(s) {
355
361
  // output_tokens supersedes it at message_delta.
356
362
  const effOutput = Math.max(s.output ?? 0, s.thinkingEstTokens ?? 0);
357
363
  const used = (s.input ?? 0) + (s.cached ?? 0) + (s.cacheCreation ?? 0) + effOutput;
358
- const window = s.contextWindow ?? null;
359
- const contextPercent = window && used > 0 ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
360
364
  const turnOutput = (s.turnOutputTokensBase ?? 0) + effOutput;
361
365
  return {
362
366
  inputTokens: s.input,
363
367
  outputTokens: s.output,
364
368
  cachedInputTokens: s.cached,
365
369
  contextUsedTokens: used > 0 ? used : null,
366
- contextPercent,
370
+ contextPercent: contextPercent(used > 0 ? used : null, s.contextWindow ?? null),
367
371
  turnOutputTokens: turnOutput > 0 ? turnOutput : null,
368
372
  };
369
373
  }
370
374
  // Accumulate the CLI's live thinking-token estimate onto driver state. Prefer the per-event
371
375
  // delta (correct whether the CLI's running total is per-message or per-turn); fall back to a
372
376
  // monotonic max of the running total. Returns true when the estimate advanced.
373
- export function applyClaudeThinkingEstimate(s, ev) {
377
+ function applyClaudeThinkingEstimate(s, ev) {
374
378
  const prev = s.thinkingEstTokens ?? 0;
375
379
  const delta = Number(ev?.estimated_tokens_delta);
376
380
  const total = Number(ev?.estimated_tokens);
@@ -383,7 +387,7 @@ export function applyClaudeThinkingEstimate(s, ev) {
383
387
  // Advertised context window by Claude model id (best-effort; unknown -> null so the
384
388
  // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
385
389
  // (us.anthropic.claude-…) still match.
386
- export function claudeContextWindowFromModel(model) {
390
+ function claudeContextWindowFromModel(model) {
387
391
  const id = String(model ?? '').trim().toLowerCase();
388
392
  if (!id)
389
393
  return null;
@@ -397,7 +401,7 @@ export function claudeContextWindowFromModel(model) {
397
401
  }
398
402
  // Usable window = advertised minus Claude's max-output (20k) + autocompact (13k) reserve.
399
403
  const CLAUDE_USABLE_WINDOW_RESERVE = 33_000;
400
- export function claudeEffectiveContextWindow(advertised) {
404
+ function claudeEffectiveContextWindow(advertised) {
401
405
  if (advertised == null)
402
406
  return null;
403
407
  return advertised <= CLAUDE_USABLE_WINDOW_RESERVE ? advertised : advertised - CLAUDE_USABLE_WINDOW_RESERVE;
@@ -523,6 +527,13 @@ export function handleClaudeEvent(ev, s, emit) {
523
527
  return;
524
528
  }
525
529
  if (t === 'assistant') {
530
+ // Synthetic resume-repair no-op: resuming a session whose previous turn was left incomplete
531
+ // makes the CLI emit an assistant message with model '<synthetic>' whose only text is
532
+ // "No response requested." (paired with an isMeta "Continue from where you left off." user
533
+ // record). It is NOT model output — mirror the legacy driver and drop it, so it neither shows
534
+ // as the reply nor counts as real output (the no-op-resume recovery keys off that emptiness).
535
+ if (ev.message?.model === '<synthetic>' && isClaudeSyntheticResumeNoise(claudeContentText(ev.message?.content)))
536
+ return;
526
537
  const contents = ev.message?.content || [];
527
538
  for (const b of contents) {
528
539
  if (b?.type !== 'tool_use')
@@ -791,6 +802,31 @@ export function claudeTruncatedRecoveryEnabled() {
791
802
  const v = String(process.env.PIKILOOM_CLAUDE_TRUNCATED_RECOVERY ?? '').trim().toLowerCase();
792
803
  return v !== '0' && v !== 'false' && v !== 'off';
793
804
  }
805
+ // The CLI's resume-repair placeholder for a turn that never concluded: an assistant message with
806
+ // model '<synthetic>' whose only text is "No response requested." (paired with an isMeta "Continue
807
+ // from where you left off." user record). It is NOT model output. Mirrors the legacy driver.
808
+ export function isClaudeSyntheticResumeNoise(text) {
809
+ const t = (text || '').trim().toLowerCase();
810
+ return t === 'no response requested.' || t === 'no response requested';
811
+ }
812
+ // True once the turn produced ANY real model output — streamed or whole-message text/reasoning, a
813
+ // tool use, or a spawned sub-agent. False for a pure no-op (a synthetic resume-repair result that
814
+ // ran none of the prompt), which is exactly what the no-op-resume recovery keys off. Pure +
815
+ // exported for hermetic testing.
816
+ export function claudeProducedRealOutput(s) {
817
+ return !!(s?.streamedText || s?.streamedReasoning
818
+ || (s?.tools?.size ?? 0) > 0 || (s?.subAgents?.size ?? 0) > 0
819
+ || (typeof s?.text === 'string' && s.text.trim().length > 0)
820
+ || (typeof s?.reasoning === 'string' && s.reasoning.trim().length > 0));
821
+ }
822
+ // How many times to re-issue the prompt when a resume comes back a pure no-op before giving up and
823
+ // settling (see the result handler). Bounded so a genuinely dead session can't loop forever; 0
824
+ // disables the recovery. Override with PIKILOOM_CLAUDE_RESUME_NOOP_RETRIES.
825
+ const CLAUDE_RESUME_NOOP_RETRY_DEFAULT = 3;
826
+ export function claudeResumeNoopRetryLimit() {
827
+ const raw = Number(process.env.PIKILOOM_CLAUDE_RESUME_NOOP_RETRIES);
828
+ return Number.isFinite(raw) && raw >= 0 ? raw : CLAUDE_RESUME_NOOP_RETRY_DEFAULT;
829
+ }
794
830
  // True when a claude `type:'user'` stream event carries at least one tool_result block — i.e. the
795
831
  // tool loop just handed control back to the model. Pure + exported for hermetic testing.
796
832
  export function claudeUserEventHasToolResult(ev) {
@@ -885,11 +921,6 @@ export function decideClaudeResultSettle(input) {
885
921
  return 'hold';
886
922
  return input.sawBackground ? 'quiet-settle' : 'settle';
887
923
  }
888
- // Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
889
- const CLAUDE_IMAGE_MIME = {
890
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
891
- '.gif': 'image/gif', '.webp': 'image/webp',
892
- };
893
924
  // A stream-json user message (for --input-format stream-json; used to send the prompt and to
894
925
  // inject mid-turn steer messages while stdin stays open). Image attachments are inlined as base64
895
926
  // image content blocks so the model actually sees them; other files become a text note. Without
@@ -897,7 +928,7 @@ const CLAUDE_IMAGE_MIME = {
897
928
  export function claudeUserMessage(text, attachments) {
898
929
  const content = [];
899
930
  for (const filePath of attachments || []) {
900
- const mime = CLAUDE_IMAGE_MIME[extname(filePath).toLowerCase()];
931
+ const mime = imageMimeForFile(filePath);
901
932
  if (mime) {
902
933
  try {
903
934
  content.push({ type: 'image', source: { type: 'base64', media_type: mime, data: readFileSync(filePath).toString('base64') } });
@@ -905,13 +936,13 @@ export function claudeUserMessage(text, attachments) {
905
936
  }
906
937
  catch { /* unreadable -> fall through to a text note */ }
907
938
  }
908
- content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
939
+ content.push({ type: 'text', text: attachedFileNote(filePath) });
909
940
  }
910
941
  content.push({ type: 'text', text });
911
942
  return JSON.stringify({ type: 'user', message: { role: 'user', content } });
912
943
  }
913
944
  // Surface base64 image content blocks as artifacts (data URLs), deduped per turn.
914
- export function emitClaudeImages(blocks, s, emit) {
945
+ function emitClaudeImages(blocks, s, emit) {
915
946
  if (!Array.isArray(blocks))
916
947
  return;
917
948
  s.seenImages ||= new Set();
@@ -955,7 +986,7 @@ export function todoWriteToPlan(input) {
955
986
  // dashboard's task-list card never renders. Ported from pikiloom's legacy claude driver.
956
987
  // The assigned task id from a TaskCreate tool_result. Prefer the structured field; fall back
957
988
  // to parsing "Task #N" from a string result.
958
- export function readClaudeTaskCreateId(ev, block) {
989
+ function readClaudeTaskCreateId(ev, block) {
959
990
  const structured = ev?.toolUseResult?.task?.id;
960
991
  if (structured != null && String(structured).trim())
961
992
  return String(structured).trim();
@@ -967,7 +998,7 @@ export function readClaudeTaskCreateId(ev, block) {
967
998
  }
968
999
  return null;
969
1000
  }
970
- export function rebuildClaudeTaskPlan(s) {
1001
+ function rebuildClaudeTaskPlan(s) {
971
1002
  if (!Array.isArray(s?.taskOrder) || !s.taskOrder.length)
972
1003
  return null;
973
1004
  const steps = [];
@@ -989,7 +1020,7 @@ export function rebuildClaudeTaskPlan(s) {
989
1020
  // Used when the id isn't in the TaskCreate store, so an update issued AFTER a TodoWrite still
990
1021
  // lands on the displayed list — the plan reflects the state after the LAST change, whichever
991
1022
  // mechanism wrote it. Returns a fresh plan (never mutates) or null when inapplicable.
992
- export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1023
+ function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
993
1024
  if (!plan || !Array.isArray(plan.steps) || !plan.steps.length)
994
1025
  return null;
995
1026
  if (!/^\d+$/.test(taskId))
@@ -1013,7 +1044,7 @@ export function applyTaskUpdateToTodoPlan(plan, taskId, rawStatus) {
1013
1044
  // Turns a Claude tool_use {name,input} into a one-line human summary. The runtime's
1014
1045
  // activity projector joins these into snapshot.activity; the structured form lives in
1015
1046
  // toolCalls. Kept driver-local: knowing Claude's tool input shapes is the driver's job.
1016
- export function shortToolValue(value, max = 140) {
1047
+ function shortToolValue(value, max = 140) {
1017
1048
  if (value == null)
1018
1049
  return '';
1019
1050
  const text = (typeof value === 'string' ? value : String(value)).replace(/\s+/g, ' ').trim();
@@ -1025,7 +1056,7 @@ function toolInputDetail(name, input) {
1025
1056
  const i = input || {};
1026
1057
  return i.command || i.file_path || i.path || i.pattern || i.query || i.url || i.description || '';
1027
1058
  }
1028
- export function summarizeToolUse(name, input) {
1059
+ function summarizeToolUse(name, input) {
1029
1060
  const tool = String(name || '').trim() || 'Tool';
1030
1061
  const i = input || {};
1031
1062
  const description = shortToolValue(i.description, 120);
@@ -1090,7 +1121,7 @@ export function summarizeToolUse(name, input) {
1090
1121
  }
1091
1122
  }
1092
1123
  // First non-empty line of a tool_result content (string | block[]), for the "summary -> detail" form.
1093
- export function firstResultLine(content) {
1124
+ function firstResultLine(content) {
1094
1125
  let text = '';
1095
1126
  if (typeof content === 'string')
1096
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;
@@ -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
  }