@pikiloom/kernel 0.1.5 → 0.2.2
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 +35 -0
- package/dist/accounts.d.ts +6 -0
- package/dist/accounts.js +29 -0
- package/dist/contracts/driver.d.ts +15 -0
- package/dist/contracts/ports.d.ts +2 -0
- package/dist/drivers/acp.d.ts +24 -0
- package/dist/drivers/acp.js +477 -0
- package/dist/drivers/claude.d.ts +5 -1
- package/dist/drivers/claude.js +4 -0
- package/dist/drivers/codex.d.ts +5 -1
- package/dist/drivers/codex.js +24 -12
- package/dist/drivers/gemini.d.ts +5 -1
- package/dist/drivers/gemini.js +4 -0
- package/dist/drivers/hermes.d.ts +3 -12
- package/dist/drivers/hermes.js +8 -191
- package/dist/drivers/index.d.ts +1 -0
- package/dist/drivers/index.js +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -0
- package/dist/ports/defaults.js +7 -1
- package/dist/runtime/loom.d.ts +10 -0
- package/dist/runtime/loom.js +18 -2
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +7 -0
- package/dist/workspace/mcp.d.ts +44 -0
- package/dist/workspace/mcp.js +82 -0
- package/dist/workspace/native.d.ts +14 -0
- package/dist/workspace/native.js +340 -0
- package/dist/workspace/paths.d.ts +33 -0
- package/dist/workspace/paths.js +40 -0
- package/dist/workspace/sessions.d.ts +49 -0
- package/dist/workspace/sessions.js +130 -0
- package/dist/workspace/skills.d.ts +44 -0
- package/dist/workspace/skills.js +173 -0
- package/llms.txt +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,6 +275,40 @@ plugins are the composable per-capability layer on top.)
|
|
|
275
275
|
|
|
276
276
|
---
|
|
277
277
|
|
|
278
|
+
## Workspace: unified directory + session / skill / mcp management
|
|
279
|
+
|
|
280
|
+
One explicitly-configurable **top-level directory** (`createLoom({ stateDirName })`, default
|
|
281
|
+
`'pikiloom'` → `.pikiloom`) gives a consuming app the same "everything under one folder" model
|
|
282
|
+
pikiloom uses, resolved in two scopes — global (`~/.pikiloom`) and per-workspace
|
|
283
|
+
(`<workdir>/.pikiloom`). It's exposed off the `Loom`:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const loom = createLoom({ drivers: [new ClaudeDriver(), new CodexDriver()], stateDirName: 'pikiloom' });
|
|
287
|
+
|
|
288
|
+
// Unified, searchable session list — the kernel's MANAGED sessions (scoped per workspace by the
|
|
289
|
+
// cwd they ran in) MERGED with each agent's OWN native sessions (claude/codex read their on-disk
|
|
290
|
+
// transcripts). Global view, per-workspace view, and search — all kernel-owned:
|
|
291
|
+
await loom.sessions.list({ scope: 'workspace', workdir }); // this folder (managed + native)
|
|
292
|
+
await loom.sessions.list({ scope: 'global' }); // every workspace's managed sessions
|
|
293
|
+
await loom.sessions.search({ query: 'deploy', workdir });
|
|
294
|
+
|
|
295
|
+
// Skills registry: one canonical dir, symlinked into every agent's skills dir.
|
|
296
|
+
loom.skills.ensureLinks('workspace', workdir); // <wd>/.claude/skills + .agents/skills → <wd>/.pikiloom/skills
|
|
297
|
+
loom.skills.list({ workdir }); // installed skills (workspace + global)
|
|
298
|
+
await loom.skills.search('pdf'); // installable skills (npm)
|
|
299
|
+
|
|
300
|
+
// MCP catalog + discovery (enabling a server stays on the Plugin.tools()/ToolProvider seam):
|
|
301
|
+
loom.mcp.recommended(); // curated catalog
|
|
302
|
+
await loom.mcp.search('postgres'); // MCP registry → npm
|
|
303
|
+
|
|
304
|
+
loom.paths.skillsDir('global'); // ~/.pikiloom/skills, etc.
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
A driver opts into native discovery by implementing
|
|
308
|
+
`listNativeSessions?({ workdir, limit }): NativeSessionInfo[]` (Claude/Codex/Gemini do; the pure
|
|
309
|
+
readers are also exported as `discover{Claude,Codex,Gemini}NativeSessions`). All of this is
|
|
310
|
+
node-builtins-only and additive — every existing port/default is unchanged.
|
|
311
|
+
|
|
278
312
|
## Exports
|
|
279
313
|
|
|
280
314
|
Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel/drivers`,
|
|
@@ -284,6 +318,7 @@ Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel
|
|
|
284
318
|
- Drivers: `EchoDriver`, `ClaudeDriver`, `CodexDriver`, `GeminiDriver`, `HermesDriver`
|
|
285
319
|
- Surfaces: `WebSurface`, `CliSurface`
|
|
286
320
|
- Ports/defaults: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`, `DeferToTerminalInteractionHandler`, `NoopCatalog`, `defaultBaseDir`
|
|
321
|
+
- Workspace: `resolveLoomPaths`, `SessionsManager`, `SkillsManager`, `McpRegistry`, `ensureDirSymlink`, `discover{Claude,Codex,Gemini}NativeSessions` + types `LoomPaths`, `LoomScope`, `ManagedSessionInfo`, `NativeSessionInfo`, `SkillInfo`, `McpCatalogEntry`
|
|
287
322
|
- Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, all wire/`Client*`/`Server*` message types
|
|
288
323
|
- Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
|
|
289
324
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Whether this agent's local accounts are switched by injecting a token. */
|
|
2
|
+
export declare function accountTokenSupported(agent: string): boolean;
|
|
3
|
+
/** The env-var name that overrides this agent's subscription identity, or null. */
|
|
4
|
+
export declare function accountTokenEnvVar(agent: string): string | null;
|
|
5
|
+
/** Env to merge at spawn so the agent runs under the given account token. */
|
|
6
|
+
export declare function accountTokenEnv(agent: string, token: string): Record<string, string>;
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Multi-account capability (token form): switch between several local subscription accounts
|
|
2
|
+
// of a coding-agent CLI by injecting a long-lived auth token at spawn time. The CLI keeps
|
|
3
|
+
// using its normal single config home (~/.claude etc.) — only the *identity* changes.
|
|
4
|
+
//
|
|
5
|
+
// claude / claude-tui -> CLAUDE_CODE_OAUTH_TOKEN=<token from `claude setup-token`>
|
|
6
|
+
//
|
|
7
|
+
// This replaces the earlier per-account config-directory approach: on macOS claude stores
|
|
8
|
+
// its credential in one shared OS-keychain item (not per CLAUDE_CONFIG_DIR), so the token is
|
|
9
|
+
// what actually isolates an account. codex has no equivalent token, so it is not supported
|
|
10
|
+
// here yet (single account only).
|
|
11
|
+
//
|
|
12
|
+
// Dependency-free (no imports) so any @pikiloom/kernel consumer inherits account switching.
|
|
13
|
+
const ACCOUNT_TOKEN_ENV = {
|
|
14
|
+
claude: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
15
|
+
'claude-tui': 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
16
|
+
};
|
|
17
|
+
/** Whether this agent's local accounts are switched by injecting a token. */
|
|
18
|
+
export function accountTokenSupported(agent) {
|
|
19
|
+
return Object.prototype.hasOwnProperty.call(ACCOUNT_TOKEN_ENV, agent);
|
|
20
|
+
}
|
|
21
|
+
/** The env-var name that overrides this agent's subscription identity, or null. */
|
|
22
|
+
export function accountTokenEnvVar(agent) {
|
|
23
|
+
return ACCOUNT_TOKEN_ENV[agent] ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** Env to merge at spawn so the agent runs under the given account token. */
|
|
26
|
+
export function accountTokenEnv(agent, token) {
|
|
27
|
+
const key = accountTokenEnvVar(agent);
|
|
28
|
+
return key && token ? { [key]: token } : {};
|
|
29
|
+
}
|
|
@@ -82,6 +82,17 @@ export interface TuiSpec {
|
|
|
82
82
|
cwd: string;
|
|
83
83
|
env?: Record<string, string>;
|
|
84
84
|
}
|
|
85
|
+
export interface NativeSessionInfo {
|
|
86
|
+
sessionId: string;
|
|
87
|
+
title: string | null;
|
|
88
|
+
preview: string | null;
|
|
89
|
+
cwd: string | null;
|
|
90
|
+
model: string | null;
|
|
91
|
+
createdAt: string | null;
|
|
92
|
+
updatedAt: string | null;
|
|
93
|
+
running: boolean;
|
|
94
|
+
messageCount?: number | null;
|
|
95
|
+
}
|
|
85
96
|
export interface AgentDriver {
|
|
86
97
|
readonly id: string;
|
|
87
98
|
readonly capabilities?: {
|
|
@@ -92,4 +103,8 @@ export interface AgentDriver {
|
|
|
92
103
|
};
|
|
93
104
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
94
105
|
tui?(input: TuiInput): TuiSpec;
|
|
106
|
+
listNativeSessions?(opts: {
|
|
107
|
+
workdir: string;
|
|
108
|
+
limit?: number;
|
|
109
|
+
}): NativeSessionInfo[] | Promise<NativeSessionInfo[]>;
|
|
95
110
|
}
|
|
@@ -5,9 +5,11 @@ export interface CoreSessionRecord {
|
|
|
5
5
|
sessionId: string;
|
|
6
6
|
nativeSessionId?: string | null;
|
|
7
7
|
workspacePath: string;
|
|
8
|
+
workdir?: string | null;
|
|
8
9
|
createdAt: string;
|
|
9
10
|
updatedAt: string;
|
|
10
11
|
title?: string | null;
|
|
12
|
+
preview?: string | null;
|
|
11
13
|
model?: string | null;
|
|
12
14
|
effort?: string | null;
|
|
13
15
|
runState?: 'running' | 'completed' | 'incomplete';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, McpServerSpec } from '../contracts/driver.js';
|
|
2
|
+
export interface AcpDriverConfig {
|
|
3
|
+
id: string;
|
|
4
|
+
command: string;
|
|
5
|
+
args?: string[];
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
fsAccess?: boolean;
|
|
8
|
+
permissionFallback?: 'allow' | 'reject' | 'cancel';
|
|
9
|
+
protocolVersion?: number;
|
|
10
|
+
promptTimeoutMs?: number;
|
|
11
|
+
capabilities?: AgentDriver['capabilities'];
|
|
12
|
+
}
|
|
13
|
+
export declare function toAcpMcpServers(servers?: McpServerSpec[]): any[];
|
|
14
|
+
export declare function buildAcpPromptBlocks(prompt: string, attachments: string[]): any[];
|
|
15
|
+
export declare function applyAcpUpdate(update: any, s: any, tools: Set<string>, emit: (e: DriverEvent) => void): void;
|
|
16
|
+
export declare class AcpDriver implements AgentDriver {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly capabilities: NonNullable<AgentDriver['capabilities']>;
|
|
19
|
+
protected readonly cfg: Required<Omit<AcpDriverConfig, 'capabilities' | 'env'>> & Pick<AcpDriverConfig, 'env' | 'capabilities'>;
|
|
20
|
+
constructor(config: AcpDriverConfig);
|
|
21
|
+
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
22
|
+
private resolvePermission;
|
|
23
|
+
private startupError;
|
|
24
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, extname } from 'node:path';
|
|
5
|
+
class AcpRpcError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// ── ACP JSON-RPC client over a child process' stdio (ndjson framing) ───────────
|
|
13
|
+
class AcpClient {
|
|
14
|
+
bin;
|
|
15
|
+
args;
|
|
16
|
+
env;
|
|
17
|
+
cwd;
|
|
18
|
+
proc = null;
|
|
19
|
+
nextId = 1;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
notifyCb;
|
|
22
|
+
requestCb;
|
|
23
|
+
stderrTail = [];
|
|
24
|
+
constructor(bin, args, env, cwd) {
|
|
25
|
+
this.bin = bin;
|
|
26
|
+
this.args = args;
|
|
27
|
+
this.env = env;
|
|
28
|
+
this.cwd = cwd;
|
|
29
|
+
}
|
|
30
|
+
onNotification(cb) { this.notifyCb = cb; }
|
|
31
|
+
onRequest(cb) { this.requestCb = cb; }
|
|
32
|
+
stderrText() { return this.stderrTail.join('\n'); }
|
|
33
|
+
start() {
|
|
34
|
+
try {
|
|
35
|
+
this.proc = spawn(this.bin, this.args, {
|
|
36
|
+
cwd: this.cwd,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
env: this.env ? { ...process.env, ...this.env } : process.env,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const rl = createInterface({ input: this.proc.stdout, crlfDelay: Infinity });
|
|
45
|
+
rl.on('line', (line) => this.onLine(line));
|
|
46
|
+
this.proc.stderr.on('data', (chunk) => {
|
|
47
|
+
for (const ln of chunk.toString('utf8').split('\n')) {
|
|
48
|
+
const t = ln.trim();
|
|
49
|
+
if (!t)
|
|
50
|
+
continue;
|
|
51
|
+
this.stderrTail.push(t.slice(0, 240));
|
|
52
|
+
if (this.stderrTail.length > 20)
|
|
53
|
+
this.stderrTail.shift();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
this.proc.on('close', () => { for (const cb of this.pending.values())
|
|
57
|
+
cb({ error: { message: 'acp process exited' } }); this.pending.clear(); });
|
|
58
|
+
this.proc.on('error', () => { });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
onLine(line) {
|
|
62
|
+
const t = line.trim();
|
|
63
|
+
if (!t)
|
|
64
|
+
return;
|
|
65
|
+
let m;
|
|
66
|
+
try {
|
|
67
|
+
m = JSON.parse(t);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
} // non-JSON stdout noise
|
|
72
|
+
if (m.method && m.id != null) {
|
|
73
|
+
void this.handleRequest(m);
|
|
74
|
+
return;
|
|
75
|
+
} // agent -> client request
|
|
76
|
+
if (m.id != null) { // response to one of our requests
|
|
77
|
+
const cb = this.pending.get(m.id);
|
|
78
|
+
if (cb) {
|
|
79
|
+
this.pending.delete(m.id);
|
|
80
|
+
cb(m);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (m.method)
|
|
85
|
+
this.notifyCb?.(m.method, m.params ?? {}); // notification (session/update)
|
|
86
|
+
}
|
|
87
|
+
async handleRequest(m) {
|
|
88
|
+
const id = m.id;
|
|
89
|
+
if (!this.requestCb) {
|
|
90
|
+
this.respondError(id, -32601, `Method not implemented: ${m.method}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const result = await this.requestCb(m.method, m.params ?? {});
|
|
95
|
+
this.respond(id, result ?? null);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const code = e instanceof AcpRpcError ? e.code : -32603;
|
|
99
|
+
this.respondError(id, code, e?.message || 'handler error');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
request(method, params, timeoutMs = 60_000) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
if (!this.proc || this.proc.killed) {
|
|
105
|
+
resolve({ error: { message: 'not connected' } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const id = this.nextId++;
|
|
109
|
+
const timer = setTimeout(() => { this.pending.delete(id); resolve({ error: { message: `ACP '${method}' timed out` } }); }, timeoutMs);
|
|
110
|
+
this.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
|
|
111
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
|
|
115
|
+
respond(id, result) { this.write({ jsonrpc: '2.0', id, result }); }
|
|
116
|
+
respondError(id, code, message) { this.write({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
117
|
+
write(msg) {
|
|
118
|
+
if (!this.proc || this.proc.killed)
|
|
119
|
+
return;
|
|
120
|
+
try {
|
|
121
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
122
|
+
}
|
|
123
|
+
catch { /* stream closed */ }
|
|
124
|
+
}
|
|
125
|
+
kill() { try {
|
|
126
|
+
this.proc?.kill('SIGTERM');
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ } this.proc = null; }
|
|
129
|
+
}
|
|
130
|
+
// ── pikiloom McpServerSpec[] -> ACP mcpServers[] ───────────────────────────────
|
|
131
|
+
export function toAcpMcpServers(servers) {
|
|
132
|
+
if (!servers || !servers.length)
|
|
133
|
+
return [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const s of servers) {
|
|
136
|
+
if (!s || !s.name)
|
|
137
|
+
continue;
|
|
138
|
+
if (s.type === 'http' && s.url) {
|
|
139
|
+
out.push({ type: 'http', name: s.name, url: s.url, headers: Object.entries(s.headers || {}).map(([name, value]) => ({ name, value: String(value) })) });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (s.command) {
|
|
143
|
+
out.push({ name: s.name, command: s.command, args: Array.isArray(s.args) ? s.args.map(String) : [], env: Object.entries(s.env || {}).map(([name, value]) => ({ name, value: String(value) })) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
|
149
|
+
function mimeForExt(ext) {
|
|
150
|
+
if (ext === '.jpg' || ext === '.jpeg')
|
|
151
|
+
return 'image/jpeg';
|
|
152
|
+
if (ext === '.gif')
|
|
153
|
+
return 'image/gif';
|
|
154
|
+
if (ext === '.webp')
|
|
155
|
+
return 'image/webp';
|
|
156
|
+
return 'image/png';
|
|
157
|
+
}
|
|
158
|
+
// ACP prompt content blocks: text + inline base64 images; other attachments noted as text.
|
|
159
|
+
export function buildAcpPromptBlocks(prompt, attachments) {
|
|
160
|
+
const blocks = [];
|
|
161
|
+
for (const f of attachments) {
|
|
162
|
+
const ext = extname(f).toLowerCase();
|
|
163
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
164
|
+
try {
|
|
165
|
+
blocks.push({ type: 'image', mimeType: mimeForExt(ext), data: readFileSync(f).toString('base64') });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
catch { /* fall through to a text note */ }
|
|
169
|
+
}
|
|
170
|
+
blocks.push({ type: 'text', text: `[Attached file: ${f}]` });
|
|
171
|
+
}
|
|
172
|
+
blocks.push({ type: 'text', text: prompt });
|
|
173
|
+
return blocks;
|
|
174
|
+
}
|
|
175
|
+
function acpContentText(content) {
|
|
176
|
+
if (content == null)
|
|
177
|
+
return '';
|
|
178
|
+
if (typeof content === 'string')
|
|
179
|
+
return content;
|
|
180
|
+
if (Array.isArray(content))
|
|
181
|
+
return content.map(acpContentText).filter(Boolean).join('\n');
|
|
182
|
+
if (typeof content === 'object') {
|
|
183
|
+
if (typeof content.text === 'string')
|
|
184
|
+
return content.text;
|
|
185
|
+
if (content.type === 'content' && content.content)
|
|
186
|
+
return acpContentText(content.content);
|
|
187
|
+
if (content.type === 'diff' && typeof content.path === 'string')
|
|
188
|
+
return `[diff ${content.path}]`;
|
|
189
|
+
}
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
function acpToolStatus(status, dflt) {
|
|
193
|
+
if (status === 'completed')
|
|
194
|
+
return 'done';
|
|
195
|
+
if (status === 'failed')
|
|
196
|
+
return 'failed';
|
|
197
|
+
if (status === 'pending' || status === 'in_progress')
|
|
198
|
+
return 'running';
|
|
199
|
+
return dflt;
|
|
200
|
+
}
|
|
201
|
+
function safeJson(v) {
|
|
202
|
+
try {
|
|
203
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function truncate(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
|
210
|
+
// session/update -> normalized DriverEvents. Exported (and aliased as applyHermesUpdate)
|
|
211
|
+
// so it can be unit-tested against the raw ACP wire without spawning a process.
|
|
212
|
+
export function applyAcpUpdate(update, s, tools, emit) {
|
|
213
|
+
if (!update)
|
|
214
|
+
return;
|
|
215
|
+
switch (update.sessionUpdate) {
|
|
216
|
+
case 'agent_message_chunk': {
|
|
217
|
+
const t = acpContentText(update.content);
|
|
218
|
+
if (t) {
|
|
219
|
+
s.text = (s.text || '') + t;
|
|
220
|
+
emit({ type: 'text', delta: t });
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case 'agent_thought_chunk': {
|
|
225
|
+
const t = acpContentText(update.content);
|
|
226
|
+
if (t) {
|
|
227
|
+
s.reasoning = (s.reasoning || '') + t;
|
|
228
|
+
emit({ type: 'reasoning', delta: t });
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
case 'tool_call': {
|
|
233
|
+
const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
|
|
234
|
+
if (!id)
|
|
235
|
+
return;
|
|
236
|
+
const name = (typeof update.title === 'string' && update.title.trim()) || (typeof update.kind === 'string' && update.kind) || 'tool';
|
|
237
|
+
tools.add(id);
|
|
238
|
+
emit({ type: 'tool', call: { id, name, summary: name, input: update.rawInput != null ? truncate(safeJson(update.rawInput) || '', 2000) : null, status: acpToolStatus(update.status, 'running') } });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
case 'tool_call_update': {
|
|
242
|
+
const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
|
|
243
|
+
if (!id)
|
|
244
|
+
return;
|
|
245
|
+
const name = (typeof update.title === 'string' && update.title.trim()) || 'tool';
|
|
246
|
+
const status = acpToolStatus(update.status, 'running');
|
|
247
|
+
const result = update.content != null ? truncate(acpContentText(update.content), 2000) : null;
|
|
248
|
+
emit({ type: 'tool', call: { id, name, summary: name, result: result || null, status } });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
case 'plan': {
|
|
252
|
+
const entries = Array.isArray(update.entries) ? update.entries : [];
|
|
253
|
+
const steps = entries
|
|
254
|
+
.map((e) => ({ text: String(e?.content ?? '').trim(), status: e?.status === 'in_progress' ? 'inProgress' : e?.status === 'completed' ? 'completed' : 'pending' }))
|
|
255
|
+
.filter((st) => st.text);
|
|
256
|
+
if (steps.length)
|
|
257
|
+
emit({ type: 'plan', plan: { explanation: null, steps } });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
case 'usage_update': {
|
|
261
|
+
if (typeof update.size === 'number')
|
|
262
|
+
s.contextWindow = update.size;
|
|
263
|
+
if (typeof update.used === 'number')
|
|
264
|
+
s.contextUsed = update.used;
|
|
265
|
+
if (typeof update.used === 'number') {
|
|
266
|
+
const window = s.contextWindow;
|
|
267
|
+
const contextPercent = window && update.used > 0 ? Math.min(99.9, Math.round((update.used / window) * 1000) / 10) : null;
|
|
268
|
+
emit({ type: 'usage', usage: { inputTokens: null, outputTokens: null, cachedInputTokens: null, contextUsedTokens: update.used, contextPercent } });
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// session/request_permission params -> a normalized UniversalInteraction (kind 'permission').
|
|
275
|
+
// Each choice's `value` IS the ACP optionId, so the answer maps straight back to an outcome.
|
|
276
|
+
function permissionToInteraction(params, promptId) {
|
|
277
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
278
|
+
if (!options.length)
|
|
279
|
+
return null;
|
|
280
|
+
const tc = params?.toolCall || {};
|
|
281
|
+
const what = (typeof tc.title === 'string' && tc.title.trim()) || (typeof tc.kind === 'string' && tc.kind) || 'this action';
|
|
282
|
+
return {
|
|
283
|
+
promptId,
|
|
284
|
+
kind: 'permission',
|
|
285
|
+
title: 'Permission required',
|
|
286
|
+
hint: `The agent wants to: ${what}`,
|
|
287
|
+
questions: [{
|
|
288
|
+
id: 'choice',
|
|
289
|
+
header: 'Permission',
|
|
290
|
+
text: `Allow the agent to ${what}?`,
|
|
291
|
+
type: 'select',
|
|
292
|
+
choices: options.map((o) => ({ label: String(o?.name || o?.optionId || 'option'), value: String(o?.optionId || ''), description: String(o?.kind || '') })),
|
|
293
|
+
allowFreeform: false,
|
|
294
|
+
allowEmpty: true,
|
|
295
|
+
}],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function pickOptionId(answers, options) {
|
|
299
|
+
const vals = Object.values(answers || {}).flat().map(String);
|
|
300
|
+
for (const v of vals) {
|
|
301
|
+
const byId = options.find((o) => String(o?.optionId) === v);
|
|
302
|
+
if (byId)
|
|
303
|
+
return String(byId.optionId);
|
|
304
|
+
const byName = options.find((o) => String(o?.name) === v);
|
|
305
|
+
if (byName)
|
|
306
|
+
return String(byName.optionId);
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
function fallbackOptionId(options, fallback) {
|
|
311
|
+
if (fallback === 'cancel')
|
|
312
|
+
return null;
|
|
313
|
+
const order = fallback === 'allow' ? ['allow_once', 'allow_always', 'allow'] : ['reject_once', 'reject_always', 'reject'];
|
|
314
|
+
for (const k of order) {
|
|
315
|
+
const o = options.find((x) => String(x?.kind) === k);
|
|
316
|
+
if (o)
|
|
317
|
+
return String(o.optionId);
|
|
318
|
+
}
|
|
319
|
+
return fallback === 'allow' && options[0] ? String(options[0].optionId) : null;
|
|
320
|
+
}
|
|
321
|
+
function readAcpTextFile(params) {
|
|
322
|
+
const p = String(params?.path || '');
|
|
323
|
+
if (!p || !existsSync(p))
|
|
324
|
+
throw new AcpRpcError(-32602, `file not found: ${p}`);
|
|
325
|
+
let content = readFileSync(p, 'utf8');
|
|
326
|
+
const line = typeof params?.line === 'number' ? params.line : null;
|
|
327
|
+
const limit = typeof params?.limit === 'number' ? params.limit : null;
|
|
328
|
+
if (line != null || limit != null) {
|
|
329
|
+
const lines = content.split('\n');
|
|
330
|
+
const start = line != null ? Math.max(0, line - 1) : 0;
|
|
331
|
+
content = lines.slice(start, limit != null ? start + limit : undefined).join('\n');
|
|
332
|
+
}
|
|
333
|
+
return { content };
|
|
334
|
+
}
|
|
335
|
+
function writeAcpTextFile(params) {
|
|
336
|
+
const p = String(params?.path || '');
|
|
337
|
+
if (!p)
|
|
338
|
+
throw new AcpRpcError(-32602, 'path required');
|
|
339
|
+
try {
|
|
340
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
341
|
+
}
|
|
342
|
+
catch { /* parent may exist */ }
|
|
343
|
+
writeFileSync(p, String(params?.content ?? ''), 'utf8');
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
function acpUsage(s, raw) {
|
|
347
|
+
let input = null, output = null, cached = null;
|
|
348
|
+
if (raw && typeof raw === 'object') {
|
|
349
|
+
const n = (v) => (typeof v === 'number' ? v : null);
|
|
350
|
+
input = n(raw.inputTokens ?? raw.input_tokens);
|
|
351
|
+
output = n(raw.outputTokens ?? raw.output_tokens);
|
|
352
|
+
cached = n(raw.cachedReadTokens ?? raw.cached_read_tokens ?? raw.cachedInputTokens);
|
|
353
|
+
}
|
|
354
|
+
const window = s.contextWindow, used = s.contextUsed;
|
|
355
|
+
const contextPercent = window && used ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
|
|
356
|
+
return { inputTokens: input, outputTokens: output, cachedInputTokens: cached, contextUsedTokens: used, contextPercent, turnOutputTokens: output };
|
|
357
|
+
}
|
|
358
|
+
export class AcpDriver {
|
|
359
|
+
id;
|
|
360
|
+
capabilities;
|
|
361
|
+
cfg;
|
|
362
|
+
constructor(config) {
|
|
363
|
+
this.id = config.id;
|
|
364
|
+
this.cfg = {
|
|
365
|
+
id: config.id,
|
|
366
|
+
command: config.command,
|
|
367
|
+
args: config.args ?? ['acp'],
|
|
368
|
+
env: config.env,
|
|
369
|
+
fsAccess: config.fsAccess ?? true,
|
|
370
|
+
permissionFallback: config.permissionFallback ?? 'allow',
|
|
371
|
+
protocolVersion: config.protocolVersion ?? 1,
|
|
372
|
+
promptTimeoutMs: config.promptTimeoutMs ?? 7_200_000,
|
|
373
|
+
capabilities: config.capabilities,
|
|
374
|
+
};
|
|
375
|
+
this.capabilities = config.capabilities ?? { steer: false, interact: true, resume: true, tui: false };
|
|
376
|
+
}
|
|
377
|
+
async run(input, ctx) {
|
|
378
|
+
const env = { ...(this.cfg.env || {}), ...(input.env || {}) };
|
|
379
|
+
const client = new AcpClient(this.cfg.command, this.cfg.args, env, input.workdir);
|
|
380
|
+
const state = { text: '', reasoning: '', contextWindow: null, contextUsed: null };
|
|
381
|
+
const tools = new Set();
|
|
382
|
+
let sessionId = input.sessionId ?? null;
|
|
383
|
+
let permSeq = 0;
|
|
384
|
+
if (!client.start())
|
|
385
|
+
return { ok: false, text: '', error: `failed to start ${this.cfg.command}`, stopReason: 'error' };
|
|
386
|
+
const onAbort = () => { if (sessionId)
|
|
387
|
+
client.notify('session/cancel', { sessionId }); client.kill(); };
|
|
388
|
+
if (ctx.signal.aborted)
|
|
389
|
+
onAbort();
|
|
390
|
+
else
|
|
391
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
392
|
+
client.onNotification((method, params) => {
|
|
393
|
+
if (method === 'session/update')
|
|
394
|
+
applyAcpUpdate(params?.update ?? params, state, tools, ctx.emit);
|
|
395
|
+
});
|
|
396
|
+
client.onRequest(async (method, params) => {
|
|
397
|
+
switch (method) {
|
|
398
|
+
case 'session/request_permission': return this.resolvePermission(params, ctx, `${this.id}-perm-${++permSeq}`);
|
|
399
|
+
case 'fs/read_text_file':
|
|
400
|
+
if (!this.cfg.fsAccess)
|
|
401
|
+
throw new AcpRpcError(-32601, 'fs/read_text_file not supported');
|
|
402
|
+
return readAcpTextFile(params);
|
|
403
|
+
case 'fs/write_text_file':
|
|
404
|
+
if (!this.cfg.fsAccess)
|
|
405
|
+
throw new AcpRpcError(-32601, 'fs/write_text_file not supported');
|
|
406
|
+
return writeAcpTextFile(params);
|
|
407
|
+
default:
|
|
408
|
+
throw new AcpRpcError(-32601, `Method not implemented: ${method}`);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
const init = await client.request('initialize', {
|
|
413
|
+
protocolVersion: this.cfg.protocolVersion,
|
|
414
|
+
clientCapabilities: { fs: { readTextFile: !!this.cfg.fsAccess, writeTextFile: !!this.cfg.fsAccess }, terminal: false },
|
|
415
|
+
});
|
|
416
|
+
if (init.error)
|
|
417
|
+
return { ok: false, text: '', error: this.startupError(client, init.error.message || 'initialize failed'), stopReason: 'error' };
|
|
418
|
+
const mcpServers = toAcpMcpServers(input.extraMcpServers);
|
|
419
|
+
if (!sessionId) {
|
|
420
|
+
const ns = await client.request('session/new', { cwd: input.workdir, mcpServers });
|
|
421
|
+
if (ns.error)
|
|
422
|
+
return { ok: false, text: '', error: ns.error.message || 'session/new failed', stopReason: 'error' };
|
|
423
|
+
sessionId = ns.result?.sessionId ?? ns.result?.session_id ?? null;
|
|
424
|
+
if (sessionId)
|
|
425
|
+
ctx.emit({ type: 'session', sessionId });
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
await client.request('session/load', { sessionId, cwd: input.workdir, mcpServers }, 30_000).catch(() => ({}));
|
|
429
|
+
ctx.emit({ type: 'session', sessionId });
|
|
430
|
+
}
|
|
431
|
+
if (!sessionId)
|
|
432
|
+
return { ok: false, text: '', error: `${this.id} returned no session id`, stopReason: 'error' };
|
|
433
|
+
if (input.model)
|
|
434
|
+
await client.request('session/set_model', { sessionId, modelId: input.model }, 15_000).catch(() => ({}));
|
|
435
|
+
if (input.effort)
|
|
436
|
+
await client.request('session/set_mode', { sessionId, modeId: input.effort }, 15_000).catch(() => ({}));
|
|
437
|
+
const promptResp = await client.request('session/prompt', {
|
|
438
|
+
sessionId,
|
|
439
|
+
prompt: buildAcpPromptBlocks(input.prompt, input.attachments || []),
|
|
440
|
+
}, this.cfg.promptTimeoutMs);
|
|
441
|
+
const usage = acpUsage(state, promptResp.result?.usage);
|
|
442
|
+
if (ctx.signal.aborted)
|
|
443
|
+
return { ok: false, text: state.text, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId, usage };
|
|
444
|
+
if (promptResp.error)
|
|
445
|
+
return { ok: false, text: state.text, error: promptResp.error.message || 'session/prompt failed', stopReason: 'error', sessionId, usage };
|
|
446
|
+
const stopReason = promptResp.result?.stopReason ?? 'end_turn';
|
|
447
|
+
return { ok: true, text: state.text, reasoning: state.reasoning || undefined, error: null, stopReason, sessionId, usage };
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
ctx.signal.removeEventListener('abort', onAbort);
|
|
451
|
+
client.kill();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async resolvePermission(params, ctx, promptId) {
|
|
455
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
456
|
+
if (!options.length)
|
|
457
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
458
|
+
const interaction = permissionToInteraction(params, promptId);
|
|
459
|
+
let answers = {};
|
|
460
|
+
if (interaction) {
|
|
461
|
+
try {
|
|
462
|
+
answers = (await ctx.askUser(interaction)) || {};
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
answers = {};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (ctx.signal.aborted)
|
|
469
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
470
|
+
const picked = pickOptionId(answers, options) ?? fallbackOptionId(options, this.cfg.permissionFallback);
|
|
471
|
+
return picked ? { outcome: { outcome: 'selected', optionId: picked } } : { outcome: { outcome: 'cancelled' } };
|
|
472
|
+
}
|
|
473
|
+
startupError(client, base) {
|
|
474
|
+
const tail = client.stderrText().trim();
|
|
475
|
+
return tail ? `${base} — ${truncate(tail, 300)}` : base;
|
|
476
|
+
}
|
|
477
|
+
}
|
package/dist/drivers/claude.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec } from '../contracts/driver.js';
|
|
1
|
+
import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
|
|
2
2
|
import type { UniversalUsage, UniversalPlan } from '../protocol/index.js';
|
|
3
3
|
export declare class ClaudeDriver implements AgentDriver {
|
|
4
4
|
private readonly bin;
|
|
@@ -11,6 +11,10 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
11
11
|
};
|
|
12
12
|
constructor(bin?: string);
|
|
13
13
|
tui(input: TuiInput): TuiSpec;
|
|
14
|
+
listNativeSessions(opts: {
|
|
15
|
+
workdir: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}): NativeSessionInfo[];
|
|
14
18
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
15
19
|
private usage;
|
|
16
20
|
}
|
package/dist/drivers/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { discoverClaudeNativeSessions } from '../workspace/native.js';
|
|
2
3
|
// Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
|
|
3
4
|
// events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
|
|
4
5
|
// (system / stream_event{message_start,content_block_delta,message_delta} / assistant / result),
|
|
@@ -22,6 +23,9 @@ export class ClaudeDriver {
|
|
|
22
23
|
args.push(...input.extraArgs);
|
|
23
24
|
return { command: this.bin, args, cwd: input.workdir, env: input.env };
|
|
24
25
|
}
|
|
26
|
+
listNativeSessions(opts) {
|
|
27
|
+
return discoverClaudeNativeSessions(opts.workdir, { limit: opts.limit });
|
|
28
|
+
}
|
|
25
29
|
run(input, ctx) {
|
|
26
30
|
const steerable = !!input.steerable;
|
|
27
31
|
const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
|