@shipers-dev/multi 0.1.0
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/.claude/settings.local.json +11 -0
- package/.codemogger/index.db +0 -0
- package/.codemogger/index.db-wal +0 -0
- package/dist/index.js +6222 -0
- package/package.json +17 -0
- package/src/acp-runner.ts +211 -0
- package/src/client.ts +40 -0
- package/src/detect.ts +70 -0
- package/src/index.ts +801 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { detectAgents } from './detect';
|
|
4
|
+
import { apiClient } from './client';
|
|
5
|
+
import { runAcp } from './acp-runner';
|
|
6
|
+
import { parseArgs } from 'util';
|
|
7
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
|
|
10
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
11
|
+
const MULTI_DIR = join(HOME, '.multi');
|
|
12
|
+
const CONFIG_PATH = join(MULTI_DIR, 'config.json');
|
|
13
|
+
const PID_PATH = join(MULTI_DIR, 'agent.pid');
|
|
14
|
+
const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
15
|
+
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
16
|
+
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
17
|
+
|
|
18
|
+
const COMMANDS = {
|
|
19
|
+
setup: 'Register this device with a workspace',
|
|
20
|
+
connect: 'Connect device to realtime hub and execute assigned tasks',
|
|
21
|
+
link: 'Link this device to an agent (agent_id required)',
|
|
22
|
+
status: 'Show current status',
|
|
23
|
+
stop: 'Stop the running daemon',
|
|
24
|
+
logs: 'View execution logs',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
type Command = keyof typeof COMMANDS;
|
|
28
|
+
|
|
29
|
+
function ensureDirs() {
|
|
30
|
+
for (const d of [MULTI_DIR, join(MULTI_DIR, 'logs'), SKILLS_DIR]) {
|
|
31
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function log(msg: string) {
|
|
36
|
+
ensureDirs();
|
|
37
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
38
|
+
appendFileSync(LOG_PATH, line);
|
|
39
|
+
process.stdout.write(line);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const args = parseArgs({
|
|
44
|
+
args: Bun.argv,
|
|
45
|
+
options: {
|
|
46
|
+
help: { type: 'boolean', default: false },
|
|
47
|
+
name: { type: 'string' },
|
|
48
|
+
workspace: { type: 'string' },
|
|
49
|
+
agent: { type: 'string' },
|
|
50
|
+
api: { type: 'string' },
|
|
51
|
+
},
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const [command] = args.positionals.slice(2) as Command[];
|
|
56
|
+
|
|
57
|
+
if (args.values.help || !command) {
|
|
58
|
+
printHelp();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const apiUrl = args.values.api || process.env.MULTI_API_URL || 'https://multi-api.adnb3r.workers.dev';
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
|
|
65
|
+
switch (command) {
|
|
66
|
+
case 'setup':
|
|
67
|
+
await cmdSetup(args.values.name, args.values.workspace, apiUrl);
|
|
68
|
+
break;
|
|
69
|
+
case 'connect':
|
|
70
|
+
case 'start':
|
|
71
|
+
await cmdConnect(apiUrl, config);
|
|
72
|
+
break;
|
|
73
|
+
case 'link':
|
|
74
|
+
await cmdLink(apiUrl, config, args.values.agent);
|
|
75
|
+
break;
|
|
76
|
+
case 'status':
|
|
77
|
+
await cmdStatus(apiUrl, config);
|
|
78
|
+
break;
|
|
79
|
+
case 'stop':
|
|
80
|
+
await cmdStop();
|
|
81
|
+
break;
|
|
82
|
+
case 'logs':
|
|
83
|
+
await cmdLogs();
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
console.error(`Unknown command: ${command}`);
|
|
87
|
+
printHelp();
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function printHelp() {
|
|
93
|
+
console.log(`
|
|
94
|
+
multi-agent - Device CLI for Multi platform
|
|
95
|
+
|
|
96
|
+
Usage: multi-agent <command> [options]
|
|
97
|
+
|
|
98
|
+
Commands:
|
|
99
|
+
setup ${COMMANDS.setup}
|
|
100
|
+
link ${COMMANDS.link}
|
|
101
|
+
connect ${COMMANDS.connect}
|
|
102
|
+
status ${COMMANDS.status}
|
|
103
|
+
stop ${COMMANDS.stop}
|
|
104
|
+
logs ${COMMANDS.logs}
|
|
105
|
+
|
|
106
|
+
Options:
|
|
107
|
+
--name <name> Device name
|
|
108
|
+
--workspace <id> Workspace ID
|
|
109
|
+
--agent <id> Agent ID (for link)
|
|
110
|
+
--api <url> API URL (default: http://localhost:8787)
|
|
111
|
+
--help Show this help
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
multi-agent setup --name "My Mac" --workspace ws_xxx
|
|
115
|
+
multi-agent link --agent agent_xxx
|
|
116
|
+
multi-agent connect
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function cmdSetup(name?: string, workspaceId?: string, apiUrl?: string) {
|
|
121
|
+
ensureDirs();
|
|
122
|
+
if (!workspaceId) {
|
|
123
|
+
console.log('❌ Workspace ID required. Run: multi-agent setup --workspace <id>');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('🔍 Detecting local agent runtimes...');
|
|
128
|
+
const detected = await detectAgents();
|
|
129
|
+
for (const a of detected) console.log(` ✓ ${a.type} at ${a.path}`);
|
|
130
|
+
if (!detected.length) console.log(' (none detected — stub runtime will be used)');
|
|
131
|
+
|
|
132
|
+
const deviceName = name || process.env.HOSTNAME || 'Unknown Device';
|
|
133
|
+
console.log(`\n📝 Registering device "${deviceName}"...`);
|
|
134
|
+
|
|
135
|
+
const result = await apiClient.post<{ id: string }>(`${apiUrl}/api/devices/register`, {
|
|
136
|
+
workspace_id: workspaceId,
|
|
137
|
+
name: deviceName,
|
|
138
|
+
platform: process.platform,
|
|
139
|
+
arch: process.arch,
|
|
140
|
+
os_version: process.version,
|
|
141
|
+
detected_runtimes: detected.map(a => a.type),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!result.success || !result.data) {
|
|
145
|
+
console.log('\n❌ Registration failed:', result.error);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(`✅ Device registered. ID: ${result.data.id}`);
|
|
150
|
+
saveConfig({ deviceId: result.data.id, workspaceId, apiUrl });
|
|
151
|
+
await syncSkills(apiUrl!, workspaceId);
|
|
152
|
+
console.log('\nNext: link to an agent with: multi-agent link --agent <agentId>');
|
|
153
|
+
console.log('Then: multi-agent connect');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function syncSkills(apiUrl: string, workspaceId: string) {
|
|
157
|
+
const res = await apiClient.get<any[]>(`${apiUrl}/api/skills?workspace_id=${workspaceId}`);
|
|
158
|
+
if (!res.success || !Array.isArray(res.data)) return;
|
|
159
|
+
ensureDirs();
|
|
160
|
+
for (const skill of res.data) {
|
|
161
|
+
writeFileSync(join(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
|
|
162
|
+
}
|
|
163
|
+
if (res.data.length) console.log(` Synced ${res.data.length} skill(s) → ${SKILLS_DIR}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function cmdLink(apiUrl: string, config: Config, agentId?: string) {
|
|
167
|
+
if (!config.deviceId) {
|
|
168
|
+
console.log('❌ Not registered. Run "multi-agent setup" first.');
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
if (!agentId) {
|
|
172
|
+
console.log('❌ Agent ID required: multi-agent link --agent <id>');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const res = await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/link`, { agent_id: agentId });
|
|
176
|
+
if (!res.success) {
|
|
177
|
+
console.log('❌ Link failed:', res.error);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
console.log(`✅ Linked agent ${agentId} ↔ device ${config.deviceId}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function cmdConnect(apiUrl: string, config: Config) {
|
|
184
|
+
if (!config.deviceId) {
|
|
185
|
+
console.log('❌ Not registered. Run "multi-agent setup" first.');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (existsSync(PID_PATH)) {
|
|
190
|
+
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
191
|
+
if (pid && isRunning(pid)) {
|
|
192
|
+
console.log(`❌ Daemon already running (pid ${pid}).`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
unlinkSync(PID_PATH);
|
|
196
|
+
}
|
|
197
|
+
ensureDirs();
|
|
198
|
+
writeFileSync(PID_PATH, String(process.pid));
|
|
199
|
+
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
200
|
+
|
|
201
|
+
const detected = await detectAgents();
|
|
202
|
+
log(`🚀 Connecting device ${config.deviceId} (pid ${process.pid})`);
|
|
203
|
+
log(` runtimes: ${detected.map(d => d.type).join(', ') || 'stub'}`);
|
|
204
|
+
|
|
205
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online' });
|
|
206
|
+
|
|
207
|
+
const wsUrl = apiUrl.replace(/^http/, 'ws') + `/ws/devices/${config.deviceId}`;
|
|
208
|
+
let ws: WebSocket | null = null;
|
|
209
|
+
let running = true;
|
|
210
|
+
|
|
211
|
+
const shutdown = async (reason: string) => {
|
|
212
|
+
if (!running) return;
|
|
213
|
+
running = false;
|
|
214
|
+
log(`🛑 Shutting down (${reason})`);
|
|
215
|
+
try { ws?.close(); } catch {}
|
|
216
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline' });
|
|
217
|
+
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
218
|
+
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
219
|
+
log('👋 Disconnected');
|
|
220
|
+
process.exit(0);
|
|
221
|
+
};
|
|
222
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
223
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
224
|
+
|
|
225
|
+
const connect = () => {
|
|
226
|
+
ws = new WebSocket(wsUrl);
|
|
227
|
+
ws.addEventListener('open', () => log(`🔌 WS connected: ${wsUrl}`));
|
|
228
|
+
ws.addEventListener('message', async (ev) => {
|
|
229
|
+
try {
|
|
230
|
+
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
|
231
|
+
if (msg.type === 'run_task') {
|
|
232
|
+
await handleRunTask(apiUrl, config.deviceId!, msg.task, detected);
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
log(`msg parse error: ${String(e)}`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
ws.addEventListener('close', () => {
|
|
239
|
+
if (!running) return;
|
|
240
|
+
log('⚠️ WS closed, reconnecting in 3s');
|
|
241
|
+
setTimeout(connect, 3000);
|
|
242
|
+
});
|
|
243
|
+
ws.addEventListener('error', (e) => log(`WS error: ${String((e as any).message || e)}`));
|
|
244
|
+
};
|
|
245
|
+
connect();
|
|
246
|
+
|
|
247
|
+
// Heartbeat loop
|
|
248
|
+
while (running) {
|
|
249
|
+
await sleep(20000);
|
|
250
|
+
if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
|
|
251
|
+
try {
|
|
252
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online' });
|
|
253
|
+
} catch (e) {
|
|
254
|
+
log(`heartbeat error: ${String(e)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
|
|
260
|
+
const issueId = task.issue_id;
|
|
261
|
+
const isFollowup = !!task.followup;
|
|
262
|
+
log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}`);
|
|
263
|
+
|
|
264
|
+
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
|
|
265
|
+
await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
|
|
266
|
+
|
|
267
|
+
let liveCommentId: string | undefined;
|
|
268
|
+
let liveBody = '';
|
|
269
|
+
let hadError = false;
|
|
270
|
+
let hasAssistantText = false;
|
|
271
|
+
|
|
272
|
+
const ensureLiveComment = async () => {
|
|
273
|
+
if (liveCommentId) return;
|
|
274
|
+
const res = await apiClient.post<any>(`${apiUrl}/api/issues/${issueId}/comments`, {
|
|
275
|
+
author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body: '…',
|
|
276
|
+
});
|
|
277
|
+
liveCommentId = res.data?.id;
|
|
278
|
+
};
|
|
279
|
+
const patchLive = async (body: string) => {
|
|
280
|
+
if (!liveCommentId) return;
|
|
281
|
+
try { await apiClient.patch(`${apiUrl}/api/issues/${issueId}/comments/${liveCommentId}`, { body: body || '…' }); } catch {}
|
|
282
|
+
};
|
|
283
|
+
const postComment = async (body: string) => {
|
|
284
|
+
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body }); } catch {}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
type ToolEntry = { id: string; tool: string; kind?: string; status?: string; input?: any; results: string[] };
|
|
288
|
+
const turn = {
|
|
289
|
+
text: '' as string,
|
|
290
|
+
toolOrder: [] as string[],
|
|
291
|
+
tools: new Map<string, ToolEntry>(),
|
|
292
|
+
plans: [] as string[],
|
|
293
|
+
progress: [] as string[],
|
|
294
|
+
result: null as null | { duration_ms?: number; total_cost_usd?: number; stopReason?: string; is_error?: boolean },
|
|
295
|
+
error: null as null | string,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const render = (): string => {
|
|
299
|
+
const parts: string[] = [];
|
|
300
|
+
if (turn.text) parts.push(turn.text);
|
|
301
|
+
|
|
302
|
+
for (const id of turn.toolOrder) {
|
|
303
|
+
const t = turn.tools.get(id);
|
|
304
|
+
if (!t) continue;
|
|
305
|
+
const icon = statusIcon(t.status);
|
|
306
|
+
const cleanTool = stripMd(t.tool);
|
|
307
|
+
const head = `${icon} ${cleanTool}${t.kind ? ` · ${t.kind}` : ''}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
|
|
308
|
+
const body: string[] = [];
|
|
309
|
+
if (t.input !== undefined && t.input !== null) {
|
|
310
|
+
const inputStr = typeof t.input === 'object' ? JSON.stringify(t.input, null, 2) : String(t.input);
|
|
311
|
+
body.push('```json\n' + inputStr + '\n```');
|
|
312
|
+
}
|
|
313
|
+
if (t.results.length) {
|
|
314
|
+
const joined = t.results.join('\n').slice(-2000);
|
|
315
|
+
body.push('**Output**\n\n```\n' + joined + '\n```');
|
|
316
|
+
}
|
|
317
|
+
parts.push(`<details>\n<summary>${head}</summary>\n\n${body.join('\n\n')}\n</details>`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (turn.plans.length) parts.push('**Plan**\n\n' + turn.plans[turn.plans.length - 1]);
|
|
321
|
+
|
|
322
|
+
if (turn.error) parts.push(`> ❌ ${turn.error}`);
|
|
323
|
+
|
|
324
|
+
if (turn.result) {
|
|
325
|
+
const bits: string[] = [];
|
|
326
|
+
if (turn.result.duration_ms) bits.push(`${Math.round(turn.result.duration_ms)}ms`);
|
|
327
|
+
if (turn.result.total_cost_usd) bits.push(`$${Number(turn.result.total_cost_usd).toFixed(4)}`);
|
|
328
|
+
if (turn.result.stopReason) bits.push(turn.result.stopReason);
|
|
329
|
+
if (bits.length) parts.push(`---\n_${bits.join(' · ')}_`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return parts.join('\n\n') || '…';
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
let renderScheduled = false;
|
|
336
|
+
const schedulePatch = () => {
|
|
337
|
+
if (renderScheduled) return;
|
|
338
|
+
renderScheduled = true;
|
|
339
|
+
queueMicrotask(async () => {
|
|
340
|
+
renderScheduled = false;
|
|
341
|
+
try { await patchLive(render()); } catch {}
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const eventHandler = async (event: any) => {
|
|
346
|
+
if (event.event_type === 'error') hadError = true;
|
|
347
|
+
await postStream(apiUrl, issueId, event.event_type, event.payload);
|
|
348
|
+
|
|
349
|
+
const p: any = event.payload || {};
|
|
350
|
+
switch (event.event_type) {
|
|
351
|
+
case 'progress': {
|
|
352
|
+
if (p.message) turn.progress.push(p.message);
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case 'assistant_text': {
|
|
356
|
+
await ensureLiveComment();
|
|
357
|
+
turn.text += (turn.text ? '\n\n' : '') + p.text;
|
|
358
|
+
hasAssistantText = true;
|
|
359
|
+
schedulePatch();
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'stdout': {
|
|
363
|
+
// Legacy (non-ACP) runners emit stdout — route to live comment too.
|
|
364
|
+
if (p.line) {
|
|
365
|
+
await ensureLiveComment();
|
|
366
|
+
turn.text += (turn.text ? '\n' : '') + p.line;
|
|
367
|
+
hasAssistantText = true;
|
|
368
|
+
schedulePatch();
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'tool_call': {
|
|
373
|
+
await ensureLiveComment();
|
|
374
|
+
const id = p.id || `anon-${turn.toolOrder.length}`;
|
|
375
|
+
const existing = turn.tools.get(id);
|
|
376
|
+
if (existing) {
|
|
377
|
+
if (p.tool) existing.tool = p.tool;
|
|
378
|
+
if (p.kind) existing.kind = p.kind;
|
|
379
|
+
if (p.status) existing.status = p.status;
|
|
380
|
+
if (p.input !== undefined) existing.input = p.input;
|
|
381
|
+
} else {
|
|
382
|
+
turn.toolOrder.push(id);
|
|
383
|
+
turn.tools.set(id, { id, tool: p.tool || 'tool', kind: p.kind, status: p.status, input: p.input, results: [] });
|
|
384
|
+
}
|
|
385
|
+
schedulePatch();
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case 'tool_result': {
|
|
389
|
+
await ensureLiveComment();
|
|
390
|
+
const id = p.tool_use_id || turn.toolOrder[turn.toolOrder.length - 1];
|
|
391
|
+
const entry = id ? turn.tools.get(id) : undefined;
|
|
392
|
+
const content = String(p.content ?? '').trim();
|
|
393
|
+
if (entry && content) {
|
|
394
|
+
entry.results.push(content);
|
|
395
|
+
schedulePatch();
|
|
396
|
+
} else if (content) {
|
|
397
|
+
// orphan result — create a pseudo tool
|
|
398
|
+
const pid = `result-${turn.toolOrder.length}`;
|
|
399
|
+
turn.toolOrder.push(pid);
|
|
400
|
+
turn.tools.set(pid, { id: pid, tool: 'tool result', results: [content] });
|
|
401
|
+
schedulePatch();
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case 'result': {
|
|
406
|
+
await ensureLiveComment();
|
|
407
|
+
if (p.is_error) hadError = true;
|
|
408
|
+
if (!hasAssistantText && p.result) {
|
|
409
|
+
turn.text = p.result;
|
|
410
|
+
hasAssistantText = true;
|
|
411
|
+
}
|
|
412
|
+
turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };
|
|
413
|
+
schedulePatch();
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
case 'error': {
|
|
417
|
+
await ensureLiveComment();
|
|
418
|
+
turn.error = p.message || 'error';
|
|
419
|
+
schedulePatch();
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Pick per-agent-type path: claude-code → ACP; pi/others → legacy stdout runner.
|
|
426
|
+
let preferType: string | undefined;
|
|
427
|
+
try {
|
|
428
|
+
const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
429
|
+
if (a.data?.type) preferType = a.data.type;
|
|
430
|
+
} catch {}
|
|
431
|
+
const acpCapable = detected.filter(d => d.type === 'claude-code');
|
|
432
|
+
const useAcp = preferType !== 'pi' && acpCapable.length > 0 && process.env.MULTI_LEGACY !== '1';
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
if (useAcp) {
|
|
436
|
+
const base = `${task.title}\n\n${task.description || ''}`.trim();
|
|
437
|
+
let userPart = task.followup
|
|
438
|
+
? `${task.followup}\n\n---\nContext (original task ${task.key}): ${task.title}`
|
|
439
|
+
: (base || task.title);
|
|
440
|
+
userPart = stripSelfMention(userPart, preferType);
|
|
441
|
+
|
|
442
|
+
// Pull agent + linked skills to construct system/context preamble
|
|
443
|
+
let preamble = '';
|
|
444
|
+
try {
|
|
445
|
+
const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
446
|
+
const agent = agentRes.data;
|
|
447
|
+
if (agent?.prompt) preamble += `# Agent instructions\n\n${agent.prompt}\n\n`;
|
|
448
|
+
|
|
449
|
+
const skillsRes = await apiClient.get<any[]>(`${apiUrl}/api/agents/${task.agent_id}/skills`);
|
|
450
|
+
const skillList = Array.isArray(skillsRes.data) ? skillsRes.data : [];
|
|
451
|
+
if (skillList.length) {
|
|
452
|
+
preamble += `# Attached skills (${skillList.length})\n\n`;
|
|
453
|
+
for (const s of skillList) {
|
|
454
|
+
const body = String(s.body || s.description || '').trim();
|
|
455
|
+
if (!body) continue;
|
|
456
|
+
preamble += `## ${s.name}${s.version ? ` v${s.version}` : ''}\n\n${body}\n\n`;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} catch (e) {
|
|
460
|
+
log(`preamble fetch failed: ${String(e)}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const prompt = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
|
|
464
|
+
|
|
465
|
+
// Pick adapter for the agent's declared type if we have it, else first ACP-capable
|
|
466
|
+
let preferredType: string = 'claude-code';
|
|
467
|
+
try {
|
|
468
|
+
const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
469
|
+
if (a.data?.type) preferredType = a.data.type;
|
|
470
|
+
} catch {}
|
|
471
|
+
const chosen = acpCapable.find(d => d.type === preferredType) || acpCapable[0];
|
|
472
|
+
const adapterBin = await resolveAcpAdapter(chosen.type, chosen.path);
|
|
473
|
+
if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
|
|
474
|
+
log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
|
|
475
|
+
|
|
476
|
+
const { sessionId } = await runAcp({
|
|
477
|
+
apiUrl, issueId, deviceId, prompt,
|
|
478
|
+
sessionId: task.session_id || null,
|
|
479
|
+
adapterBin,
|
|
480
|
+
onEvent: eventHandler,
|
|
481
|
+
onSession: async (sid) => {
|
|
482
|
+
try { await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { session_id: sid } as any); } catch {}
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
log(` acp session ${sessionId.slice(0, 8)}`);
|
|
486
|
+
} else {
|
|
487
|
+
// Determine preferred type from agent (e.g. pi)
|
|
488
|
+
let preferType: string | undefined;
|
|
489
|
+
try {
|
|
490
|
+
const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
491
|
+
if (a.data?.type) preferType = a.data.type;
|
|
492
|
+
} catch {}
|
|
493
|
+
const runner = pickRunner(detected, preferType);
|
|
494
|
+
for await (const event of runner(task)) await eventHandler(event);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
|
|
498
|
+
else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
|
|
499
|
+
} catch (e) {
|
|
500
|
+
await postStream(apiUrl, issueId, 'error', { message: String(e) });
|
|
501
|
+
await postComment(`❌ spawn error: ${String(e)}`);
|
|
502
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
503
|
+
log(` ✗ ${task.key} failed: ${String(e)}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function stripMd(s: string): string {
|
|
508
|
+
return s.replace(/[`*_]/g, '').trim();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Strip leading "@agent_name" so the agent CLI doesn't treat it as a file ref (pi)
|
|
512
|
+
// or get confused by self-address. Universally safe across runners.
|
|
513
|
+
function stripSelfMention(prompt: string, _agentType?: string): string {
|
|
514
|
+
return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, '').trim();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function statusIcon(status?: string): string {
|
|
518
|
+
switch (status) {
|
|
519
|
+
case 'pending': return '⏳';
|
|
520
|
+
case 'in_progress': return '🔧';
|
|
521
|
+
case 'completed': return '✓';
|
|
522
|
+
case 'failed': return '✗';
|
|
523
|
+
default: return '🔧';
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function resolveAcpAdapter(agentType: string, detectedPath?: string): Promise<string[] | null> {
|
|
528
|
+
// Native ACP agents (like `pi`) — invoke directly with ACP mode flag
|
|
529
|
+
if (agentType === 'pi' && detectedPath && existsSync(detectedPath)) {
|
|
530
|
+
return [detectedPath, '--mode', 'rpc'];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// claude-code → Zed adapter wrapper (stdio ACP)
|
|
534
|
+
const adapterName = 'claude-code-acp';
|
|
535
|
+
const candidates = [
|
|
536
|
+
join(HOME, '.bun', 'install', 'global', 'node_modules', '.bin', adapterName),
|
|
537
|
+
];
|
|
538
|
+
try {
|
|
539
|
+
const here = new URL(import.meta.url).pathname;
|
|
540
|
+
let dir = here;
|
|
541
|
+
for (let i = 0; i < 8; i++) {
|
|
542
|
+
dir = dirname(dir);
|
|
543
|
+
const bin = join(dir, 'node_modules', '.bin', adapterName);
|
|
544
|
+
if (existsSync(bin)) return [bin];
|
|
545
|
+
}
|
|
546
|
+
} catch {}
|
|
547
|
+
for (const c of candidates) if (existsSync(c)) return [c];
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
|
|
552
|
+
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
type StreamEvent = { event_type: 'progress' | 'stdout' | 'stderr' | 'tool_call' | 'done' | 'error'; payload: any };
|
|
556
|
+
type Runner = (task: any) => AsyncGenerator<StreamEvent>;
|
|
557
|
+
|
|
558
|
+
function pickRunner(detected: { type: string; path: string }[], preferType?: string): Runner {
|
|
559
|
+
const forceStub = process.env.MULTI_STUB === '1';
|
|
560
|
+
if (forceStub || !detected.length) return stubRunner;
|
|
561
|
+
const preferred = (preferType && detected.find(d => d.type === preferType)) || detected.find(d => d.type === 'claude-code') || detected[0];
|
|
562
|
+
return makeCliRunner(preferred);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function* stubRunner(task: any): AsyncGenerator<StreamEvent> {
|
|
566
|
+
yield { event_type: 'stdout', payload: { line: `# ${task.key}: ${task.title}` } };
|
|
567
|
+
await sleep(600);
|
|
568
|
+
yield { event_type: 'stdout', payload: { line: 'Analyzing task...' } };
|
|
569
|
+
await sleep(800);
|
|
570
|
+
yield { event_type: 'progress', payload: { message: 'Generating solution' } };
|
|
571
|
+
await sleep(800);
|
|
572
|
+
yield { event_type: 'stdout', payload: { line: 'Solution ready.' } };
|
|
573
|
+
yield { event_type: 'done', payload: { code: 0 } };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function makeCliRunner(agent: { type: string; path: string }): Runner {
|
|
577
|
+
return async function* (task: any): AsyncGenerator<StreamEvent> {
|
|
578
|
+
const base = `${task.title}\n\n${task.description || ''}`.trim();
|
|
579
|
+
let prompt = task.followup
|
|
580
|
+
? `${task.followup}\n\n---\nContext (original task ${task.key}): ${task.title}`
|
|
581
|
+
: (base || task.title);
|
|
582
|
+
prompt = stripSelfMention(prompt, agent.type);
|
|
583
|
+
const args = buildArgs(agent.type, prompt);
|
|
584
|
+
yield { event_type: 'progress', payload: { message: `spawning ${agent.type}`, cmd: `${agent.path} ${args.slice(0, 2).join(' ')} …` } };
|
|
585
|
+
|
|
586
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
587
|
+
try {
|
|
588
|
+
proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
|
589
|
+
} catch (e) {
|
|
590
|
+
yield { event_type: 'error', payload: { message: `spawn failed: ${String(e)}` } };
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const queue: StreamEvent[] = [];
|
|
595
|
+
let stdoutDone = false, stderrDone = false;
|
|
596
|
+
let notify: (() => void) | null = null;
|
|
597
|
+
const pushWaiter = () => new Promise<void>((r) => { notify = r; });
|
|
598
|
+
|
|
599
|
+
const drainStderr = (stream: ReadableStream<Uint8Array>) => {
|
|
600
|
+
const reader = stream.getReader();
|
|
601
|
+
const dec = new TextDecoder();
|
|
602
|
+
let buf = '';
|
|
603
|
+
(async () => {
|
|
604
|
+
try {
|
|
605
|
+
while (true) {
|
|
606
|
+
const { value, done } = await reader.read();
|
|
607
|
+
if (done) break;
|
|
608
|
+
buf += dec.decode(value, { stream: true });
|
|
609
|
+
let nl: number;
|
|
610
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
611
|
+
const line = buf.slice(0, nl);
|
|
612
|
+
buf = buf.slice(nl + 1);
|
|
613
|
+
if (line.trim()) queue.push({ event_type: 'stderr', payload: { line } });
|
|
614
|
+
notify?.();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (buf.trim()) { queue.push({ event_type: 'stderr', payload: { line: buf } }); notify?.(); }
|
|
618
|
+
} finally { stderrDone = true; notify?.(); }
|
|
619
|
+
})();
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const drainStdout = (stream: ReadableStream<Uint8Array>) => {
|
|
623
|
+
const reader = stream.getReader();
|
|
624
|
+
const dec = new TextDecoder();
|
|
625
|
+
let buf = '';
|
|
626
|
+
(async () => {
|
|
627
|
+
try {
|
|
628
|
+
while (true) {
|
|
629
|
+
const { value, done } = await reader.read();
|
|
630
|
+
if (done) break;
|
|
631
|
+
buf += dec.decode(value, { stream: true });
|
|
632
|
+
let nl: number;
|
|
633
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
634
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
635
|
+
if (!line.trim()) continue;
|
|
636
|
+
if (agent.type === 'claude-code') {
|
|
637
|
+
const events = parseClaudeStreamJson(line);
|
|
638
|
+
for (const ev of events) queue.push(ev);
|
|
639
|
+
} else {
|
|
640
|
+
queue.push({ event_type: 'stdout', payload: { line } });
|
|
641
|
+
}
|
|
642
|
+
notify?.();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (buf.trim()) {
|
|
646
|
+
if (agent.type === 'claude-code') {
|
|
647
|
+
for (const ev of parseClaudeStreamJson(buf)) queue.push(ev);
|
|
648
|
+
} else queue.push({ event_type: 'stdout', payload: { line: buf } });
|
|
649
|
+
notify?.();
|
|
650
|
+
}
|
|
651
|
+
} finally { stdoutDone = true; notify?.(); }
|
|
652
|
+
})();
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
drainStderr(proc.stderr as ReadableStream<Uint8Array>);
|
|
656
|
+
drainStdout(proc.stdout as ReadableStream<Uint8Array>);
|
|
657
|
+
|
|
658
|
+
while (!stdoutDone || !stderrDone || queue.length) {
|
|
659
|
+
if (queue.length) yield queue.shift()!;
|
|
660
|
+
else await pushWaiter();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const code = await proc.exited;
|
|
664
|
+
if (code === 0) yield { event_type: 'done', payload: { code } };
|
|
665
|
+
else yield { event_type: 'error', payload: { code, message: `exited with code ${code}` } };
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function parseClaudeStreamJson(line: string): StreamEvent[] {
|
|
670
|
+
let msg: any;
|
|
671
|
+
try { msg = JSON.parse(line); } catch { return [{ event_type: 'stdout', payload: { line } }]; }
|
|
672
|
+
const out: StreamEvent[] = [];
|
|
673
|
+
const t = msg.type;
|
|
674
|
+
if (t === 'system') {
|
|
675
|
+
if (msg.subtype === 'init') {
|
|
676
|
+
out.push({ event_type: 'progress', payload: { message: `session ${msg.session_id?.slice(0, 8)} started`, model: msg.model } });
|
|
677
|
+
}
|
|
678
|
+
// skip hook chatter
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
if (t === 'assistant') {
|
|
682
|
+
const content = msg.message?.content || [];
|
|
683
|
+
for (const c of content) {
|
|
684
|
+
if (c.type === 'text' && c.text) {
|
|
685
|
+
out.push({ event_type: 'assistant_text', payload: { text: c.text } });
|
|
686
|
+
} else if (c.type === 'tool_use') {
|
|
687
|
+
out.push({ event_type: 'tool_call', payload: { tool: c.name, input: c.input, id: c.id } });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
if (t === 'user') {
|
|
693
|
+
const content = msg.message?.content || [];
|
|
694
|
+
for (const c of content) {
|
|
695
|
+
if (c.type === 'tool_result') {
|
|
696
|
+
const preview = typeof c.content === 'string' ? c.content : JSON.stringify(c.content);
|
|
697
|
+
out.push({ event_type: 'tool_result', payload: { tool_use_id: c.tool_use_id, content: preview.slice(0, 2000), truncated: preview.length > 2000 } });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
if (t === 'result') {
|
|
703
|
+
out.push({ event_type: 'result', payload: {
|
|
704
|
+
result: msg.result, duration_ms: msg.duration_ms,
|
|
705
|
+
num_turns: msg.num_turns, total_cost_usd: msg.total_cost_usd,
|
|
706
|
+
usage: msg.usage, is_error: !!msg.is_error,
|
|
707
|
+
}});
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function buildArgs(type: string, prompt: string): string[] {
|
|
714
|
+
switch (type) {
|
|
715
|
+
case 'claude-code':
|
|
716
|
+
return ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
717
|
+
case 'pi':
|
|
718
|
+
// pi: non-interactive, plain text mode, no session saved
|
|
719
|
+
return ['-p', '--mode', 'text', '--no-session', prompt];
|
|
720
|
+
case 'codex':
|
|
721
|
+
return ['-q', prompt];
|
|
722
|
+
case 'opencode':
|
|
723
|
+
return ['run', prompt];
|
|
724
|
+
case 'gemini-cli':
|
|
725
|
+
return ['-p', prompt];
|
|
726
|
+
default:
|
|
727
|
+
return [prompt];
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function cmdStatus(apiUrl: string, config: Config) {
|
|
732
|
+
if (!config.deviceId) {
|
|
733
|
+
console.log('❌ Not registered.');
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
const res = await apiClient.get<any>(`${apiUrl}/api/devices/${config.deviceId}`);
|
|
737
|
+
if (!res.success) { console.log('❌', res.error); process.exit(1); }
|
|
738
|
+
const d = res.data;
|
|
739
|
+
const pid = existsSync(PID_PATH) ? readFileSync(PID_PATH, 'utf8').trim() : null;
|
|
740
|
+
const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : 'stopped';
|
|
741
|
+
console.log(`
|
|
742
|
+
Device Status
|
|
743
|
+
=============
|
|
744
|
+
ID: ${d.id}
|
|
745
|
+
Name: ${d.name}
|
|
746
|
+
Platform: ${d.platform} ${d.arch}
|
|
747
|
+
Runtimes: ${JSON.parse(d.detected_runtimes || '[]').join(', ') || 'none'}
|
|
748
|
+
Status: ${d.status}
|
|
749
|
+
Daemon: ${daemon}
|
|
750
|
+
`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function cmdStop() {
|
|
754
|
+
if (!existsSync(PID_PATH)) { console.log('No daemon running.'); return; }
|
|
755
|
+
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
756
|
+
if (!pid || !isRunning(pid)) {
|
|
757
|
+
unlinkSync(PID_PATH);
|
|
758
|
+
console.log('Cleaned stale pidfile.');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
ensureDirs();
|
|
762
|
+
writeFileSync(STOP_PATH, '1');
|
|
763
|
+
try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function cmdLogs() {
|
|
767
|
+
if (!existsSync(LOG_PATH)) { console.log('No logs yet.'); return; }
|
|
768
|
+
const content = readFileSync(LOG_PATH, 'utf8');
|
|
769
|
+
console.log(content.split('\n').slice(-100).join('\n'));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function isRunning(pid: number): boolean {
|
|
773
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
interface Config {
|
|
777
|
+
deviceId?: string;
|
|
778
|
+
workspaceId?: string;
|
|
779
|
+
apiUrl?: string;
|
|
780
|
+
// legacy
|
|
781
|
+
agentId?: string;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function loadConfig(): Config {
|
|
785
|
+
try {
|
|
786
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
787
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
788
|
+
} catch { return {}; }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function saveConfig(config: Config) {
|
|
792
|
+
ensureDirs();
|
|
793
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
|
797
|
+
|
|
798
|
+
main().catch((err) => {
|
|
799
|
+
log(`Fatal: ${String(err)}`);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
});
|