@shipers-dev/multi 0.12.0 → 0.12.1
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/dist/index.js +7 -2
- package/package.json +7 -2
- package/src/acp-runner.ts +0 -274
- package/src/acpx-runner.ts +0 -177
- package/src/client.ts +0 -46
- package/src/detect.ts +0 -70
- package/src/index.ts +0 -1784
- package/src/materializer.ts +0 -166
- package/src/worktree.ts +0 -89
package/dist/index.js
CHANGED
|
@@ -16272,18 +16272,23 @@ import { join as join4, dirname as dirname3 } from "path";
|
|
|
16272
16272
|
// package.json
|
|
16273
16273
|
var package_default = {
|
|
16274
16274
|
name: "@shipers-dev/multi",
|
|
16275
|
-
version: "0.12.
|
|
16275
|
+
version: "0.12.1",
|
|
16276
16276
|
type: "module",
|
|
16277
16277
|
bin: {
|
|
16278
16278
|
"multi-agent": "./dist/index.js"
|
|
16279
16279
|
},
|
|
16280
|
+
files: [
|
|
16281
|
+
"dist"
|
|
16282
|
+
],
|
|
16280
16283
|
scripts: {
|
|
16281
16284
|
dev: "bun run src/index.ts",
|
|
16282
16285
|
build: "bun build src/index.ts --outdir=dist --target=bun"
|
|
16283
16286
|
},
|
|
16284
16287
|
dependencies: {
|
|
16285
16288
|
"@agentclientprotocol/sdk": "^0.20.0",
|
|
16286
|
-
"@agentclientprotocol/claude-agent-acp": "^0.31.0"
|
|
16289
|
+
"@agentclientprotocol/claude-agent-acp": "^0.31.0"
|
|
16290
|
+
},
|
|
16291
|
+
devDependencies: {
|
|
16287
16292
|
"@multi/lib": "workspace:*"
|
|
16288
16293
|
}
|
|
16289
16294
|
};
|
package/package.json
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipers-dev/multi",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"multi-agent": "./dist/index.js"
|
|
7
7
|
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
8
11
|
"scripts": {
|
|
9
12
|
"dev": "bun run src/index.ts",
|
|
10
13
|
"build": "bun build src/index.ts --outdir=dist --target=bun"
|
|
11
14
|
},
|
|
12
15
|
"dependencies": {
|
|
13
16
|
"@agentclientprotocol/sdk": "^0.20.0",
|
|
14
|
-
"@agentclientprotocol/claude-agent-acp": "^0.31.0"
|
|
17
|
+
"@agentclientprotocol/claude-agent-acp": "^0.31.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
15
20
|
"@multi/lib": "workspace:*"
|
|
16
21
|
}
|
|
17
22
|
}
|
package/src/acp-runner.ts
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
// ACP runner — speaks Agent Client Protocol to an agent adapter (claude-code-acp etc).
|
|
2
|
-
// Spawns the adapter as a subprocess over stdio, converts ACP events → our StreamEvent shape
|
|
3
|
-
// and handles client-side callbacks (requestPermission forwarded to server, fs read/write local).
|
|
4
|
-
|
|
5
|
-
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';
|
|
6
|
-
import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse } from '@agentclientprotocol/sdk';
|
|
7
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
8
|
-
import { dirname } from 'path';
|
|
9
|
-
import { apiClient } from './client';
|
|
10
|
-
|
|
11
|
-
function fmtErr(e: any): string {
|
|
12
|
-
if (e == null) return 'unknown error';
|
|
13
|
-
if (typeof e === 'string') return e;
|
|
14
|
-
if (e instanceof Error) return e.message;
|
|
15
|
-
if (typeof e === 'object') {
|
|
16
|
-
const inner = e.error ?? e.cause ?? e;
|
|
17
|
-
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
18
|
-
const code = inner?.code ?? e.code ?? e.status;
|
|
19
|
-
if (msg) return code ? `${msg} (code ${code})` : String(msg);
|
|
20
|
-
try { return JSON.stringify(e).slice(0, 500); } catch {}
|
|
21
|
-
}
|
|
22
|
-
return String(e);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
import type { CliStreamEmit } from '@multi/lib';
|
|
26
|
-
export type AcpEvent = CliStreamEmit;
|
|
27
|
-
|
|
28
|
-
export type AcpRunOpts = {
|
|
29
|
-
apiUrl: string;
|
|
30
|
-
issueId: string;
|
|
31
|
-
deviceId: string;
|
|
32
|
-
prompt: string;
|
|
33
|
-
cwd?: string;
|
|
34
|
-
sessionId?: string | null;
|
|
35
|
-
adapterBin: string | string[]; // command + args to spawn ACP agent
|
|
36
|
-
autonomy?: 'manual' | 'ask' | 'auto';
|
|
37
|
-
onEvent: (ev: AcpEvent) => void | Promise<void>;
|
|
38
|
-
onSession?: (sessionId: string) => void | Promise<void>;
|
|
39
|
-
onSpawn?: (child: any) => void;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; sessionId: string }> {
|
|
43
|
-
const cleanEnv: Record<string, string> = {};
|
|
44
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
45
|
-
if (v === undefined) continue;
|
|
46
|
-
if (k === 'CLAUDECODE' || k.startsWith('CLAUDE_CODE_')) continue;
|
|
47
|
-
cleanEnv[k] = v;
|
|
48
|
-
}
|
|
49
|
-
const argv = Array.isArray(opts.adapterBin) ? opts.adapterBin : [opts.adapterBin];
|
|
50
|
-
// Daemon has no TTY — default to bypass (claude's --dangerously-skip-permissions equivalent).
|
|
51
|
-
// Set MULTI_CLAUDE_SAFE=1 to restore interactive permission prompts.
|
|
52
|
-
const permMode = process.env.MULTI_CLAUDE_SAFE === '1' ? 'default' : 'bypassPermissions';
|
|
53
|
-
const child = Bun.spawn(argv, {
|
|
54
|
-
stdio: ['pipe', 'pipe', 'inherit'],
|
|
55
|
-
cwd: opts.cwd || process.cwd(),
|
|
56
|
-
env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode },
|
|
57
|
-
});
|
|
58
|
-
try { opts.onSpawn?.(child); } catch {}
|
|
59
|
-
|
|
60
|
-
// Adapter expects plain newline-delimited JSON on stdio. Wrap child streams as Web Streams.
|
|
61
|
-
const output = new WritableStream<Uint8Array>({
|
|
62
|
-
write(chunk) {
|
|
63
|
-
(child.stdin as any).write(chunk);
|
|
64
|
-
},
|
|
65
|
-
close() { try { (child.stdin as any).end?.(); } catch {} },
|
|
66
|
-
});
|
|
67
|
-
const input = child.stdout as ReadableStream<Uint8Array>;
|
|
68
|
-
const stream = ndJsonStream(output, input);
|
|
69
|
-
|
|
70
|
-
let activeSessionId: string | null = opts.sessionId || null;
|
|
71
|
-
let recording = false; // only forward events after prompt() starts
|
|
72
|
-
let chunkCount = 0; // assistant_text + tool_call chunks seen during prompt()
|
|
73
|
-
const alwaysAllow = new Set<string>(); // keys: toolName|kind that user said "always allow"
|
|
74
|
-
|
|
75
|
-
const client: Client = {
|
|
76
|
-
async sessionUpdate(params: SessionNotification): Promise<void> {
|
|
77
|
-
if (!recording) return; // drop replay chatter during loadSession
|
|
78
|
-
await handleSessionUpdate(params, opts);
|
|
79
|
-
},
|
|
80
|
-
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
|
81
|
-
return await handleRequestPermission(params, opts, alwaysAllow);
|
|
82
|
-
},
|
|
83
|
-
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
|
84
|
-
try {
|
|
85
|
-
const content = readFileSync(params.path, 'utf8');
|
|
86
|
-
const sliced = typeof params.line === 'number' && typeof params.limit === 'number'
|
|
87
|
-
? content.split('\n').slice(params.line, params.line + params.limit).join('\n')
|
|
88
|
-
: content;
|
|
89
|
-
return { content: sliced };
|
|
90
|
-
} catch (e) {
|
|
91
|
-
throw new Error(`readTextFile failed: ${fmtErr(e)}`);
|
|
92
|
-
}
|
|
93
|
-
},
|
|
94
|
-
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
|
95
|
-
try {
|
|
96
|
-
const dir = dirname(params.path);
|
|
97
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
98
|
-
writeFileSync(params.path, params.content, 'utf8');
|
|
99
|
-
return {};
|
|
100
|
-
} catch (e) {
|
|
101
|
-
throw new Error(`writeTextFile failed: ${fmtErr(e)}`);
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const conn = new ClientSideConnection(() => client, stream);
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await conn.initialize({
|
|
110
|
-
protocolVersion: 1,
|
|
111
|
-
clientCapabilities: {
|
|
112
|
-
fs: { readTextFile: true, writeTextFile: true },
|
|
113
|
-
terminal: false,
|
|
114
|
-
},
|
|
115
|
-
} as any);
|
|
116
|
-
|
|
117
|
-
if (!activeSessionId) {
|
|
118
|
-
const { sessionId } = await conn.newSession({
|
|
119
|
-
cwd: opts.cwd || process.cwd(),
|
|
120
|
-
mcpServers: [],
|
|
121
|
-
} as any);
|
|
122
|
-
activeSessionId = sessionId;
|
|
123
|
-
if (opts.onSession) await opts.onSession(sessionId);
|
|
124
|
-
await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${sessionId.slice(0, 8)} created` } });
|
|
125
|
-
} else {
|
|
126
|
-
try {
|
|
127
|
-
await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
|
|
128
|
-
await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
|
|
129
|
-
} catch (e) {
|
|
130
|
-
await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${fmtErr(e)}` } });
|
|
131
|
-
const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
|
|
132
|
-
activeSessionId = sessionId;
|
|
133
|
-
if (opts.onSession) await opts.onSession(sessionId);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
recording = true;
|
|
138
|
-
const runPrompt = async () => {
|
|
139
|
-
chunkCount = 0;
|
|
140
|
-
return await conn.prompt({
|
|
141
|
-
sessionId: activeSessionId!,
|
|
142
|
-
prompt: [{ type: 'text', text: opts.prompt }],
|
|
143
|
-
} as any);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
let res = await runPrompt();
|
|
147
|
-
let stopReason = (res as any).stopReason;
|
|
148
|
-
|
|
149
|
-
// Resumed session that returned zero chunks → session is likely stale in the adapter.
|
|
150
|
-
// Retry once with a fresh session so user gets an actual response.
|
|
151
|
-
if (chunkCount === 0 && opts.sessionId) {
|
|
152
|
-
await opts.onEvent({ event_type: 'progress', payload: { message: `resumed session produced no output (stopReason=${stopReason}); retrying with fresh session` } });
|
|
153
|
-
try {
|
|
154
|
-
const fresh = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
|
|
155
|
-
activeSessionId = fresh.sessionId;
|
|
156
|
-
if (opts.onSession) await opts.onSession(fresh.sessionId);
|
|
157
|
-
res = await runPrompt();
|
|
158
|
-
stopReason = (res as any).stopReason;
|
|
159
|
-
} catch (e) {
|
|
160
|
-
await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${fmtErr(e)}` } });
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (chunkCount === 0) {
|
|
165
|
-
await opts.onEvent({ event_type: 'error', payload: { message: `agent produced no output (stopReason=${stopReason})` } });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
await opts.onEvent({ event_type: 'result', payload: { stopReason } });
|
|
169
|
-
return { stopReason, sessionId: activeSessionId! };
|
|
170
|
-
} finally {
|
|
171
|
-
try { child.kill(); } catch {}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function handleSessionUpdate(params: SessionNotification, o: AcpRunOpts) {
|
|
175
|
-
const u: any = params.update;
|
|
176
|
-
const kind = u.sessionUpdate;
|
|
177
|
-
switch (kind) {
|
|
178
|
-
case 'agent_message_chunk':
|
|
179
|
-
case 'agent_thought_chunk': {
|
|
180
|
-
const text = extractText(u.content);
|
|
181
|
-
if (text) { chunkCount++; await o.onEvent({ event_type: 'assistant_text', payload: { text } }); }
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
case 'tool_call':
|
|
185
|
-
case 'tool_call_update': {
|
|
186
|
-
chunkCount++;
|
|
187
|
-
await o.onEvent({ event_type: 'tool_call', payload: {
|
|
188
|
-
id: u.toolCallId || u.id, tool: u.title || u.toolName || 'tool', kind: u.kind, status: u.status, input: u.rawInput, locations: u.locations,
|
|
189
|
-
}});
|
|
190
|
-
// Surface tool_call content (e.g. diff/text/terminal) as tool_result if present
|
|
191
|
-
if (Array.isArray(u.content)) {
|
|
192
|
-
for (const c of u.content) {
|
|
193
|
-
if (c.type === 'content' && c.content?.type === 'text') {
|
|
194
|
-
await o.onEvent({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: c.content.text.slice(0, 4000) } });
|
|
195
|
-
} else if (c.type === 'diff') {
|
|
196
|
-
const diff = `--- ${c.path}\n${c.oldText || ''}\n+++ ${c.path}\n${c.newText || ''}`.slice(0, 4000);
|
|
197
|
-
await o.onEvent({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: diff } });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
break;
|
|
202
|
-
}
|
|
203
|
-
case 'plan': {
|
|
204
|
-
const entries = (u.entries || []).map((e: any, i: number) => `${i + 1}. [${e.status || 'pending'}] ${e.content}`).join('\n');
|
|
205
|
-
if (entries) await o.onEvent({ event_type: 'assistant_text', payload: { text: `**Plan**\n\n${entries}` } });
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
case 'current_mode_update': {
|
|
209
|
-
await o.onEvent({ event_type: 'progress', payload: { message: `mode=${u.currentModeId}` } });
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
default: break;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts, allowCache: Set<string>): Promise<RequestPermissionResponse> {
|
|
217
|
-
const tc: any = params.toolCall || {};
|
|
218
|
-
const toolKey = `${tc.toolName || tc.title || ''}|${tc.kind || ''}`.toLowerCase();
|
|
219
|
-
|
|
220
|
-
// Autonomy=auto: auto-approve everything, prefer "always" variant so adapter caches it.
|
|
221
|
-
if (o.autonomy === 'auto') {
|
|
222
|
-
const opts = params.options as any[];
|
|
223
|
-
const alwaysOpt = opts.find(op => /always/i.test(op.name || '') || /allow_always/i.test(op.kind || ''));
|
|
224
|
-
const allowOpt = opts.find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
|
|
225
|
-
const chosen = alwaysOpt || allowOpt;
|
|
226
|
-
if (chosen) return { outcome: { outcome: 'selected', optionId: chosen.optionId } as any };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Auto-approve if user previously chose "always allow" for same tool/kind
|
|
230
|
-
if (toolKey && allowCache.has(toolKey)) {
|
|
231
|
-
const allowOpt = (params.options as any[]).find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
|
|
232
|
-
if (allowOpt) return { outcome: { outcome: 'selected', optionId: allowOpt.optionId } as any };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const created = await apiClient.post<any>(`${o.apiUrl}/api/permissions`, {
|
|
236
|
-
issue_id: o.issueId,
|
|
237
|
-
device_id: o.deviceId,
|
|
238
|
-
tool_call_id: tc.id || tc.toolCallId,
|
|
239
|
-
tool_name: tc.title || tc.toolName,
|
|
240
|
-
summary: tc.title,
|
|
241
|
-
options: params.options.map((op: any) => ({ optionId: op.optionId, name: op.name, kind: op.kind })),
|
|
242
|
-
});
|
|
243
|
-
const permId = created.data?.id;
|
|
244
|
-
if (!permId) return { outcome: { outcome: 'cancelled' } as any };
|
|
245
|
-
|
|
246
|
-
const deadline = Date.now() + 5 * 60 * 1000;
|
|
247
|
-
while (Date.now() < deadline) {
|
|
248
|
-
await new Promise(r => setTimeout(r, 500));
|
|
249
|
-
const got = await apiClient.get<any>(`${o.apiUrl}/api/permissions/${permId}`);
|
|
250
|
-
const row = got.data;
|
|
251
|
-
if (!row) break;
|
|
252
|
-
if (row.status === 'resolved' && row.chosen) {
|
|
253
|
-
// If the chosen option is an "always" variant, cache it
|
|
254
|
-
const chosenOpt = (params.options as any[]).find(op => op.optionId === row.chosen);
|
|
255
|
-
const isAlways = chosenOpt && (/always/i.test(chosenOpt.name || '') || /allow_always/i.test(chosenOpt.kind || ''));
|
|
256
|
-
if (isAlways && toolKey) allowCache.add(toolKey);
|
|
257
|
-
return { outcome: { outcome: 'selected', optionId: row.chosen } as any };
|
|
258
|
-
}
|
|
259
|
-
if (row.status === 'cancelled') {
|
|
260
|
-
return { outcome: { outcome: 'cancelled' } as any };
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return { outcome: { outcome: 'cancelled' } as any };
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function extractText(content: any): string {
|
|
268
|
-
if (!content) return '';
|
|
269
|
-
if (typeof content === 'string') return content;
|
|
270
|
-
if (Array.isArray(content)) return content.map(extractText).join('');
|
|
271
|
-
if (content.type === 'text' && typeof content.text === 'string') return content.text;
|
|
272
|
-
if (content.text) return content.text;
|
|
273
|
-
return '';
|
|
274
|
-
}
|
package/src/acpx-runner.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
// acpx-runner — delegate to `acpx` CLI for pi/codex/openclaw agents.
|
|
2
|
-
// acpx emits raw ACP JSON-RPC NDJSON on stdout with --format json; we reuse the
|
|
3
|
-
// same parse semantics as our direct ACP runner to produce AcpEvent stream.
|
|
4
|
-
//
|
|
5
|
-
// Requires: `npm install -g acpx` on device host (or npx acpx@latest).
|
|
6
|
-
// Runs with --approve-all for now (no permission UI forwarding).
|
|
7
|
-
|
|
8
|
-
import type { AcpEvent } from './acp-runner';
|
|
9
|
-
import { appendFileSync } from 'fs';
|
|
10
|
-
import { join } from 'path';
|
|
11
|
-
|
|
12
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
13
|
-
const LOG_PATH = join(HOME, '.multi', 'logs', 'agent.log');
|
|
14
|
-
function dlog(msg: string) {
|
|
15
|
-
try { appendFileSync(LOG_PATH, `[${new Date().toISOString()}] ${msg}\n`); } catch {}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface AcpxRunOpts {
|
|
19
|
-
agentType: 'pi' | 'codex' | 'openclaw' | 'claude' | string;
|
|
20
|
-
prompt: string;
|
|
21
|
-
cwd?: string;
|
|
22
|
-
sessionName?: string; // reuse same session across follow-ups (e.g. "issue-<id>")
|
|
23
|
-
onEvent: (ev: AcpEvent) => void | Promise<void>;
|
|
24
|
-
onSpawn?: (child: any) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }> {
|
|
28
|
-
const args = ['acpx', '--format', 'json', '--json-strict', '--approve-all', '--ttl', '0'];
|
|
29
|
-
if (opts.cwd) args.push('--cwd', opts.cwd);
|
|
30
|
-
args.push(opts.agentType);
|
|
31
|
-
// Ensure a session exists for this name (idempotent). ensure = get-or-create.
|
|
32
|
-
// We run it as a separate invocation, then prompt.
|
|
33
|
-
if (opts.sessionName) {
|
|
34
|
-
const ensureArgs = ['acpx', '--ttl', '0', ...(opts.cwd ? ['--cwd', opts.cwd] : []), opts.agentType, 'sessions', 'ensure', '--name', opts.sessionName];
|
|
35
|
-
dlog(`[acpx] ensure: ${ensureArgs.join(' ')}`);
|
|
36
|
-
try {
|
|
37
|
-
const ensure = Bun.spawn(ensureArgs, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
|
38
|
-
const ensureOut = await new Response(ensure.stdout as any).text();
|
|
39
|
-
const ensureErr = await new Response(ensure.stderr as any).text();
|
|
40
|
-
const ensureCode = await ensure.exited;
|
|
41
|
-
dlog(`[acpx] ensure exit=${ensureCode} stdout=${ensureOut.slice(0, 300)} stderr=${ensureErr.slice(0, 300)}`);
|
|
42
|
-
} catch (e) {
|
|
43
|
-
dlog(`[acpx] ensure spawn error: ${String(e)}`);
|
|
44
|
-
}
|
|
45
|
-
args.push('prompt', '-s', opts.sessionName);
|
|
46
|
-
} else {
|
|
47
|
-
args.push('prompt');
|
|
48
|
-
}
|
|
49
|
-
args.push(opts.prompt);
|
|
50
|
-
|
|
51
|
-
dlog(`[acpx] prompt: ${args.slice(0, 10).join(' ')} ... (prompt len=${opts.prompt.length})`);
|
|
52
|
-
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
|
53
|
-
try { opts.onSpawn?.(proc); } catch {}
|
|
54
|
-
let stopReason = 'end_turn';
|
|
55
|
-
|
|
56
|
-
// Forward stderr to our progress stream so we can debug in the UI/logs
|
|
57
|
-
(async () => {
|
|
58
|
-
try {
|
|
59
|
-
const r = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
|
60
|
-
const d = new TextDecoder();
|
|
61
|
-
let sb = '';
|
|
62
|
-
while (true) {
|
|
63
|
-
const { value, done } = await r.read();
|
|
64
|
-
if (done) break;
|
|
65
|
-
sb += d.decode(value, { stream: true });
|
|
66
|
-
let nl: number;
|
|
67
|
-
while ((nl = sb.indexOf('\n')) !== -1) {
|
|
68
|
-
const ln = sb.slice(0, nl).trim();
|
|
69
|
-
sb = sb.slice(nl + 1);
|
|
70
|
-
if (ln) dlog(`[acpx stderr] ${ln.slice(0, 500)}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
} catch {}
|
|
74
|
-
})();
|
|
75
|
-
|
|
76
|
-
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
77
|
-
const dec = new TextDecoder();
|
|
78
|
-
let buf = '';
|
|
79
|
-
|
|
80
|
-
let lineCount = 0;
|
|
81
|
-
while (true) {
|
|
82
|
-
const { value, done } = await reader.read();
|
|
83
|
-
if (done) break;
|
|
84
|
-
buf += dec.decode(value, { stream: true });
|
|
85
|
-
let nl: number;
|
|
86
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
87
|
-
const line = buf.slice(0, nl).trim();
|
|
88
|
-
buf = buf.slice(nl + 1);
|
|
89
|
-
if (!line) continue;
|
|
90
|
-
lineCount++;
|
|
91
|
-
dlog(`[acpx stdout] ${line.slice(0, 500)}`);
|
|
92
|
-
const events = parseAcpLine(line, (r) => { stopReason = r; });
|
|
93
|
-
for (const ev of events) await opts.onEvent(ev);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
dlog(`[acpx] stdout lines total=${lineCount}`);
|
|
97
|
-
if (buf.trim()) {
|
|
98
|
-
const events = parseAcpLine(buf.trim(), (r) => { stopReason = r; });
|
|
99
|
-
for (const ev of events) await opts.onEvent(ev);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const code = await proc.exited;
|
|
103
|
-
if (code !== 0 && code !== 130) {
|
|
104
|
-
await opts.onEvent({ event_type: 'error', payload: { message: `acpx exited with code ${code}` } });
|
|
105
|
-
}
|
|
106
|
-
return { stopReason };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function parseAcpLine(line: string, setStop: (r: string) => void): AcpEvent[] {
|
|
110
|
-
let msg: any;
|
|
111
|
-
try { msg = JSON.parse(line); } catch { return []; }
|
|
112
|
-
const out: AcpEvent[] = [];
|
|
113
|
-
|
|
114
|
-
// session/update notification
|
|
115
|
-
if (msg.method === 'session/update' && msg.params?.update?.sessionUpdate) {
|
|
116
|
-
return handleSessionUpdate(msg.params);
|
|
117
|
-
}
|
|
118
|
-
// prompt result
|
|
119
|
-
if (msg.id && msg.result && typeof msg.result === 'object' && 'stopReason' in msg.result) {
|
|
120
|
-
setStop(msg.result.stopReason);
|
|
121
|
-
out.push({ event_type: 'result', payload: { stopReason: msg.result.stopReason } });
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
// errors
|
|
125
|
-
if (msg.error) {
|
|
126
|
-
out.push({ event_type: 'error', payload: { message: msg.error.message || JSON.stringify(msg.error) } });
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
return out;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function handleSessionUpdate(params: any): AcpEvent[] {
|
|
133
|
-
const u = params.update || {};
|
|
134
|
-
const kind = u.sessionUpdate;
|
|
135
|
-
const out: AcpEvent[] = [];
|
|
136
|
-
switch (kind) {
|
|
137
|
-
case 'agent_message_chunk':
|
|
138
|
-
case 'agent_thought_chunk': {
|
|
139
|
-
const text = extractText(u.content);
|
|
140
|
-
if (text) out.push({ event_type: 'assistant_text', payload: { text } });
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
case 'tool_call':
|
|
144
|
-
case 'tool_call_update': {
|
|
145
|
-
out.push({ event_type: 'tool_call', payload: {
|
|
146
|
-
id: u.toolCallId || u.id, tool: u.title || u.toolName || 'tool',
|
|
147
|
-
kind: u.kind, status: u.status, input: u.rawInput, locations: u.locations,
|
|
148
|
-
}});
|
|
149
|
-
if (Array.isArray(u.content)) {
|
|
150
|
-
for (const c of u.content) {
|
|
151
|
-
if (c.type === 'content' && c.content?.type === 'text') {
|
|
152
|
-
out.push({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: String(c.content.text).slice(0, 4000) } });
|
|
153
|
-
} else if (c.type === 'diff') {
|
|
154
|
-
const diff = `--- ${c.path}\n${c.oldText || ''}\n+++ ${c.path}\n${c.newText || ''}`.slice(0, 4000);
|
|
155
|
-
out.push({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: diff } });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
case 'plan': {
|
|
162
|
-
const entries = (u.entries || []).map((e: any, i: number) => `${i + 1}. [${e.status || 'pending'}] ${e.content}`).join('\n');
|
|
163
|
-
if (entries) out.push({ event_type: 'assistant_text', payload: { text: `**Plan**\n\n${entries}` } });
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function extractText(content: any): string {
|
|
171
|
-
if (!content) return '';
|
|
172
|
-
if (typeof content === 'string') return content;
|
|
173
|
-
if (Array.isArray(content)) return content.map(extractText).join('');
|
|
174
|
-
if (content.type === 'text' && typeof content.text === 'string') return content.text;
|
|
175
|
-
if (content.text) return content.text;
|
|
176
|
-
return '';
|
|
177
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
interface ApiResponse<T = unknown> {
|
|
2
|
-
success: boolean;
|
|
3
|
-
data?: T;
|
|
4
|
-
error?: string;
|
|
5
|
-
status?: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
let authToken: string | null = null;
|
|
9
|
-
|
|
10
|
-
export function setAuthToken(token: string | null) {
|
|
11
|
-
authToken = token;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function request<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
|
|
15
|
-
try {
|
|
16
|
-
const headers: Record<string, string> = {
|
|
17
|
-
'Content-Type': 'application/json',
|
|
18
|
-
...(options?.headers as Record<string, string> | undefined),
|
|
19
|
-
};
|
|
20
|
-
if (authToken && !headers['Authorization']) headers['Authorization'] = `Bearer ${authToken}`;
|
|
21
|
-
|
|
22
|
-
const response = await fetch(url, { ...options, headers });
|
|
23
|
-
const raw = await response.json().catch(() => ({}));
|
|
24
|
-
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
return { success: false, error: (raw as any)?.error || `HTTP ${response.status}`, status: response.status };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const data = (raw && typeof raw === 'object' && 'results' in (raw as any))
|
|
30
|
-
? (raw as any).results
|
|
31
|
-
: raw;
|
|
32
|
-
|
|
33
|
-
return { success: true, data, status: response.status };
|
|
34
|
-
} catch (error) {
|
|
35
|
-
return { success: false, error: String(error) };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export const apiClient = {
|
|
40
|
-
get: <T = unknown>(url: string) => request<T>(url),
|
|
41
|
-
post: <T = unknown>(url: string, body: unknown, opts?: { headers?: Record<string, string> }) =>
|
|
42
|
-
request<T>(url, { method: 'POST', body: JSON.stringify(body), headers: opts?.headers }),
|
|
43
|
-
patch: <T = unknown>(url: string, body: unknown, opts?: { headers?: Record<string, string> }) =>
|
|
44
|
-
request<T>(url, { method: 'PATCH', body: JSON.stringify(body), headers: opts?.headers }),
|
|
45
|
-
delete: <T = unknown>(url: string) => request<T>(url, { method: 'DELETE' }),
|
|
46
|
-
};
|
package/src/detect.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
export type AgentType = 'claude-code' | 'codex' | 'gemini-cli' | 'openclaw' | 'opencode' | 'pi' | 'custom';
|
|
2
|
-
|
|
3
|
-
export interface DetectedAgent {
|
|
4
|
-
type: AgentType;
|
|
5
|
-
path: string;
|
|
6
|
-
version?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const AGENT_BINARIES: Record<AgentType, string[]> = {
|
|
10
|
-
'claude-code': ['claude', 'claude-code', 'claude_code'],
|
|
11
|
-
'codex': ['codex', 'openai-codex'],
|
|
12
|
-
'gemini-cli': ['gemini', 'gemini-cli', 'google-gemini'],
|
|
13
|
-
'openclaw': ['openclaw'],
|
|
14
|
-
'opencode': ['opencode'],
|
|
15
|
-
'pi': ['pi'],
|
|
16
|
-
'custom': [],
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export async function detectAgents(): Promise<DetectedAgent[]> {
|
|
20
|
-
const detected: DetectedAgent[] = [];
|
|
21
|
-
|
|
22
|
-
// Check PATH for known agent binaries
|
|
23
|
-
const path = Bun.env.PATH?.split(':') || [];
|
|
24
|
-
|
|
25
|
-
for (const [type, binaries] of Object.entries(AGENT_BINARIES)) {
|
|
26
|
-
if (type === 'custom') continue;
|
|
27
|
-
|
|
28
|
-
for (const binary of binaries) {
|
|
29
|
-
const found = await which(binary);
|
|
30
|
-
if (found) {
|
|
31
|
-
const version = await getVersion(found);
|
|
32
|
-
detected.push({ type: type as AgentType, path: found, version });
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return detected;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function which(cmd: string): Promise<string | null> {
|
|
42
|
-
for (const dir of Bun.env.PATH?.split(':') || []) {
|
|
43
|
-
const fullPath = `${dir}/${cmd}`;
|
|
44
|
-
try {
|
|
45
|
-
const stat = await fileExists(fullPath);
|
|
46
|
-
if (stat) return fullPath;
|
|
47
|
-
} catch {
|
|
48
|
-
// Continue
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function fileExists(path: string): Promise<boolean> {
|
|
55
|
-
try {
|
|
56
|
-
return await Bun.file(path).exists();
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function getVersion(path: string): Promise<string | undefined> {
|
|
63
|
-
try {
|
|
64
|
-
const proc = Bun.spawn([path, '--version'], { stdout: 'pipe' });
|
|
65
|
-
const output = await new Response(proc.stdout).text();
|
|
66
|
-
return output.trim() || undefined;
|
|
67
|
-
} catch {
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
}
|