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.
- package/README.md +1 -1
- package/dist/browser-profile.js +3 -2
- package/dist/core/constants.js +4 -0
- package/package.json +2 -1
- package/packages/kernel/README.md +20 -7
- package/packages/kernel/dist/contracts/driver.d.ts +1 -0
- package/packages/kernel/dist/drivers/acp.js +30 -163
- package/packages/kernel/dist/drivers/claude.d.ts +2 -21
- package/packages/kernel/dist/drivers/claude.js +61 -57
- package/packages/kernel/dist/drivers/codex.d.ts +0 -10
- package/packages/kernel/dist/drivers/codex.js +47 -144
- package/packages/kernel/dist/drivers/gemini.js +9 -27
- package/packages/kernel/dist/drivers/hermes.d.ts +1 -2
- package/packages/kernel/dist/drivers/hermes.js +1 -4
- package/packages/kernel/dist/drivers/index.d.ts +5 -4
- package/packages/kernel/dist/drivers/index.js +8 -4
- package/packages/kernel/dist/{workspace → drivers}/native.d.ts +0 -1
- package/packages/kernel/dist/{workspace → drivers}/native.js +212 -45
- package/packages/kernel/dist/drivers/rpc.d.ts +45 -0
- package/packages/kernel/dist/drivers/rpc.js +130 -0
- package/packages/kernel/dist/drivers/shared.d.ts +21 -0
- package/packages/kernel/dist/drivers/shared.js +66 -0
- package/packages/kernel/dist/index.d.ts +2 -1
- package/packages/kernel/dist/index.js +10 -2
- package/packages/kernel/dist/protocol/index.d.ts +5 -0
- package/packages/kernel/dist/protocol/index.js +10 -0
- package/packages/kernel/dist/runtime/hub.js +10 -8
- package/packages/kernel/dist/workspace/index.d.ts +1 -2
- package/packages/kernel/dist/workspace/index.js +3 -2
- package/packages/kernel/dist/workspace/mcp.js +6 -16
- package/packages/kernel/dist/workspace/npm-search.d.ts +9 -0
- package/packages/kernel/dist/workspace/npm-search.js +21 -0
- package/packages/kernel/dist/workspace/sessions.d.ts +3 -0
- package/packages/kernel/dist/workspace/sessions.js +28 -14
- package/packages/kernel/dist/workspace/skills.d.ts +11 -0
- 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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
129
|
-
child.kill('SIGTERM');
|
|
130
|
-
}
|
|
131
|
-
catch { /* ignore */ }
|
|
132
|
-
}
|
|
130
|
+
if (kill)
|
|
131
|
+
sigterm(child);
|
|
133
132
|
else {
|
|
134
|
-
const guard = setTimeout(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
wireAbort(ctx.signal, () => {
|
|
268
169
|
if (state.sessionId && state.turnId) {
|
|
269
|
-
srv.
|
|
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.
|
|
290
|
-
: await srv.
|
|
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.
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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,
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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') {
|