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.
- package/package.json +2 -1
- package/packages/kernel/README.md +20 -7
- package/packages/kernel/dist/drivers/acp.js +30 -163
- package/packages/kernel/dist/drivers/claude.d.ts +4 -21
- package/packages/kernel/dist/drivers/claude.js +88 -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 +1 -1
- 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 +4 -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.js +6 -9
- 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,14 +71,21 @@ 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;
|
|
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
|
-
|
|
125
|
-
child.kill('SIGTERM');
|
|
126
|
-
}
|
|
127
|
-
catch { /* ignore */ }
|
|
128
|
-
}
|
|
130
|
+
if (kill)
|
|
131
|
+
sigterm(child);
|
|
129
132
|
else {
|
|
130
|
-
const guard = setTimeout(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|