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,4 +1,4 @@
|
|
|
1
|
-
import { AcpDriver
|
|
1
|
+
import { AcpDriver } from './acp.js';
|
|
2
2
|
// Hermes = the reference ACP agent, shipped as a thin preset over the generic AcpDriver.
|
|
3
3
|
// Any other ACP CLI (OpenCode, Gemini-ACP, …) is just `new AcpDriver({ id, command, args })`.
|
|
4
4
|
export class HermesDriver extends AcpDriver {
|
|
@@ -6,6 +6,3 @@ export class HermesDriver extends AcpDriver {
|
|
|
6
6
|
super({ id: 'hermes', command: bin, args: ['acp'] });
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
-
// Back-compat alias: the generic ACP session/update parser handles the identical wire that
|
|
10
|
-
// the original hermes-specific parser did. Kept so existing imports/tests resolve.
|
|
11
|
-
export const applyHermesUpdate = applyAcpUpdate;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { EchoDriver } from './echo.js';
|
|
2
|
-
export { ClaudeDriver
|
|
2
|
+
export { ClaudeDriver } from './claude.js';
|
|
3
3
|
export { CodexDriver } from './codex.js';
|
|
4
|
-
export { GeminiDriver
|
|
5
|
-
export { AcpDriver,
|
|
6
|
-
export { HermesDriver
|
|
4
|
+
export { GeminiDriver } from './gemini.js';
|
|
5
|
+
export { AcpDriver, type AcpDriverConfig } from './acp.js';
|
|
6
|
+
export { HermesDriver } from './hermes.js';
|
|
7
|
+
export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, type DiscoverOptions, type NativeSessionInfo, } from './native.js';
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
// The agent axis: one driver per coding-agent CLI, plus native-session discovery (pure
|
|
2
|
+
// readers of each CLI's own on-disk transcript store). White-box parser/settle helpers are
|
|
3
|
+
// deliberately NOT re-exported here — tests import them from their defining module.
|
|
1
4
|
export { EchoDriver } from './echo.js';
|
|
2
|
-
export { ClaudeDriver
|
|
5
|
+
export { ClaudeDriver } from './claude.js';
|
|
3
6
|
export { CodexDriver } from './codex.js';
|
|
4
|
-
export { GeminiDriver
|
|
5
|
-
export { AcpDriver
|
|
6
|
-
export { HermesDriver
|
|
7
|
+
export { GeminiDriver } from './gemini.js';
|
|
8
|
+
export { AcpDriver } from './acp.js';
|
|
9
|
+
export { HermesDriver } from './hermes.js';
|
|
10
|
+
export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, } from './native.js';
|
|
@@ -6,7 +6,6 @@ export interface DiscoverOptions {
|
|
|
6
6
|
/** A session whose file changed within this window is treated as "running". */
|
|
7
7
|
runningThresholdMs?: number;
|
|
8
8
|
}
|
|
9
|
-
export declare const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10000;
|
|
10
9
|
/** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
|
|
11
10
|
export declare function encodeClaudeProjectDir(workdir: string): string;
|
|
12
11
|
export declare function discoverClaudeNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
4
|
+
const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10_000;
|
|
5
5
|
function homeOf(opts) {
|
|
6
6
|
return opts?.home || os.homedir();
|
|
7
7
|
}
|
|
@@ -21,6 +21,53 @@ function statSafe(p) {
|
|
|
21
21
|
return null;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
// A transcript's title/preview/model/effort/turns all live in a BOUNDED slice of the file: the HEAD
|
|
25
|
+
// (first prompt, model, ai-title) and the TAIL (latest reply, last turn_context). We read only those
|
|
26
|
+
// slices — never the whole file — so discovery stays O(bounded) per session even when a rollout grows
|
|
27
|
+
// to hundreds of MB. Results are memoized by the file's (mtime,size): an unchanged transcript is read
|
|
28
|
+
// once and every later list call is free, so adding the tail read costs nothing on repeat. Any real
|
|
29
|
+
// change moves mtime+size and re-reads, so the cache can never go stale.
|
|
30
|
+
const HEAD_BYTES = 256 * 1024;
|
|
31
|
+
const TAIL_BYTES = 256 * 1024;
|
|
32
|
+
/** Read a bounded byte region as UTF-8. A partial leading/trailing line is expected; callers skip unparseable lines. */
|
|
33
|
+
function readRegion(filePath, start, length) {
|
|
34
|
+
const fd = fs.openSync(filePath, 'r');
|
|
35
|
+
try {
|
|
36
|
+
const buf = Buffer.alloc(Math.max(0, length));
|
|
37
|
+
const n = fs.readSync(fd, buf, 0, buf.length, Math.max(0, start));
|
|
38
|
+
return buf.toString('utf8', 0, n);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function memoized(cache, filePath, stat, compute) {
|
|
45
|
+
const hit = cache.get(filePath);
|
|
46
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size)
|
|
47
|
+
return hit.value;
|
|
48
|
+
const value = compute();
|
|
49
|
+
cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/** Extract plain text from a Codex `response_item` message content (array of `{type,text}` blocks). */
|
|
53
|
+
function codexText(content) {
|
|
54
|
+
if (typeof content === 'string')
|
|
55
|
+
return content;
|
|
56
|
+
if (!Array.isArray(content))
|
|
57
|
+
return '';
|
|
58
|
+
return content
|
|
59
|
+
.filter((b) => b && typeof b.text === 'string' && (b.type === 'text' || (b.type ?? '').endsWith('_text')))
|
|
60
|
+
.map((b) => b.text)
|
|
61
|
+
.join('');
|
|
62
|
+
}
|
|
63
|
+
/** Reasoning effort from a Codex `turn_context` payload: top-level `effort`, else the nested collaboration setting. */
|
|
64
|
+
function codexEffortOf(payload) {
|
|
65
|
+
const top = payload?.effort;
|
|
66
|
+
if (typeof top === 'string' && top.trim())
|
|
67
|
+
return top.trim();
|
|
68
|
+
const re = payload?.collaboration_mode?.settings?.reasoning_effort;
|
|
69
|
+
return typeof re === 'string' && re.trim() ? re.trim() : null;
|
|
70
|
+
}
|
|
24
71
|
// ---- Claude: ~/.claude/projects/<encoded-workdir>/<sessionId>.jsonl ----
|
|
25
72
|
/** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
|
|
26
73
|
export function encodeClaudeProjectDir(workdir) {
|
|
@@ -40,52 +87,80 @@ function claudeText(content) {
|
|
|
40
87
|
}
|
|
41
88
|
return parts.join(' ');
|
|
42
89
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
54
|
-
fs.closeSync(fd);
|
|
55
|
-
head = buf.toString('utf8', 0, n);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return { title: null, model: null, preview: null, turns: 0 };
|
|
59
|
-
}
|
|
60
|
-
for (const line of head.split('\n')) {
|
|
61
|
-
if (!line || line[0] !== '{')
|
|
62
|
-
continue;
|
|
63
|
-
let ev;
|
|
90
|
+
const claudeMetaCache = new Map();
|
|
91
|
+
function readClaudeMeta(filePath, stat) {
|
|
92
|
+
return memoized(claudeMetaCache, filePath, stat, () => {
|
|
93
|
+
const size = stat.size;
|
|
94
|
+
let title = null;
|
|
95
|
+
let headModel = null;
|
|
96
|
+
let lastUser = null;
|
|
97
|
+
let headAssistant = null;
|
|
98
|
+
let turns = 0;
|
|
99
|
+
let head = '';
|
|
64
100
|
try {
|
|
65
|
-
|
|
101
|
+
head = readRegion(filePath, 0, Math.min(HEAD_BYTES, Math.max(65536, size)));
|
|
66
102
|
}
|
|
67
103
|
catch {
|
|
68
|
-
|
|
104
|
+
return { title: null, model: null, preview: null, turns: 0 };
|
|
69
105
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
for (const line of head.split('\n')) {
|
|
107
|
+
if (!line || line[0] !== '{')
|
|
108
|
+
continue;
|
|
109
|
+
let ev;
|
|
110
|
+
try {
|
|
111
|
+
ev = JSON.parse(line);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ev.type === 'user' && ev.isMeta !== true) {
|
|
117
|
+
const text = claudeText(ev.message?.content).trim();
|
|
118
|
+
if (text && !text.startsWith('<') && !text.startsWith('[Image:')) {
|
|
119
|
+
if (!title)
|
|
120
|
+
title = cleanTitle(text);
|
|
121
|
+
lastUser = text;
|
|
122
|
+
turns++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (ev.type === 'assistant') {
|
|
126
|
+
if (!headModel && ev.message?.model && ev.message.model !== '<synthetic>')
|
|
127
|
+
headModel = ev.message.model;
|
|
128
|
+
const text = claudeText(ev.message?.content).trim();
|
|
129
|
+
if (text)
|
|
130
|
+
headAssistant = text;
|
|
77
131
|
}
|
|
78
132
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
// The LATEST reply lives at the end of a long transcript, not in the head — read a bounded tail
|
|
134
|
+
// slice for an accurate preview (and the most recent model). Skipped when the file already fits
|
|
135
|
+
// in the head window (then the head scan above already saw the whole thing).
|
|
136
|
+
let tailAssistant = null;
|
|
137
|
+
let tailModel = null;
|
|
138
|
+
if (size > HEAD_BYTES) {
|
|
139
|
+
try {
|
|
140
|
+
for (const line of readRegion(filePath, size - TAIL_BYTES, TAIL_BYTES).split('\n')) {
|
|
141
|
+
if (!line || line[0] !== '{')
|
|
142
|
+
continue;
|
|
143
|
+
let ev;
|
|
144
|
+
try {
|
|
145
|
+
ev = JSON.parse(line);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ev.type !== 'assistant')
|
|
151
|
+
continue;
|
|
152
|
+
const text = claudeText(ev.message?.content).trim();
|
|
153
|
+
if (text)
|
|
154
|
+
tailAssistant = text;
|
|
155
|
+
if (ev.message?.model && ev.message.model !== '<synthetic>')
|
|
156
|
+
tailModel = ev.message.model;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* keep head-derived values */ }
|
|
85
160
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
161
|
+
const preview = cleanTitle(tailAssistant || headAssistant || lastUser, 200);
|
|
162
|
+
return { title, model: tailModel || headModel, preview, turns };
|
|
163
|
+
});
|
|
89
164
|
}
|
|
90
165
|
export function discoverClaudeNativeSessions(workdir, opts = {}) {
|
|
91
166
|
const home = homeOf(opts);
|
|
@@ -111,7 +186,7 @@ export function discoverClaudeNativeSessions(workdir, opts = {}) {
|
|
|
111
186
|
const selected = typeof opts.limit === 'number' ? files.slice(0, Math.max(0, opts.limit)) : files;
|
|
112
187
|
const now = Date.now();
|
|
113
188
|
return selected.map(({ sessionId, filePath, stat }) => {
|
|
114
|
-
const { title, model, preview, turns } =
|
|
189
|
+
const { title, model, preview, turns } = readClaudeMeta(filePath, stat);
|
|
115
190
|
return {
|
|
116
191
|
sessionId,
|
|
117
192
|
title,
|
|
@@ -148,6 +223,18 @@ function loadCodexTitleIndex(home) {
|
|
|
148
223
|
}
|
|
149
224
|
return map;
|
|
150
225
|
}
|
|
226
|
+
// A rollout's `session_meta` (id/cwd/timestamp) is written once at creation and never changes, so its
|
|
227
|
+
// parsed head is cached by path permanently — the per-list cwd filter then costs one 8 KB read per file
|
|
228
|
+
// only the FIRST time it is ever seen, not on every list call.
|
|
229
|
+
const codexHeadCache = new Map();
|
|
230
|
+
function readCodexHeadCached(filePath) {
|
|
231
|
+
const hit = codexHeadCache.get(filePath);
|
|
232
|
+
if (hit !== undefined)
|
|
233
|
+
return hit;
|
|
234
|
+
const v = readCodexHead(filePath);
|
|
235
|
+
codexHeadCache.set(filePath, v);
|
|
236
|
+
return v;
|
|
237
|
+
}
|
|
151
238
|
function readCodexHead(filePath) {
|
|
152
239
|
try {
|
|
153
240
|
const fd = fs.openSync(filePath, 'r');
|
|
@@ -173,6 +260,81 @@ function readCodexHead(filePath) {
|
|
|
173
260
|
return null;
|
|
174
261
|
}
|
|
175
262
|
}
|
|
263
|
+
const codexMetaCache = new Map();
|
|
264
|
+
/**
|
|
265
|
+
* Bounded per-row metadata for a Codex rollout: the first user prompt (title fallback when the thread
|
|
266
|
+
* has no index name) from a HEAD slice, and the latest reply + last model/effort from a TAIL slice.
|
|
267
|
+
* Memoized by (mtime,size); never reads the whole file.
|
|
268
|
+
*/
|
|
269
|
+
function readCodexBoundedMeta(filePath, stat) {
|
|
270
|
+
return memoized(codexMetaCache, filePath, stat, () => {
|
|
271
|
+
let firstPrompt = null;
|
|
272
|
+
let firstAny = null;
|
|
273
|
+
let preview = null;
|
|
274
|
+
let model = null;
|
|
275
|
+
let effort = null;
|
|
276
|
+
try {
|
|
277
|
+
// HEAD: the first real user turn (skip a leading <handover> injected by an agent switch).
|
|
278
|
+
for (const line of readRegion(filePath, 0, Math.min(HEAD_BYTES, stat.size)).split('\n')) {
|
|
279
|
+
const t = line.trim();
|
|
280
|
+
if (!t || t[0] !== '{' || !t.includes('user_message'))
|
|
281
|
+
continue;
|
|
282
|
+
let ev;
|
|
283
|
+
try {
|
|
284
|
+
ev = JSON.parse(t);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (ev.type !== 'event_msg' || ev.payload?.type !== 'user_message')
|
|
290
|
+
continue;
|
|
291
|
+
const text = String(ev.payload.message ?? '').trim();
|
|
292
|
+
if (!text)
|
|
293
|
+
continue;
|
|
294
|
+
if (!firstAny)
|
|
295
|
+
firstAny = text;
|
|
296
|
+
if (!String(text).toLowerCase().startsWith('<handover')) {
|
|
297
|
+
firstPrompt = text;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch { /* best-effort */ }
|
|
303
|
+
try {
|
|
304
|
+
// TAIL: the latest assistant reply + last turn_context (model/effort).
|
|
305
|
+
const start = Math.max(0, stat.size - TAIL_BYTES);
|
|
306
|
+
for (const line of readRegion(filePath, start, Math.min(TAIL_BYTES, stat.size)).split('\n')) {
|
|
307
|
+
const t = line.trim();
|
|
308
|
+
if (!t || t[0] !== '{')
|
|
309
|
+
continue;
|
|
310
|
+
let ev;
|
|
311
|
+
try {
|
|
312
|
+
ev = JSON.parse(t);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const p = ev.payload;
|
|
318
|
+
if (!p)
|
|
319
|
+
continue;
|
|
320
|
+
if (ev.type === 'turn_context') {
|
|
321
|
+
if (typeof p.model === 'string' && p.model.trim())
|
|
322
|
+
model = p.model.trim();
|
|
323
|
+
const e = codexEffortOf(p);
|
|
324
|
+
if (e)
|
|
325
|
+
effort = e;
|
|
326
|
+
}
|
|
327
|
+
else if (ev.type === 'response_item' && p.type === 'message' && p.role === 'assistant') {
|
|
328
|
+
const text = codexText(p.content).trim();
|
|
329
|
+
if (text)
|
|
330
|
+
preview = text;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch { /* best-effort */ }
|
|
335
|
+
return { firstPrompt: cleanTitle(firstPrompt ?? firstAny), preview: cleanTitle(preview, 200), model, effort };
|
|
336
|
+
});
|
|
337
|
+
}
|
|
176
338
|
export function discoverCodexNativeSessions(workdir, opts = {}) {
|
|
177
339
|
const home = homeOf(opts);
|
|
178
340
|
const sessionsDir = path.join(home, '.codex', 'sessions');
|
|
@@ -206,7 +368,7 @@ export function discoverCodexNativeSessions(workdir, opts = {}) {
|
|
|
206
368
|
const out = [];
|
|
207
369
|
const seen = new Set();
|
|
208
370
|
for (const { filePath, stat } of files) {
|
|
209
|
-
const meta =
|
|
371
|
+
const meta = readCodexHeadCached(filePath);
|
|
210
372
|
if (!meta || meta.isSubagent || path.resolve(meta.cwd) !== resolvedWorkdir)
|
|
211
373
|
continue;
|
|
212
374
|
if (seen.has(meta.sessionId))
|
|
@@ -214,12 +376,17 @@ export function discoverCodexNativeSessions(workdir, opts = {}) {
|
|
|
214
376
|
seen.add(meta.sessionId);
|
|
215
377
|
const idx = titleIndex.get(meta.sessionId);
|
|
216
378
|
const updatedAt = idx?.updatedAt || stat.mtime.toISOString();
|
|
379
|
+
// Only files that passed the cwd/subagent filter above pay for the bounded head+tail read (first
|
|
380
|
+
// prompt / preview / model / effort) — scoped to THIS workdir's own rollouts, and memoized per file.
|
|
381
|
+
const bounded = readCodexBoundedMeta(filePath, stat);
|
|
217
382
|
out.push({
|
|
218
383
|
sessionId: meta.sessionId,
|
|
219
|
-
|
|
220
|
-
|
|
384
|
+
// The agent's own thread name wins; otherwise fall back to the first prompt so the row is named + searchable.
|
|
385
|
+
title: cleanTitle(idx?.threadName || null) ?? bounded.firstPrompt,
|
|
386
|
+
preview: bounded.preview,
|
|
221
387
|
cwd: meta.cwd,
|
|
222
|
-
model:
|
|
388
|
+
model: bounded.model,
|
|
389
|
+
effort: bounded.effort,
|
|
223
390
|
createdAt: meta.timestamp || stat.birthtime.toISOString(),
|
|
224
391
|
updatedAt,
|
|
225
392
|
running: Date.now() - Date.parse(updatedAt) < threshold,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type RpcMsg = {
|
|
2
|
+
jsonrpc?: string;
|
|
3
|
+
id?: number | string;
|
|
4
|
+
method?: string;
|
|
5
|
+
params?: any;
|
|
6
|
+
result?: any;
|
|
7
|
+
error?: any;
|
|
8
|
+
};
|
|
9
|
+
/** Throw from an onRequest handler to answer with a specific JSON-RPC error code. */
|
|
10
|
+
export declare class RpcError extends Error {
|
|
11
|
+
readonly code: number;
|
|
12
|
+
constructor(code: number, message: string);
|
|
13
|
+
}
|
|
14
|
+
export interface StdioRpcOptions {
|
|
15
|
+
command: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class StdioRpcClient {
|
|
22
|
+
private readonly opts;
|
|
23
|
+
private proc;
|
|
24
|
+
private nextId;
|
|
25
|
+
private readonly pending;
|
|
26
|
+
private notifyCb?;
|
|
27
|
+
private requestCb?;
|
|
28
|
+
private readonly stderrTail;
|
|
29
|
+
private readonly label;
|
|
30
|
+
constructor(opts: StdioRpcOptions);
|
|
31
|
+
onNotification(cb: (method: string, params: any) => void): void;
|
|
32
|
+
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
33
|
+
onRequest(cb: (method: string, params: any, id: number | string) => any | Promise<any>): void;
|
|
34
|
+
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
35
|
+
stderrText(): string;
|
|
36
|
+
start(): boolean;
|
|
37
|
+
private onLine;
|
|
38
|
+
private handleRequest;
|
|
39
|
+
request(method: string, params?: any, timeoutMs?: number): Promise<RpcMsg>;
|
|
40
|
+
notify(method: string, params?: any): void;
|
|
41
|
+
private respond;
|
|
42
|
+
private respondError;
|
|
43
|
+
private write;
|
|
44
|
+
kill(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
/** Throw from an onRequest handler to answer with a specific JSON-RPC error code. */
|
|
4
|
+
export class RpcError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const STDERR_TAIL_LINES = 20;
|
|
12
|
+
export class StdioRpcClient {
|
|
13
|
+
opts;
|
|
14
|
+
proc = null;
|
|
15
|
+
nextId = 1;
|
|
16
|
+
pending = new Map();
|
|
17
|
+
notifyCb;
|
|
18
|
+
requestCb;
|
|
19
|
+
stderrTail = [];
|
|
20
|
+
label;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.opts = opts;
|
|
23
|
+
this.label = opts.label || opts.command;
|
|
24
|
+
}
|
|
25
|
+
onNotification(cb) { this.notifyCb = cb; }
|
|
26
|
+
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
27
|
+
onRequest(cb) { this.requestCb = cb; }
|
|
28
|
+
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
29
|
+
stderrText() { return this.stderrTail.join('\n'); }
|
|
30
|
+
start() {
|
|
31
|
+
try {
|
|
32
|
+
this.proc = spawn(this.opts.command, this.opts.args, {
|
|
33
|
+
cwd: this.opts.cwd,
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
env: this.opts.env ? { ...process.env, ...this.opts.env } : process.env,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const rl = createInterface({ input: this.proc.stdout, crlfDelay: Infinity });
|
|
42
|
+
rl.on('line', (line) => this.onLine(line));
|
|
43
|
+
// Always drain stderr (an unread pipe backpressures the child) and keep a tail for errors.
|
|
44
|
+
this.proc.stderr.on('data', (chunk) => {
|
|
45
|
+
for (const ln of chunk.toString('utf8').split('\n')) {
|
|
46
|
+
const t = ln.trim();
|
|
47
|
+
if (!t)
|
|
48
|
+
continue;
|
|
49
|
+
this.stderrTail.push(t.slice(0, 240));
|
|
50
|
+
if (this.stderrTail.length > STDERR_TAIL_LINES)
|
|
51
|
+
this.stderrTail.shift();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
this.proc.on('close', () => {
|
|
55
|
+
for (const cb of this.pending.values())
|
|
56
|
+
cb({ error: { message: `${this.label} exited` } });
|
|
57
|
+
this.pending.clear();
|
|
58
|
+
});
|
|
59
|
+
this.proc.on('error', () => { });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
onLine(line) {
|
|
63
|
+
const t = line.trim();
|
|
64
|
+
if (!t)
|
|
65
|
+
return;
|
|
66
|
+
let m;
|
|
67
|
+
try {
|
|
68
|
+
m = JSON.parse(t);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return;
|
|
72
|
+
} // non-JSON stdout noise
|
|
73
|
+
if (m.method && m.id != null) {
|
|
74
|
+
void this.handleRequest(m);
|
|
75
|
+
return;
|
|
76
|
+
} // peer -> client request
|
|
77
|
+
if (m.id != null) { // response to one of our requests
|
|
78
|
+
const cb = this.pending.get(m.id);
|
|
79
|
+
if (cb) {
|
|
80
|
+
this.pending.delete(m.id);
|
|
81
|
+
cb(m);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (m.method)
|
|
86
|
+
this.notifyCb?.(m.method, m.params ?? {}); // notification
|
|
87
|
+
}
|
|
88
|
+
async handleRequest(m) {
|
|
89
|
+
const id = m.id;
|
|
90
|
+
if (!this.requestCb) {
|
|
91
|
+
this.respondError(id, -32601, `Method not implemented: ${m.method}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const result = await this.requestCb(m.method, m.params ?? {}, id);
|
|
96
|
+
this.respond(id, result ?? null);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
const code = e instanceof RpcError ? e.code : -32603;
|
|
100
|
+
this.respondError(id, code, e?.message || 'handler error');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
request(method, params, timeoutMs = 60_000) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
if (!this.proc || this.proc.killed) {
|
|
106
|
+
resolve({ error: { message: 'not connected' } });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const id = this.nextId++;
|
|
110
|
+
const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `${this.label} '${method}' timed out` } }); }, timeoutMs);
|
|
111
|
+
this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
|
|
112
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
|
|
116
|
+
respond(id, result) { this.write({ jsonrpc: '2.0', id, result }); }
|
|
117
|
+
respondError(id, code, message) { this.write({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
118
|
+
write(msg) {
|
|
119
|
+
if (!this.proc || this.proc.killed)
|
|
120
|
+
return;
|
|
121
|
+
try {
|
|
122
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
123
|
+
}
|
|
124
|
+
catch { /* stream closed */ }
|
|
125
|
+
}
|
|
126
|
+
kill() { try {
|
|
127
|
+
this.proc?.kill('SIGTERM');
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ } this.proc = null; }
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
/** Stateful newline splitter for a child process' stdout: feed chunks, get complete lines. */
|
|
3
|
+
export declare function createLineBuffer(): (chunk: Buffer | string) => string[];
|
|
4
|
+
/** Parse one ndjson line; undefined for blank lines / non-JSON noise. */
|
|
5
|
+
export declare function parseJsonLine(line: string): any | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` when the signal aborts (immediately if it already has). Returns an
|
|
8
|
+
* unsubscribe for drivers that must detach the handler when the turn settles.
|
|
9
|
+
*/
|
|
10
|
+
export declare function wireAbort(signal: AbortSignal, fn: () => void): () => void;
|
|
11
|
+
/** SIGTERM a child, swallowing the already-dead race. */
|
|
12
|
+
export declare function sigterm(proc: ChildProcess | null | undefined): void;
|
|
13
|
+
/** Mime type when the file is an inlineable image, else null. */
|
|
14
|
+
export declare function imageMimeForFile(filePath: string): string | null;
|
|
15
|
+
/** The text note substituted for a non-image attachment. */
|
|
16
|
+
export declare function attachedFileNote(filePath: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Context-window occupancy as a display percent (one decimal, capped at 99.9).
|
|
19
|
+
* Pass `used` as null when the caller wants "no data" rather than 0%.
|
|
20
|
+
*/
|
|
21
|
+
export declare function contextPercent(used: number | null | undefined, window: number | null | undefined): number | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
// Driver-internal helpers shared by the concrete drivers (claude/codex/gemini/acp).
|
|
3
|
+
// NOT part of the public API — nothing here is re-exported by any barrel. Each helper
|
|
4
|
+
// exists because the same code appeared verbatim in 3+ drivers.
|
|
5
|
+
/** Stateful newline splitter for a child process' stdout: feed chunks, get complete lines. */
|
|
6
|
+
export function createLineBuffer() {
|
|
7
|
+
let buf = '';
|
|
8
|
+
return (chunk) => {
|
|
9
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
10
|
+
const lines = buf.split('\n');
|
|
11
|
+
buf = lines.pop() || '';
|
|
12
|
+
return lines;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Parse one ndjson line; undefined for blank lines / non-JSON noise. */
|
|
16
|
+
export function parseJsonLine(line) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(trimmed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Run `fn` when the signal aborts (immediately if it already has). Returns an
|
|
29
|
+
* unsubscribe for drivers that must detach the handler when the turn settles.
|
|
30
|
+
*/
|
|
31
|
+
export function wireAbort(signal, fn) {
|
|
32
|
+
if (signal.aborted) {
|
|
33
|
+
fn();
|
|
34
|
+
return () => { };
|
|
35
|
+
}
|
|
36
|
+
signal.addEventListener('abort', fn, { once: true });
|
|
37
|
+
return () => signal.removeEventListener('abort', fn);
|
|
38
|
+
}
|
|
39
|
+
/** SIGTERM a child, swallowing the already-dead race. */
|
|
40
|
+
export function sigterm(proc) {
|
|
41
|
+
try {
|
|
42
|
+
proc?.kill('SIGTERM');
|
|
43
|
+
}
|
|
44
|
+
catch { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
// Attachment vocabulary: every driver inlines the same image formats (the Anthropic
|
|
47
|
+
// vision set, which the others accept too) and notes non-image files the same way.
|
|
48
|
+
const IMAGE_MIME_BY_EXT = {
|
|
49
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
50
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
51
|
+
};
|
|
52
|
+
/** Mime type when the file is an inlineable image, else null. */
|
|
53
|
+
export function imageMimeForFile(filePath) {
|
|
54
|
+
return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
|
|
55
|
+
}
|
|
56
|
+
/** The text note substituted for a non-image attachment. */
|
|
57
|
+
export function attachedFileNote(filePath) {
|
|
58
|
+
return `[Attached file: ${filePath}]`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Context-window occupancy as a display percent (one decimal, capped at 99.9).
|
|
62
|
+
* Pass `used` as null when the caller wants "no data" rather than 0%.
|
|
63
|
+
*/
|
|
64
|
+
export function contextPercent(used, window) {
|
|
65
|
+
return window && used != null ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
|
|
66
|
+
}
|
|
@@ -9,13 +9,14 @@ export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, To
|
|
|
9
9
|
export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, SpawnContribution, } from './contracts/surface.js';
|
|
10
10
|
export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
|
|
11
11
|
export { EchoDriver } from './drivers/echo.js';
|
|
12
|
-
export { ClaudeDriver
|
|
12
|
+
export { ClaudeDriver } from './drivers/claude.js';
|
|
13
13
|
export { CodexDriver } from './drivers/codex.js';
|
|
14
14
|
export { GeminiDriver } from './drivers/gemini.js';
|
|
15
15
|
export { AcpDriver, type AcpDriverConfig } from './drivers/acp.js';
|
|
16
16
|
export { HermesDriver } from './drivers/hermes.js';
|
|
17
17
|
export { WebSurface, type WebSurfaceOptions } from './surfaces/web.js';
|
|
18
18
|
export { CliSurface } from './surfaces/cli.js';
|
|
19
|
+
export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, type DiscoverOptions, } from './drivers/native.js';
|
|
19
20
|
export * from './workspace/index.js';
|
|
20
21
|
export * from './accounts.js';
|
|
21
22
|
export * from './protocol/index.js';
|