@shipers-dev/multi 0.12.0 → 0.13.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/dist/index.js +69 -8
- 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/src/index.ts
DELETED
|
@@ -1,1784 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
import { detectAgents } from './detect';
|
|
4
|
-
import { apiClient, setAuthToken } from './client';
|
|
5
|
-
import { Database } from 'bun:sqlite';
|
|
6
|
-
import { runAcp } from './acp-runner';
|
|
7
|
-
import { runAcpx } from './acpx-runner';
|
|
8
|
-
import { STREAM_SCHEMA_VERSION, type StreamEventType, type CliStreamEmit } from '@multi/lib';
|
|
9
|
-
import { ensureWorktree } from './worktree';
|
|
10
|
-
import { materializeBundle, lastMaterializedRevision } from './materializer';
|
|
11
|
-
import { parseArgs } from 'util';
|
|
12
|
-
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
13
|
-
import { join, dirname } from 'path';
|
|
14
|
-
import pkg from '../package.json' with { type: 'json' };
|
|
15
|
-
|
|
16
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
17
|
-
const MULTI_DIR = join(HOME, '.multi');
|
|
18
|
-
const CONFIG_PATH = join(MULTI_DIR, 'config.json');
|
|
19
|
-
const PID_PATH = join(MULTI_DIR, 'agent.pid');
|
|
20
|
-
const PORT_PATH = join(MULTI_DIR, 'agent.port');
|
|
21
|
-
const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
22
|
-
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
23
|
-
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
24
|
-
const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
|
|
25
|
-
const VERSION = (pkg as { version: string }).version;
|
|
26
|
-
|
|
27
|
-
const COMMANDS = {
|
|
28
|
-
setup: 'Register this device with a workspace',
|
|
29
|
-
connect: 'Connect device to realtime hub and execute assigned tasks',
|
|
30
|
-
link: 'Link this device to an agent (agent_id required)',
|
|
31
|
-
status: 'Show current status',
|
|
32
|
-
stop: 'Stop the running daemon',
|
|
33
|
-
restart: 'Stop and relaunch the daemon in background',
|
|
34
|
-
logs: 'View execution logs',
|
|
35
|
-
reset: 'Reset acpx session for an issue (--issue <id>)',
|
|
36
|
-
} as const;
|
|
37
|
-
|
|
38
|
-
type Command = keyof typeof COMMANDS;
|
|
39
|
-
|
|
40
|
-
function ensureDirs() {
|
|
41
|
-
for (const d of [MULTI_DIR, join(MULTI_DIR, 'logs'), SKILLS_DIR]) {
|
|
42
|
-
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function log(msg: string) {
|
|
47
|
-
ensureDirs();
|
|
48
|
-
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
49
|
-
appendFileSync(LOG_PATH, line);
|
|
50
|
-
process.stdout.write(line);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function main() {
|
|
54
|
-
const rawArgs = Bun.argv.slice(2);
|
|
55
|
-
if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
|
|
56
|
-
console.log(VERSION);
|
|
57
|
-
process.exit(0);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const args = parseArgs({
|
|
61
|
-
args: Bun.argv,
|
|
62
|
-
options: {
|
|
63
|
-
help: { type: 'boolean', default: false, short: 'h' },
|
|
64
|
-
version: { type: 'boolean', default: false },
|
|
65
|
-
detach: { type: 'boolean', default: false, short: 'd' },
|
|
66
|
-
name: { type: 'string' },
|
|
67
|
-
workspace: { type: 'string' },
|
|
68
|
-
agent: { type: 'string' },
|
|
69
|
-
api: { type: 'string' },
|
|
70
|
-
issue: { type: 'string' },
|
|
71
|
-
},
|
|
72
|
-
allowPositionals: true,
|
|
73
|
-
strict: false,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const [command] = args.positionals.slice(2) as Command[];
|
|
77
|
-
|
|
78
|
-
if (args.values.help || !command) {
|
|
79
|
-
printHelp();
|
|
80
|
-
process.exit(0);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const apiUrl = args.values.api || process.env.MULTI_API_URL || 'https://multi-api.adnb3r.workers.dev';
|
|
84
|
-
const config = loadConfig();
|
|
85
|
-
if (config.token) setAuthToken(config.token);
|
|
86
|
-
|
|
87
|
-
switch (command) {
|
|
88
|
-
case 'setup':
|
|
89
|
-
await cmdSetup(args.values.name, apiUrl);
|
|
90
|
-
break;
|
|
91
|
-
case 'connect':
|
|
92
|
-
case 'start':
|
|
93
|
-
if (args.values.detach) {
|
|
94
|
-
await cmdConnectDetached(apiUrl);
|
|
95
|
-
} else {
|
|
96
|
-
await cmdConnect(apiUrl, config);
|
|
97
|
-
}
|
|
98
|
-
break;
|
|
99
|
-
case 'link':
|
|
100
|
-
await cmdLink(apiUrl, config, args.values.agent);
|
|
101
|
-
break;
|
|
102
|
-
case 'status':
|
|
103
|
-
await cmdStatus(apiUrl, config);
|
|
104
|
-
break;
|
|
105
|
-
case 'stop':
|
|
106
|
-
await cmdStop();
|
|
107
|
-
break;
|
|
108
|
-
case 'restart':
|
|
109
|
-
await cmdRestart(apiUrl);
|
|
110
|
-
break;
|
|
111
|
-
case 'logs':
|
|
112
|
-
await cmdLogs();
|
|
113
|
-
break;
|
|
114
|
-
case 'reset':
|
|
115
|
-
await cmdReset(args.values.issue);
|
|
116
|
-
break;
|
|
117
|
-
default:
|
|
118
|
-
console.error(`Unknown command: ${command}`);
|
|
119
|
-
printHelp();
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function printHelp() {
|
|
125
|
-
console.log(`
|
|
126
|
-
multi-agent v${VERSION} - Device CLI for Multi platform
|
|
127
|
-
|
|
128
|
-
Usage: multi-agent <command> [options]
|
|
129
|
-
|
|
130
|
-
Commands:
|
|
131
|
-
setup ${COMMANDS.setup}
|
|
132
|
-
link ${COMMANDS.link}
|
|
133
|
-
connect ${COMMANDS.connect}
|
|
134
|
-
status ${COMMANDS.status}
|
|
135
|
-
stop ${COMMANDS.stop}
|
|
136
|
-
restart ${COMMANDS.restart}
|
|
137
|
-
logs ${COMMANDS.logs}
|
|
138
|
-
reset ${COMMANDS.reset}
|
|
139
|
-
|
|
140
|
-
Options:
|
|
141
|
-
--name <name> Device name
|
|
142
|
-
--agent <id> Agent ID (for link)
|
|
143
|
-
--api <url> API URL
|
|
144
|
-
-d, --detach Run connect in background (daemon)
|
|
145
|
-
-v, --version Print version
|
|
146
|
-
-h, --help Show this help
|
|
147
|
-
|
|
148
|
-
Examples:
|
|
149
|
-
multi-agent setup --name "My Mac" --workspace ws_xxx
|
|
150
|
-
multi-agent link --agent agent_xxx
|
|
151
|
-
multi-agent connect
|
|
152
|
-
`);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function cmdSetup(name?: string, apiUrl?: string) {
|
|
156
|
-
ensureDirs();
|
|
157
|
-
|
|
158
|
-
console.log('🔍 Detecting local agent runtimes...');
|
|
159
|
-
const detected = await detectAgents();
|
|
160
|
-
for (const a of detected) console.log(` ✓ ${a.type} at ${a.path}`);
|
|
161
|
-
if (!detected.length) console.log(' (none detected — stub runtime will be used)');
|
|
162
|
-
|
|
163
|
-
const deviceName = name || process.env.HOSTNAME || 'Unknown Device';
|
|
164
|
-
console.log(`\n📡 Requesting pairing code for "${deviceName}"...`);
|
|
165
|
-
|
|
166
|
-
const start = await apiClient.post<{ code: string; expires_at: number }>(`${apiUrl}/api/pair/start`, {
|
|
167
|
-
name: deviceName,
|
|
168
|
-
platform: process.platform,
|
|
169
|
-
arch: process.arch,
|
|
170
|
-
os_version: process.version,
|
|
171
|
-
detected_runtimes: detected.map(a => a.type),
|
|
172
|
-
});
|
|
173
|
-
if (!start.success || !start.data) {
|
|
174
|
-
console.log('\n❌ Failed to start pairing:', start.error);
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const { code } = start.data;
|
|
179
|
-
const pairUrl = `${apiUrl}/pair/${code}`;
|
|
180
|
-
console.log(`\n👉 Open this URL in your browser to approve:\n\n ${pairUrl}\n`);
|
|
181
|
-
console.log(` Or enter code manually: ${code}`);
|
|
182
|
-
console.log('\n⏳ Waiting for approval (10 min timeout)...');
|
|
183
|
-
|
|
184
|
-
const deadline = Date.now() + 10 * 60 * 1000;
|
|
185
|
-
let approved: { device_id: string; token: string; dispatch_secret: string } | null = null;
|
|
186
|
-
while (Date.now() < deadline) {
|
|
187
|
-
await sleep(3000);
|
|
188
|
-
const poll = await apiClient.get<any>(`${apiUrl}/api/pair/poll/${code}`);
|
|
189
|
-
if (!poll.success) {
|
|
190
|
-
if (poll.status === 410) { console.log('\n❌ Pairing expired. Run setup again.'); process.exit(1); }
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (poll.data?.status === 'approved') {
|
|
194
|
-
approved = { device_id: poll.data.device_id, token: poll.data.token, dispatch_secret: poll.data.dispatch_secret };
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (!approved) { console.log('\n❌ Timed out.'); process.exit(1); }
|
|
199
|
-
|
|
200
|
-
console.log(`\n✅ Device paired. ID: ${approved.device_id}`);
|
|
201
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, apiUrl });
|
|
202
|
-
setAuthToken(approved.token);
|
|
203
|
-
|
|
204
|
-
// Fetch workspace_id from device (now authed)
|
|
205
|
-
const dev = await apiClient.get<any>(`${apiUrl}/api/devices/${approved.device_id}`);
|
|
206
|
-
const workspaceId = dev.data?.workspace_id;
|
|
207
|
-
if (workspaceId) {
|
|
208
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, workspaceId, apiUrl });
|
|
209
|
-
try { await materializeBundle(apiUrl!, approved.device_id, (m) => console.log(` ${m}`)); } catch (e) { console.log(` materialize failed: ${String(e)}`); }
|
|
210
|
-
}
|
|
211
|
-
console.log('\nNext: link to an agent with: multi-agent link --agent <agentId>');
|
|
212
|
-
console.log('Then: multi-agent connect');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function cmdLink(apiUrl: string, config: Config, agentId?: string) {
|
|
216
|
-
if (!config.deviceId) {
|
|
217
|
-
console.log('❌ Not registered. Run "multi-agent setup" first.');
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
|
-
if (!agentId) {
|
|
221
|
-
console.log('❌ Agent ID required: multi-agent link --agent <id>');
|
|
222
|
-
process.exit(1);
|
|
223
|
-
}
|
|
224
|
-
const res = await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/link`, { agent_id: agentId });
|
|
225
|
-
if (!res.success) {
|
|
226
|
-
console.log('❌ Link failed:', res.error);
|
|
227
|
-
process.exit(1);
|
|
228
|
-
}
|
|
229
|
-
console.log(`✅ Linked agent ${agentId} ↔ device ${config.deviceId}`);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
async function cmdConnect(apiUrl: string, config: Config) {
|
|
233
|
-
if (!config.deviceId || !config.token) {
|
|
234
|
-
console.log('❌ Not registered. Run "multi-agent setup" first.');
|
|
235
|
-
process.exit(1);
|
|
236
|
-
}
|
|
237
|
-
if (!config.dispatchSecret) {
|
|
238
|
-
console.log('❌ Missing dispatch secret. Re-pair via "multi-agent setup".');
|
|
239
|
-
process.exit(1);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (existsSync(PID_PATH)) {
|
|
243
|
-
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
244
|
-
if (pid && isRunning(pid)) {
|
|
245
|
-
console.log(`❌ Daemon already running (pid ${pid}).`);
|
|
246
|
-
process.exit(1);
|
|
247
|
-
}
|
|
248
|
-
unlinkSync(PID_PATH);
|
|
249
|
-
}
|
|
250
|
-
ensureDirs();
|
|
251
|
-
writeFileSync(PID_PATH, String(process.pid));
|
|
252
|
-
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
253
|
-
|
|
254
|
-
const detected = await detectAgents();
|
|
255
|
-
log(`🚀 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
|
|
256
|
-
log(` runtimes: ${detected.map(d => d.type).join(', ') || 'stub'}`);
|
|
257
|
-
|
|
258
|
-
const db = openTasksDb();
|
|
259
|
-
|
|
260
|
-
// Requeue orphaned 'running' tasks from previous crash
|
|
261
|
-
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
262
|
-
|
|
263
|
-
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
|
|
264
|
-
// Keyed by task row id — unique per dispatch. Duplicate issue_id / agent_id still
|
|
265
|
-
// serialize via busyAgents/busyIssues filters.
|
|
266
|
-
const running = new Map<string, RunEntry>();
|
|
267
|
-
|
|
268
|
-
function resolvePayloadIds(row: { payload: string; agent_id: string | null; issue_id: string | null }): { agent_id: string | null; issue_id: string | null } {
|
|
269
|
-
let agent_id = row.agent_id;
|
|
270
|
-
let issue_id = row.issue_id;
|
|
271
|
-
if (!agent_id || !issue_id) {
|
|
272
|
-
try {
|
|
273
|
-
const p = JSON.parse(row.payload) as any;
|
|
274
|
-
agent_id ??= p?.agent_id ?? null;
|
|
275
|
-
issue_id ??= p?.issue_id ?? null;
|
|
276
|
-
} catch {}
|
|
277
|
-
}
|
|
278
|
-
return { agent_id, issue_id };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
|
|
282
|
-
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v): v is string => !!v);
|
|
283
|
-
const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v): v is string => !!v);
|
|
284
|
-
const clauses: string[] = [];
|
|
285
|
-
const binds: string[] = [];
|
|
286
|
-
if (busyAgents.length) {
|
|
287
|
-
clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`);
|
|
288
|
-
binds.push(...busyAgents);
|
|
289
|
-
}
|
|
290
|
-
if (busyIssues.length) {
|
|
291
|
-
clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => '?').join(',')}))`);
|
|
292
|
-
binds.push(...busyIssues);
|
|
293
|
-
}
|
|
294
|
-
const where = clauses.length ? `AND ${clauses.join(' AND ')}` : '';
|
|
295
|
-
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
|
|
296
|
-
return db.query(sql).get(...binds) as any;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function schedule() {
|
|
300
|
-
while (running.size < MAX_DEVICE) {
|
|
301
|
-
const row = pickNext();
|
|
302
|
-
if (!row) return;
|
|
303
|
-
const ids = resolvePayloadIds(row);
|
|
304
|
-
// Defensive: if DB saw NULL agent/issue but payload has them, skip if busy.
|
|
305
|
-
if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id)) return;
|
|
306
|
-
if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id)) return;
|
|
307
|
-
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
308
|
-
const entry: RunEntry = { agentId: ids.agent_id || '', issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: '' };
|
|
309
|
-
running.set(row.id, entry);
|
|
310
|
-
void (async () => {
|
|
311
|
-
try {
|
|
312
|
-
const task = JSON.parse(row.payload);
|
|
313
|
-
await handleRunTask(apiUrl, config.deviceId!, task, detected, { runEntry: entry });
|
|
314
|
-
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
315
|
-
} catch (e) {
|
|
316
|
-
log(`task ${row.id} error: ${String(e)}`);
|
|
317
|
-
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
318
|
-
} finally {
|
|
319
|
-
running.delete(row.id);
|
|
320
|
-
queueMicrotask(() => schedule());
|
|
321
|
-
}
|
|
322
|
-
})();
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
// Local HTTP server on a free port
|
|
328
|
-
const port = await pickFreePort();
|
|
329
|
-
const expectedAuth = `Bearer ${config.dispatchSecret}`;
|
|
330
|
-
const server = Bun.serve({
|
|
331
|
-
port, hostname: '127.0.0.1',
|
|
332
|
-
fetch(req) {
|
|
333
|
-
const url = new URL(req.url);
|
|
334
|
-
if (url.pathname === '/health') return Response.json({ ok: true, device_id: config.deviceId });
|
|
335
|
-
if (url.pathname === '/run' && req.method === 'POST') {
|
|
336
|
-
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
337
|
-
return (async () => {
|
|
338
|
-
try {
|
|
339
|
-
const body = await req.json() as { task: any };
|
|
340
|
-
const t = body?.task || {};
|
|
341
|
-
const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
342
|
-
db.run(
|
|
343
|
-
"INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
|
|
344
|
-
[taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null],
|
|
345
|
-
);
|
|
346
|
-
const pos = (db.query("SELECT COUNT(*) AS c FROM tasks WHERE status = 'queued' AND created_at <= (SELECT created_at FROM tasks WHERE id = ?)").get(taskId) as any)?.c ?? 1;
|
|
347
|
-
if (t.issue_id) {
|
|
348
|
-
void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
|
|
349
|
-
}
|
|
350
|
-
// Ack dispatch back to worker so UI flips from queued → acked. Fire-and-forget:
|
|
351
|
-
// missing/failed ack is fine, stall-sweep covers it.
|
|
352
|
-
if (t.dispatch_id) {
|
|
353
|
-
void ackDispatch(apiUrl, t.dispatch_id, config.dispatchSecret!);
|
|
354
|
-
}
|
|
355
|
-
queueMicrotask(() => schedule());
|
|
356
|
-
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
357
|
-
} catch (e) {
|
|
358
|
-
return Response.json({ error: String(e) }, { status: 400 });
|
|
359
|
-
}
|
|
360
|
-
})();
|
|
361
|
-
}
|
|
362
|
-
if (url.pathname === '/reset_session' && req.method === 'POST') {
|
|
363
|
-
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
364
|
-
return (async () => {
|
|
365
|
-
try {
|
|
366
|
-
const { issue_id, agent_type } = await req.json() as { issue_id: string; agent_type?: string };
|
|
367
|
-
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
368
|
-
|
|
369
|
-
// Kill any running acpx child for this issue.
|
|
370
|
-
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
371
|
-
for (const entry of entries) {
|
|
372
|
-
entry.stopped = true;
|
|
373
|
-
entry.stopReason = 'session reset';
|
|
374
|
-
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
375
|
-
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 3000);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Close acpx session so next dispatch creates fresh. Only known acpx types.
|
|
379
|
-
const acpxTypes = agent_type ? [agent_type] : ['pi', 'codex', 'openclaw'];
|
|
380
|
-
const sessionName = `issue-${issue_id}`;
|
|
381
|
-
const cwd = process.env.HOME || process.cwd();
|
|
382
|
-
const results: { agent: string; exit: number; stderr: string }[] = [];
|
|
383
|
-
for (const t of acpxTypes) {
|
|
384
|
-
try {
|
|
385
|
-
const rm = Bun.spawn(['acpx', '--ttl', '0', '--cwd', cwd, t, 'sessions', 'close', sessionName], {
|
|
386
|
-
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
387
|
-
});
|
|
388
|
-
const err = await new Response(rm.stderr as any).text();
|
|
389
|
-
const exit = await rm.exited;
|
|
390
|
-
results.push({ agent: t, exit, stderr: err.slice(0, 200) });
|
|
391
|
-
} catch (e) {
|
|
392
|
-
results.push({ agent: t, exit: -1, stderr: String(e).slice(0, 200) });
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
log(`🔄 reset_session ${issue_id} (killed=${entries.length}) ${results.map(r => `${r.agent}:${r.exit}`).join(' ')}`);
|
|
396
|
-
return Response.json({ ok: true, killed: entries.length, sessions: results });
|
|
397
|
-
} catch (e) {
|
|
398
|
-
return Response.json({ error: String(e) }, { status: 400 });
|
|
399
|
-
}
|
|
400
|
-
})();
|
|
401
|
-
}
|
|
402
|
-
if (url.pathname === '/stop' && req.method === 'POST') {
|
|
403
|
-
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
404
|
-
return (async () => {
|
|
405
|
-
try {
|
|
406
|
-
const { issue_id } = await req.json() as { issue_id: string };
|
|
407
|
-
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
408
|
-
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
409
|
-
if (!entries.length) {
|
|
410
|
-
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
411
|
-
await markStopped(apiUrl, issue_id, 'stopped before start');
|
|
412
|
-
return Response.json({ ok: true, state: 'queued-cancelled' });
|
|
413
|
-
}
|
|
414
|
-
for (const entry of entries) {
|
|
415
|
-
entry.stopped = true;
|
|
416
|
-
entry.stopReason = 'user requested';
|
|
417
|
-
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
418
|
-
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
|
|
419
|
-
}
|
|
420
|
-
return Response.json({ ok: true, state: 'running-signalled' });
|
|
421
|
-
} catch (e) {
|
|
422
|
-
return Response.json({ error: String(e) }, { status: 400 });
|
|
423
|
-
}
|
|
424
|
-
})();
|
|
425
|
-
}
|
|
426
|
-
return new Response('not found', { status: 404 });
|
|
427
|
-
},
|
|
428
|
-
});
|
|
429
|
-
log(`🌐 Local server: http://127.0.0.1:${port}`);
|
|
430
|
-
try { writeFileSync(PORT_PATH, String(port)); } catch {}
|
|
431
|
-
|
|
432
|
-
// Spawn cloudflared quick tunnel (with self-healing on death)
|
|
433
|
-
let tunnel = await startTunnel(port);
|
|
434
|
-
if (!tunnel) {
|
|
435
|
-
log('❌ cloudflared did not emit a tunnel URL — is `cloudflared` installed? (`brew install cloudflared`)');
|
|
436
|
-
try { server.stop(); } catch {}
|
|
437
|
-
process.exit(1);
|
|
438
|
-
}
|
|
439
|
-
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
440
|
-
|
|
441
|
-
const heartbeat = async (): Promise<number> => {
|
|
442
|
-
const res = await apiClient.post<{ pending_dispatches?: number; agent_skill_revision?: number }>(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
|
|
443
|
-
if (res.success && res.data) {
|
|
444
|
-
const remoteRev = Number(res.data.agent_skill_revision ?? 0);
|
|
445
|
-
if (remoteRev > 0 && remoteRev !== lastMaterializedRevision()) {
|
|
446
|
-
try { await materializeBundle(apiUrl, config.deviceId!, log); }
|
|
447
|
-
catch (e) { log(`materialize error: ${String(e)}`); }
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return (res.success && res.data?.pending_dispatches) || 0;
|
|
451
|
-
};
|
|
452
|
-
{
|
|
453
|
-
const pending = await heartbeat();
|
|
454
|
-
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
455
|
-
}
|
|
456
|
-
// Always attempt one drain on fresh startup, covering the case where worker
|
|
457
|
-
// couldn't reach the previous daemon instance right before restart.
|
|
458
|
-
void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
459
|
-
|
|
460
|
-
let alive = true;
|
|
461
|
-
|
|
462
|
-
const shutdown = async (reason: string) => {
|
|
463
|
-
if (!alive) return;
|
|
464
|
-
alive = false;
|
|
465
|
-
log(`🛑 Shutting down (${reason})`);
|
|
466
|
-
try { server.stop(); } catch {}
|
|
467
|
-
try { tunnel?.child.kill(); } catch {}
|
|
468
|
-
try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
|
|
469
|
-
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
470
|
-
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
471
|
-
if (existsSync(PORT_PATH)) unlinkSync(PORT_PATH);
|
|
472
|
-
db.close();
|
|
473
|
-
log('👋 Disconnected');
|
|
474
|
-
process.exit(0);
|
|
475
|
-
};
|
|
476
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
477
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
478
|
-
|
|
479
|
-
// Kick the scheduler on startup to drain any leftover queued rows.
|
|
480
|
-
schedule();
|
|
481
|
-
|
|
482
|
-
// Tunnel self-heal: relaunch cloudflared if child exits or DNS stops resolving.
|
|
483
|
-
// `restarting` guards against two entries racing (probe-failure + exit-watcher
|
|
484
|
-
// both firing when we kill the child as part of our own restart).
|
|
485
|
-
let restarting = false;
|
|
486
|
-
const restartTunnel = async (reason: string) => {
|
|
487
|
-
if (!alive || restarting) return;
|
|
488
|
-
restarting = true;
|
|
489
|
-
try {
|
|
490
|
-
log(`🔁 Restarting tunnel (${reason})`);
|
|
491
|
-
const old = tunnel;
|
|
492
|
-
tunnel = null; // null first so the exit-watcher's `tunnel === t` check skips our own kill
|
|
493
|
-
try { old?.child.kill(); } catch {}
|
|
494
|
-
for (let attempt = 1; alive; attempt++) {
|
|
495
|
-
const next = await startTunnel(port);
|
|
496
|
-
if (next) {
|
|
497
|
-
tunnel = next;
|
|
498
|
-
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
499
|
-
try {
|
|
500
|
-
const pending = await heartbeat();
|
|
501
|
-
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
502
|
-
} catch (e) {
|
|
503
|
-
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
504
|
-
}
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
const wait = Math.min(30000, 2000 * attempt);
|
|
508
|
-
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
509
|
-
await sleep(wait);
|
|
510
|
-
}
|
|
511
|
-
} finally {
|
|
512
|
-
restarting = false;
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
// Watch for cloudflared process exit.
|
|
517
|
-
(async () => {
|
|
518
|
-
while (alive) {
|
|
519
|
-
const t = tunnel;
|
|
520
|
-
if (!t) { await sleep(1000); continue; }
|
|
521
|
-
const code = await t.child.exited;
|
|
522
|
-
if (!alive) return;
|
|
523
|
-
// If `tunnel` was nulled by restartTunnel, we killed it ourselves — skip.
|
|
524
|
-
if (tunnel === t) await restartTunnel(`cloudflared exited code=${code}`);
|
|
525
|
-
}
|
|
526
|
-
})();
|
|
527
|
-
|
|
528
|
-
// Heartbeat (20s) + tunnel liveness probe (every 6 ticks = ~2min).
|
|
529
|
-
let probeFailures = 0;
|
|
530
|
-
let tick = 0;
|
|
531
|
-
const PROBE_EVERY = 6;
|
|
532
|
-
while (alive) {
|
|
533
|
-
await sleep(20000);
|
|
534
|
-
if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
|
|
535
|
-
tick++;
|
|
536
|
-
const currentUrl = tunnel?.url;
|
|
537
|
-
if (currentUrl && tick % PROBE_EVERY === 0) {
|
|
538
|
-
const ok = await probeTunnel(currentUrl);
|
|
539
|
-
if (!ok) {
|
|
540
|
-
probeFailures++;
|
|
541
|
-
log(`tunnel probe failed (${probeFailures}/2): ${currentUrl}`);
|
|
542
|
-
if (probeFailures >= 2) {
|
|
543
|
-
probeFailures = 0;
|
|
544
|
-
await restartTunnel('probe failed 2x');
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
} else {
|
|
548
|
-
probeFailures = 0;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
try {
|
|
552
|
-
const pending = await heartbeat();
|
|
553
|
-
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
554
|
-
} catch (e) {
|
|
555
|
-
log(`heartbeat error: ${String(e)}`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
async function startTunnel(port: number): Promise<{ child: ReturnType<typeof Bun.spawn>; url: string } | null> {
|
|
561
|
-
const child = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
|
|
562
|
-
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
563
|
-
});
|
|
564
|
-
const url = await parseTunnelUrl(child.stderr as ReadableStream<Uint8Array>);
|
|
565
|
-
if (!url) {
|
|
566
|
-
try { child.kill(); } catch {}
|
|
567
|
-
return null;
|
|
568
|
-
}
|
|
569
|
-
return { child, url };
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
async function probeTunnel(url: string): Promise<boolean> {
|
|
573
|
-
try {
|
|
574
|
-
const ctrl = new AbortController();
|
|
575
|
-
const t = setTimeout(() => ctrl.abort(), 8000);
|
|
576
|
-
// Any HTTP response — even 404 — proves the tunnel edge is routing.
|
|
577
|
-
const res = await fetch(url, { method: 'GET', signal: ctrl.signal });
|
|
578
|
-
clearTimeout(t);
|
|
579
|
-
return res.status > 0;
|
|
580
|
-
} catch {
|
|
581
|
-
return false;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async function cmdConnectDetached(apiUrl: string) {
|
|
586
|
-
if (existsSync(PID_PATH)) {
|
|
587
|
-
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
588
|
-
if (pid && isRunning(pid)) {
|
|
589
|
-
console.log(`❌ Daemon already running (pid ${pid}).`);
|
|
590
|
-
process.exit(1);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
ensureDirs();
|
|
594
|
-
const logFd = Bun.file(LOG_PATH);
|
|
595
|
-
// Re-exec ourselves with same args minus --detach
|
|
596
|
-
const args = Bun.argv.slice(1).filter(a => a !== '-d' && a !== '--detach');
|
|
597
|
-
const proc = Bun.spawn([process.execPath, ...args, '--api', apiUrl], {
|
|
598
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
599
|
-
env: { ...process.env, MULTI_DETACHED: '1' },
|
|
600
|
-
});
|
|
601
|
-
(proc as any).unref?.();
|
|
602
|
-
// Give daemon a moment to write pidfile
|
|
603
|
-
await sleep(500);
|
|
604
|
-
console.log(`✅ Daemon started in background (pid ${proc.pid}).`);
|
|
605
|
-
console.log(` Tail logs: multi-agent logs`);
|
|
606
|
-
console.log(` Stop: multi-agent stop`);
|
|
607
|
-
process.exit(0);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async function pickFreePort(): Promise<number> {
|
|
611
|
-
// Bind to 0, read assigned port, close immediately.
|
|
612
|
-
for (let i = 0; i < 10; i++) {
|
|
613
|
-
const p = 40000 + Math.floor(Math.random() * 20000);
|
|
614
|
-
try {
|
|
615
|
-
const s = Bun.serve({ port: p, hostname: '127.0.0.1', fetch: () => new Response('ok') });
|
|
616
|
-
s.stop();
|
|
617
|
-
return p;
|
|
618
|
-
} catch {}
|
|
619
|
-
}
|
|
620
|
-
return 47832;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<string | null> {
|
|
624
|
-
const reader = stream.getReader();
|
|
625
|
-
const dec = new TextDecoder();
|
|
626
|
-
const deadline = Date.now() + 30000;
|
|
627
|
-
let buf = '';
|
|
628
|
-
while (Date.now() < deadline) {
|
|
629
|
-
const { value, done } = await reader.read();
|
|
630
|
-
if (done) break;
|
|
631
|
-
buf += dec.decode(value, { stream: true });
|
|
632
|
-
const m = buf.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/i);
|
|
633
|
-
if (m) {
|
|
634
|
-
// Keep draining in background so pipe doesn't block
|
|
635
|
-
(async () => { try { while (true) { const { done } = await reader.read(); if (done) break; } } catch {} })();
|
|
636
|
-
return m[1];
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
return null;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
interface RunEntry {
|
|
643
|
-
agentId: string;
|
|
644
|
-
issueId: string | null;
|
|
645
|
-
startedAt: number;
|
|
646
|
-
child: any | null;
|
|
647
|
-
worktreePath: string;
|
|
648
|
-
stopped?: boolean;
|
|
649
|
-
stopReason?: string;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
interface RuntimeCtx {
|
|
653
|
-
runEntry?: RunEntry;
|
|
654
|
-
refreshLocalAgents?: () => Promise<void>;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
async function markStopped(apiUrl: string, issueId: string, reason: string) {
|
|
658
|
-
try {
|
|
659
|
-
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'stopped' });
|
|
660
|
-
} catch {}
|
|
661
|
-
try {
|
|
662
|
-
await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
|
|
663
|
-
author_type: 'agent', author_id: 'daemon', author_name: 'daemon', body: `⏹ Stopped: ${reason}`,
|
|
664
|
-
});
|
|
665
|
-
} catch {}
|
|
666
|
-
await postStream(apiUrl, issueId, 'stopped', { reason });
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
|
|
670
|
-
const issueId = task.issue_id;
|
|
671
|
-
const isFollowup = !!task.followup;
|
|
672
|
-
const baseWorkingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
|
|
673
|
-
|
|
674
|
-
// Per-issue worktree isolation. Falls back to baseWorkingDir on failure or non-git.
|
|
675
|
-
let workingDir = baseWorkingDir;
|
|
676
|
-
let worktreeBranch = '';
|
|
677
|
-
if (baseWorkingDir) {
|
|
678
|
-
try {
|
|
679
|
-
const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
|
|
680
|
-
workingDir = wt.path;
|
|
681
|
-
worktreeBranch = wt.branch;
|
|
682
|
-
await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
683
|
-
} catch (e) {
|
|
684
|
-
await postStream(apiUrl, issueId, 'worktree_error', { message: fmtError(e) });
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ''}]` : ''}`);
|
|
689
|
-
|
|
690
|
-
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
|
|
691
|
-
await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
|
|
692
|
-
|
|
693
|
-
// Fetch attachments from originating comment into a scratch dir under working_dir (or tmp)
|
|
694
|
-
let attachmentRefs: { filename: string; path: string }[] = [];
|
|
695
|
-
if (task.from_comment_id) {
|
|
696
|
-
const baseDir = workingDir || join(MULTI_DIR, 'tmp', issueId);
|
|
697
|
-
const inDir = join(baseDir, '.multi-in', task.from_comment_id);
|
|
698
|
-
attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
|
|
699
|
-
if (attachmentRefs.length) log(` fetched ${attachmentRefs.length} attachment(s) → ${inDir}`);
|
|
700
|
-
}
|
|
701
|
-
const outDir = join(workingDir || join(MULTI_DIR, 'tmp', issueId), '.multi-out');
|
|
702
|
-
|
|
703
|
-
let liveCommentId: string | undefined;
|
|
704
|
-
let liveBody = '';
|
|
705
|
-
let hadError = false;
|
|
706
|
-
let hasAssistantText = false;
|
|
707
|
-
let liveCommentPromise: Promise<void> | null = null;
|
|
708
|
-
|
|
709
|
-
const ensureLiveComment = (): Promise<void> => {
|
|
710
|
-
if (liveCommentId) return Promise.resolve();
|
|
711
|
-
if (!liveCommentPromise) {
|
|
712
|
-
liveCommentPromise = apiClient.post<any>(`${apiUrl}/api/issues/${issueId}/comments`, {
|
|
713
|
-
author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body: '…',
|
|
714
|
-
}).then(res => { liveCommentId = res.data?.id; });
|
|
715
|
-
}
|
|
716
|
-
return liveCommentPromise;
|
|
717
|
-
};
|
|
718
|
-
const patchLive = async (body: string) => {
|
|
719
|
-
if (!liveCommentId) return;
|
|
720
|
-
try { await apiClient.patch(`${apiUrl}/api/issues/${issueId}/comments/${liveCommentId}`, { body: body || '…' }); } catch {}
|
|
721
|
-
};
|
|
722
|
-
const postComment = async (body: string) => {
|
|
723
|
-
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body }); } catch {}
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
type ToolEntry = { id: string; tool: string; kind?: string; status?: string; input?: any; results: string[] };
|
|
727
|
-
type Block = { kind: 'text'; text: string } | { kind: 'tool'; id: string };
|
|
728
|
-
const turn = {
|
|
729
|
-
blocks: [] as Block[],
|
|
730
|
-
tools: new Map<string, ToolEntry>(),
|
|
731
|
-
plans: [] as string[],
|
|
732
|
-
progress: [] as string[],
|
|
733
|
-
result: null as null | { duration_ms?: number; total_cost_usd?: number; stopReason?: string; is_error?: boolean },
|
|
734
|
-
error: null as null | string,
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
const appendText = (text: string) => {
|
|
738
|
-
const last = turn.blocks[turn.blocks.length - 1];
|
|
739
|
-
if (last && last.kind === 'text') last.text += text;
|
|
740
|
-
else turn.blocks.push({ kind: 'text', text });
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
const upsertTool = (id: string, patch: Partial<ToolEntry>) => {
|
|
744
|
-
const existing = turn.tools.get(id);
|
|
745
|
-
if (existing) {
|
|
746
|
-
if (patch.tool && patch.tool !== 'tool') existing.tool = patch.tool;
|
|
747
|
-
if (patch.kind) existing.kind = patch.kind;
|
|
748
|
-
if (patch.status) existing.status = patch.status;
|
|
749
|
-
if (patch.input !== undefined) existing.input = patch.input;
|
|
750
|
-
} else {
|
|
751
|
-
turn.tools.set(id, { id, tool: patch.tool || 'tool', kind: patch.kind, status: patch.status, input: patch.input, results: [] });
|
|
752
|
-
turn.blocks.push({ kind: 'tool', id });
|
|
753
|
-
}
|
|
754
|
-
};
|
|
755
|
-
|
|
756
|
-
const toolLabel = (t: ToolEntry): string => {
|
|
757
|
-
const clean = stripMd(t.tool);
|
|
758
|
-
if (clean && clean !== 'tool') return clean;
|
|
759
|
-
// Fallback from input shape
|
|
760
|
-
if (t.input && typeof t.input === 'object') {
|
|
761
|
-
if (t.input.command) return `Bash: ${String(t.input.command).split('\n')[0].slice(0, 60)}`;
|
|
762
|
-
if (t.input.file_path) return `${t.kind === 'edit' ? 'Edit' : 'Read'}: ${t.input.file_path}`;
|
|
763
|
-
if (t.input.pattern) return `Grep: ${t.input.pattern}`;
|
|
764
|
-
if (t.input.url) return `Fetch: ${t.input.url}`;
|
|
765
|
-
}
|
|
766
|
-
return (t.kind ? `${t.kind}` : 'tool');
|
|
767
|
-
};
|
|
768
|
-
|
|
769
|
-
const render = (): string => {
|
|
770
|
-
const parts: string[] = [];
|
|
771
|
-
for (const b of turn.blocks) {
|
|
772
|
-
if (b.kind === 'text') {
|
|
773
|
-
if (b.text) parts.push(b.text);
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
const t = turn.tools.get(b.id);
|
|
777
|
-
if (!t) continue;
|
|
778
|
-
const icon = statusIcon(t.status);
|
|
779
|
-
const label = toolLabel(t);
|
|
780
|
-
const head = `${icon} ${label}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
|
|
781
|
-
const body: string[] = [];
|
|
782
|
-
if (t.input !== undefined && t.input !== null) {
|
|
783
|
-
const inputStr = typeof t.input === 'object' ? JSON.stringify(t.input, null, 2) : String(t.input);
|
|
784
|
-
body.push('```json\n' + inputStr.slice(0, 2000) + '\n```');
|
|
785
|
-
}
|
|
786
|
-
if (t.results.length) {
|
|
787
|
-
const joined = t.results.join('\n').slice(-2000);
|
|
788
|
-
body.push('**Output**\n\n```\n' + joined + '\n```');
|
|
789
|
-
}
|
|
790
|
-
parts.push(`<details>\n<summary>${head}</summary>\n\n${body.join('\n\n')}\n</details>`);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (turn.plans.length) parts.push('**Plan**\n\n' + turn.plans[turn.plans.length - 1]);
|
|
794
|
-
if (turn.error) parts.push(`> ❌ ${turn.error}`);
|
|
795
|
-
|
|
796
|
-
if (turn.result) {
|
|
797
|
-
const bits: string[] = [];
|
|
798
|
-
if (turn.result.duration_ms) bits.push(`${Math.round(turn.result.duration_ms)}ms`);
|
|
799
|
-
if (turn.result.total_cost_usd) bits.push(`$${Number(turn.result.total_cost_usd).toFixed(4)}`);
|
|
800
|
-
if (turn.result.stopReason) bits.push(turn.result.stopReason);
|
|
801
|
-
if (bits.length) parts.push(`---\n_${bits.join(' · ')}_`);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
return parts.join('\n\n') || '…';
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
let renderScheduled = false;
|
|
808
|
-
const schedulePatch = () => {
|
|
809
|
-
if (renderScheduled) return;
|
|
810
|
-
renderScheduled = true;
|
|
811
|
-
queueMicrotask(async () => {
|
|
812
|
-
renderScheduled = false;
|
|
813
|
-
try { await patchLive(render()); } catch {}
|
|
814
|
-
});
|
|
815
|
-
};
|
|
816
|
-
|
|
817
|
-
const eventHandler = async (event: any) => {
|
|
818
|
-
if (event.event_type === 'error') hadError = true;
|
|
819
|
-
await postStream(apiUrl, issueId, event.event_type, event.payload);
|
|
820
|
-
|
|
821
|
-
const p: any = event.payload || {};
|
|
822
|
-
switch (event.event_type) {
|
|
823
|
-
case 'progress': {
|
|
824
|
-
if (p.message) turn.progress.push(p.message);
|
|
825
|
-
break;
|
|
826
|
-
}
|
|
827
|
-
case 'assistant_text': {
|
|
828
|
-
await ensureLiveComment();
|
|
829
|
-
appendText(p.text);
|
|
830
|
-
hasAssistantText = true;
|
|
831
|
-
schedulePatch();
|
|
832
|
-
break;
|
|
833
|
-
}
|
|
834
|
-
case 'stdout': {
|
|
835
|
-
if (p.line) {
|
|
836
|
-
await ensureLiveComment();
|
|
837
|
-
appendText((turn.blocks.length ? '\n' : '') + p.line);
|
|
838
|
-
hasAssistantText = true;
|
|
839
|
-
schedulePatch();
|
|
840
|
-
}
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
case 'tool_call': {
|
|
844
|
-
await ensureLiveComment();
|
|
845
|
-
const id = p.id || `anon-${turn.tools.size}`;
|
|
846
|
-
upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
|
|
847
|
-
schedulePatch();
|
|
848
|
-
break;
|
|
849
|
-
}
|
|
850
|
-
case 'tool_result': {
|
|
851
|
-
await ensureLiveComment();
|
|
852
|
-
const lastToolId = [...turn.blocks].reverse().find(b => b.kind === 'tool')?.id;
|
|
853
|
-
const id = p.tool_use_id || lastToolId;
|
|
854
|
-
const entry = id ? turn.tools.get(id) : undefined;
|
|
855
|
-
const content = String(p.content ?? '').trim();
|
|
856
|
-
if (entry && content) {
|
|
857
|
-
entry.results.push(content);
|
|
858
|
-
schedulePatch();
|
|
859
|
-
} else if (content) {
|
|
860
|
-
const pid = `result-${turn.tools.size}`;
|
|
861
|
-
upsertTool(pid, { tool: 'tool result' });
|
|
862
|
-
turn.tools.get(pid)!.results.push(content);
|
|
863
|
-
schedulePatch();
|
|
864
|
-
}
|
|
865
|
-
break;
|
|
866
|
-
}
|
|
867
|
-
case 'result': {
|
|
868
|
-
await ensureLiveComment();
|
|
869
|
-
if (p.is_error) hadError = true;
|
|
870
|
-
if (!hasAssistantText && p.result) {
|
|
871
|
-
appendText(p.result);
|
|
872
|
-
hasAssistantText = true;
|
|
873
|
-
}
|
|
874
|
-
turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };
|
|
875
|
-
schedulePatch();
|
|
876
|
-
break;
|
|
877
|
-
}
|
|
878
|
-
case 'error': {
|
|
879
|
-
await ensureLiveComment();
|
|
880
|
-
turn.error = p.message || 'error';
|
|
881
|
-
schedulePatch();
|
|
882
|
-
break;
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
|
|
887
|
-
// Pick per-agent-type path: claude-code → ACP; pi/others → legacy stdout runner.
|
|
888
|
-
let preferType: string | undefined;
|
|
889
|
-
try {
|
|
890
|
-
const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
891
|
-
if (a.data?.type) preferType = a.data.type;
|
|
892
|
-
} catch {}
|
|
893
|
-
const acpCapable = detected.filter(d => d.type === 'claude-code');
|
|
894
|
-
const useAcp = preferType !== 'pi' && acpCapable.length > 0 && process.env.MULTI_LEGACY !== '1';
|
|
895
|
-
// Route pi/codex/openclaw through acpx (handles these agent types natively)
|
|
896
|
-
const useAcpx = !useAcp && preferType && ['pi', 'codex', 'openclaw'].includes(preferType) && process.env.MULTI_LEGACY !== '1';
|
|
897
|
-
|
|
898
|
-
try {
|
|
899
|
-
if (useAcp) {
|
|
900
|
-
const issueContext = `## Issue ${task.key}: ${task.title}${task.description ? `\n\n${task.description}` : ''}`;
|
|
901
|
-
let userPart: string;
|
|
902
|
-
if (task.followup) {
|
|
903
|
-
const cleanFollowup = stripSelfMention(task.followup, preferType);
|
|
904
|
-
userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
|
|
905
|
-
} else {
|
|
906
|
-
userPart = stripSelfMention(issueContext, preferType);
|
|
907
|
-
}
|
|
908
|
-
if (attachmentRefs.length) {
|
|
909
|
-
const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
|
|
910
|
-
userPart += `\n\n---\nAttached input files (read them with your tools if useful):\n${lines}\n\nNote: if (and only if) you produce binary or large artifact outputs (screenshots, data exports, generated source files), write them under ${outDir}. Always put your human-facing reply in the chat response itself — do NOT write your answer as a file.`;
|
|
911
|
-
} else {
|
|
912
|
-
userPart += `\n\n---\nRespond in the chat. Only if you produce large artifact files (screenshots, data exports, generated source code), write them under ${outDir}. Do not put your answer in a file.`;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Pull agent + linked skills to construct system/context preamble
|
|
916
|
-
let preamble = '';
|
|
917
|
-
try {
|
|
918
|
-
const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
919
|
-
const agent = agentRes.data;
|
|
920
|
-
if (agent?.prompt) preamble += `# Agent instructions\n\n${agent.prompt}\n\n`;
|
|
921
|
-
|
|
922
|
-
const skillsRes = await apiClient.get<any[]>(`${apiUrl}/api/agents/${task.agent_id}/skills`);
|
|
923
|
-
const skillList = Array.isArray(skillsRes.data) ? skillsRes.data : [];
|
|
924
|
-
if (skillList.length) {
|
|
925
|
-
preamble += `# Attached skills (${skillList.length})\n\n`;
|
|
926
|
-
for (const s of skillList) {
|
|
927
|
-
const body = String(s.body || s.description || '').trim();
|
|
928
|
-
if (!body) continue;
|
|
929
|
-
preamble += `## ${s.name}${s.version ? ` v${s.version}` : ''}\n\n${body}\n\n`;
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
} catch (e) {
|
|
933
|
-
log(`preamble fetch failed: ${String(e)}`);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
937
|
-
|
|
938
|
-
const prompt = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
|
|
939
|
-
|
|
940
|
-
// Pick adapter for the agent's declared type if we have it, else first ACP-capable
|
|
941
|
-
let preferredType: string = 'claude-code';
|
|
942
|
-
try {
|
|
943
|
-
const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
944
|
-
if (a.data?.type) preferredType = a.data.type;
|
|
945
|
-
} catch {}
|
|
946
|
-
const chosen = acpCapable.find(d => d.type === preferredType) || acpCapable[0];
|
|
947
|
-
const adapterBin = await resolveAcpAdapter(chosen.type, chosen.path);
|
|
948
|
-
if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
|
|
949
|
-
log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
|
|
950
|
-
|
|
951
|
-
const startedAt = Date.now();
|
|
952
|
-
const { sessionId } = await runAcp({
|
|
953
|
-
apiUrl, issueId, deviceId, prompt,
|
|
954
|
-
sessionId: task.session_id || null,
|
|
955
|
-
adapterBin,
|
|
956
|
-
autonomy: task.autonomy_level,
|
|
957
|
-
cwd: workingDir,
|
|
958
|
-
onEvent: eventHandler,
|
|
959
|
-
onSession: async (sid) => {
|
|
960
|
-
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
|
|
961
|
-
},
|
|
962
|
-
onSpawn: (child) => {
|
|
963
|
-
if (ctx?.runEntry) {
|
|
964
|
-
ctx.runEntry.child = child;
|
|
965
|
-
ctx.runEntry.worktreePath = workingDir || '';
|
|
966
|
-
}
|
|
967
|
-
void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
|
|
968
|
-
},
|
|
969
|
-
});
|
|
970
|
-
void postStream(apiUrl, issueId, 'run_finished', { stopReason: (typeof sessionId === 'string' ? 'ok' : 'unknown'), duration_ms: Date.now() - startedAt });
|
|
971
|
-
log(` acp session ${sessionId.slice(0, 8)}`);
|
|
972
|
-
} else if (useAcpx) {
|
|
973
|
-
// Build prompt with preamble (same logic as ACP path, but as one string)
|
|
974
|
-
let preamble = '';
|
|
975
|
-
try {
|
|
976
|
-
const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
977
|
-
const agent = agentRes.data;
|
|
978
|
-
if (agent?.prompt) preamble += `# Agent instructions\n\n${agent.prompt}\n\n`;
|
|
979
|
-
const skillsRes = await apiClient.get<any[]>(`${apiUrl}/api/agents/${task.agent_id}/skills`);
|
|
980
|
-
const skillList = Array.isArray(skillsRes.data) ? skillsRes.data : [];
|
|
981
|
-
if (skillList.length) {
|
|
982
|
-
preamble += `# Attached skills (${skillList.length})\n\n`;
|
|
983
|
-
for (const s of skillList) {
|
|
984
|
-
const body = String(s.body || s.description || '').trim();
|
|
985
|
-
if (body) preamble += `## ${s.name}${s.version ? ` v${s.version}` : ''}\n\n${body}\n\n`;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
} catch {}
|
|
989
|
-
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
990
|
-
const issueContext = `## Issue ${task.key}: ${task.title}${task.description ? `\n\n${task.description}` : ''}`;
|
|
991
|
-
let userPart: string;
|
|
992
|
-
if (task.followup) {
|
|
993
|
-
const cleanFollowup = stripSelfMention(task.followup, preferType);
|
|
994
|
-
userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
|
|
995
|
-
} else {
|
|
996
|
-
userPart = stripSelfMention(issueContext, preferType);
|
|
997
|
-
}
|
|
998
|
-
if (attachmentRefs.length) {
|
|
999
|
-
const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
|
|
1000
|
-
userPart += `\n\n---\nAttached files:\n${lines}\n\nWrite generated files to: ${outDir}`;
|
|
1001
|
-
} else {
|
|
1002
|
-
userPart += `\n\n---\nWrite generated files to: ${outDir}`;
|
|
1003
|
-
}
|
|
1004
|
-
const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
|
|
1005
|
-
log(` acpx runner: ${preferType}`);
|
|
1006
|
-
const acpxStartedAt = Date.now();
|
|
1007
|
-
await runAcpx({
|
|
1008
|
-
agentType: preferType!,
|
|
1009
|
-
prompt: full,
|
|
1010
|
-
cwd: workingDir,
|
|
1011
|
-
sessionName: `issue-${issueId}`,
|
|
1012
|
-
onEvent: eventHandler,
|
|
1013
|
-
onSpawn: (child) => {
|
|
1014
|
-
if (ctx?.runEntry) {
|
|
1015
|
-
ctx.runEntry.child = child;
|
|
1016
|
-
ctx.runEntry.worktreePath = workingDir || '';
|
|
1017
|
-
}
|
|
1018
|
-
void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
|
|
1019
|
-
},
|
|
1020
|
-
});
|
|
1021
|
-
void postStream(apiUrl, issueId, 'run_finished', { stopReason: 'ok', duration_ms: Date.now() - acpxStartedAt });
|
|
1022
|
-
} else {
|
|
1023
|
-
const runner = pickRunner(detected, preferType);
|
|
1024
|
-
for await (const event of runner(task)) await eventHandler(event);
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// Upload any files the agent wrote to .multi-out as attachments on the live comment
|
|
1028
|
-
if (liveCommentId) {
|
|
1029
|
-
const n = await uploadOutputDir(apiUrl, liveCommentId, outDir);
|
|
1030
|
-
if (n > 0) log(` 📎 uploaded ${n} output file(s)`);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// Post-turn: scan agent text for `multi-plan` fenced blocks and execute mutations.
|
|
1034
|
-
if (ctx) {
|
|
1035
|
-
const fullText = turn.blocks.filter(b => b.kind === 'text').map(b => (b as any).text).join('\n');
|
|
1036
|
-
const actions = extractPlanActions(fullText);
|
|
1037
|
-
if (actions.length) {
|
|
1038
|
-
const summary = await executePlanActions(apiUrl, task, actions, ctx);
|
|
1039
|
-
if (summary) {
|
|
1040
|
-
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body: summary }); } catch {}
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// Visible fallback: agent ran but emitted nothing user-facing
|
|
1046
|
-
if (!hasAssistantText && !hadError) {
|
|
1047
|
-
const stopReason = turn.result?.stopReason || 'unknown';
|
|
1048
|
-
await postComment(`⚠️ Agent returned no output (stopReason=${stopReason}). Adapter may be stuck on a stale session — try starting a new issue or clearing session_id.`);
|
|
1049
|
-
log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (ctx?.runEntry?.stopped) {
|
|
1053
|
-
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
|
|
1054
|
-
log(` ⏹ ${task.key} stopped`);
|
|
1055
|
-
} else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
|
|
1056
|
-
else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
|
|
1057
|
-
} catch (e) {
|
|
1058
|
-
const msg = fmtError(e);
|
|
1059
|
-
if (ctx?.runEntry?.stopped) {
|
|
1060
|
-
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
|
|
1061
|
-
log(` ⏹ ${task.key} stopped (${msg})`);
|
|
1062
|
-
} else {
|
|
1063
|
-
await postStream(apiUrl, issueId, 'error', { message: msg });
|
|
1064
|
-
await postComment(`❌ spawn error: ${msg}`);
|
|
1065
|
-
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
1066
|
-
log(` ✗ ${task.key} failed: ${msg}`);
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Appended to agent prompt. Teaches the agent how to emit planning actions the
|
|
1072
|
-
// daemon will execute after the turn: create sub-issues, update any issue in
|
|
1073
|
-
// the project, or delegate to other agents.
|
|
1074
|
-
async function buildPlanningPreamble(apiUrl: string, task: any): Promise<string> {
|
|
1075
|
-
const depth = typeof task.planning_depth === 'number' ? task.planning_depth : 0;
|
|
1076
|
-
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
1077
|
-
return `# Planning (sub-task context)
|
|
1078
|
-
|
|
1079
|
-
You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-plan\` block to update your own issue's status (e.g. mark done/failed) but CANNOT create child issues or delegate further.
|
|
1080
|
-
|
|
1081
|
-
\`\`\`multi-plan
|
|
1082
|
-
{"actions":[{"type":"update","id":"<this issue id>","status":"done"}]}
|
|
1083
|
-
\`\`\`
|
|
1084
|
-
|
|
1085
|
-
`;
|
|
1086
|
-
}
|
|
1087
|
-
let projectId = '';
|
|
1088
|
-
let agentsBlock = '';
|
|
1089
|
-
try {
|
|
1090
|
-
const issueRes = await apiClient.get<any>(`${apiUrl}/api/issues/${task.issue_id}`);
|
|
1091
|
-
projectId = issueRes.data?.project_id || '';
|
|
1092
|
-
const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
1093
|
-
const workspaceId = agentRes.data?.workspace_id;
|
|
1094
|
-
if (workspaceId) {
|
|
1095
|
-
const ag = await apiClient.get<any[]>(`${apiUrl}/api/agents?workspace_id=${workspaceId}`);
|
|
1096
|
-
const list = Array.isArray(ag.data) ? ag.data : [];
|
|
1097
|
-
const others = list.filter(a => a.id !== task.agent_id);
|
|
1098
|
-
if (others.length) {
|
|
1099
|
-
agentsBlock = others.map(a => `- \`${a.id}\` (${a.name}${a.type ? `, ${a.type}` : ''})`).join('\n');
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
} catch {}
|
|
1103
|
-
|
|
1104
|
-
return `# Planning, delegation, and self-service
|
|
1105
|
-
|
|
1106
|
-
After finishing your reply, you may append ONE fenced \`multi-plan\` block. The daemon parses it and executes after your turn.
|
|
1107
|
-
|
|
1108
|
-
Issue actions:
|
|
1109
|
-
|
|
1110
|
-
\`\`\`multi-plan
|
|
1111
|
-
{"actions":[
|
|
1112
|
-
{"type":"create","title":"...","description":"...","assignee_type":"agent","assignee_id":"<agent id>"},
|
|
1113
|
-
{"type":"update","id":"<issue id>","status":"done"},
|
|
1114
|
-
{"type":"delegate","id":"<issue id>","assignee_id":"<agent id>"}
|
|
1115
|
-
]}
|
|
1116
|
-
\`\`\`
|
|
1117
|
-
|
|
1118
|
-
Agent + skill self-service (use sparingly — only when you genuinely need a new capability that isn't covered by an existing agent or skill):
|
|
1119
|
-
|
|
1120
|
-
\`\`\`multi-plan
|
|
1121
|
-
{"actions":[
|
|
1122
|
-
{"type":"agent.create","name":"refactor-bot","agent_type":"claude-code","prompt":"You refactor TS code...","skill_ids":["sk_xxx"],"allowed_tools":["Read","Edit","Bash"]},
|
|
1123
|
-
{"type":"agent.update","id":"ag_xxx","prompt":"new prompt..."},
|
|
1124
|
-
{"type":"skill.create","name":"run-tests","description":"Run the test suite","body":"---\\nname: run-tests\\n---\\n\\n# Run tests\\n..."},
|
|
1125
|
-
{"type":"skill.attach","agent_id":"ag_xxx","skill_id":"sk_yyy"},
|
|
1126
|
-
{"type":"skill.detach","agent_id":"ag_xxx","skill_id":"sk_yyy"}
|
|
1127
|
-
]}
|
|
1128
|
-
\`\`\`
|
|
1129
|
-
|
|
1130
|
-
Rules:
|
|
1131
|
-
- Omit the block if no actions are needed.
|
|
1132
|
-
- Max 10 actions per turn. Sub-caps: agent.create=2, skill.create=3 per turn.
|
|
1133
|
-
- Planning depth capped at ${PLANNING_DEPTH_LIMIT}. At depth ≥ 1 you can only \`update\` your own issue — no creates, no agents, no skills.
|
|
1134
|
-
- \`agent.create\` is auto-approved on auto-autonomy issues. Caps + rate limits prevent abuse.
|
|
1135
|
-
- \`skill.create\` ALWAYS waits for human review (skill bodies become future system prompts).
|
|
1136
|
-
- \`allowed_tools\` on a new agent must be a subset of your own tools.
|
|
1137
|
-
- \`create\` / \`update\` / \`delegate\` target issues only in the current project (${projectId || 'this project'}).
|
|
1138
|
-
|
|
1139
|
-
${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
type PlanAction =
|
|
1143
|
-
| { type: 'create'; project_id?: string; title: string; description?: string; priority?: string; assignee_type?: string; assignee_id?: string; parent_id?: string }
|
|
1144
|
-
| { type: 'update'; id: string; title?: string; description?: string; status?: string; priority?: string; assignee_type?: string; assignee_id?: string }
|
|
1145
|
-
| { type: 'delegate'; id: string; assignee_id: string }
|
|
1146
|
-
| { type: 'agent.create'; name: string; agent_type: string; prompt?: string; skill_ids?: string[]; allowed_tools?: string[] }
|
|
1147
|
-
| { type: 'agent.update'; id: string; name?: string; prompt?: string | null; allowed_tools?: string[] | null }
|
|
1148
|
-
| { type: 'skill.create'; name: string; version?: string; description?: string; body: string; files?: { path: string; content: string }[] }
|
|
1149
|
-
| { type: 'skill.attach'; agent_id: string; skill_id: string }
|
|
1150
|
-
| { type: 'skill.detach'; agent_id: string; skill_id: string };
|
|
1151
|
-
|
|
1152
|
-
// Extract JSON action blocks fenced as ```multi-plan ... ```
|
|
1153
|
-
function extractPlanActions(text: string): PlanAction[] {
|
|
1154
|
-
const out: PlanAction[] = [];
|
|
1155
|
-
if (!text) return out;
|
|
1156
|
-
const re = /```multi-plan\s*\n([\s\S]*?)\n```/g;
|
|
1157
|
-
let m: RegExpExecArray | null;
|
|
1158
|
-
while ((m = re.exec(text)) !== null) {
|
|
1159
|
-
try {
|
|
1160
|
-
const parsed = JSON.parse(m[1]);
|
|
1161
|
-
const actions = Array.isArray(parsed?.actions) ? parsed.actions : Array.isArray(parsed) ? parsed : [parsed];
|
|
1162
|
-
for (const a of actions) if (a && typeof a === 'object' && typeof a.type === 'string') out.push(a as PlanAction);
|
|
1163
|
-
} catch {}
|
|
1164
|
-
}
|
|
1165
|
-
return out;
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
const PLAN_ACTION_LIMIT = 10;
|
|
1169
|
-
const PLANNING_DEPTH_LIMIT = 10;
|
|
1170
|
-
|
|
1171
|
-
async function executePlanActions(apiUrl: string, parentTask: any, actions: PlanAction[], ctx: RuntimeCtx): Promise<string> {
|
|
1172
|
-
const lines: string[] = [];
|
|
1173
|
-
let truncated = false;
|
|
1174
|
-
if (actions.length > PLAN_ACTION_LIMIT) {
|
|
1175
|
-
truncated = true;
|
|
1176
|
-
actions = actions.slice(0, PLAN_ACTION_LIMIT);
|
|
1177
|
-
}
|
|
1178
|
-
// Prevent agent recursion: a child turn's plan cannot itself spawn more children.
|
|
1179
|
-
// `planning_depth` is carried on each dispatched task (set server-side from issue row).
|
|
1180
|
-
const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
|
|
1181
|
-
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
1182
|
-
const blocked = actions.filter(a => a.type !== 'update').length;
|
|
1183
|
-
actions = actions.filter(a => a.type === 'update');
|
|
1184
|
-
if (blocked) lines.push(`- ⚠ ${blocked} non-update action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
|
|
1185
|
-
}
|
|
1186
|
-
// Sub-caps: agent/skill creation is rare, prevent runaway turns.
|
|
1187
|
-
const SUBCAPS = { 'agent.create': 2, 'skill.create': 3, 'skill.attach': 5, 'skill.detach': 5, 'agent.update': 5 } as Record<string, number>;
|
|
1188
|
-
const counts: Record<string, number> = {};
|
|
1189
|
-
actions = actions.filter(a => {
|
|
1190
|
-
const cap = SUBCAPS[a.type];
|
|
1191
|
-
if (cap === undefined) return true;
|
|
1192
|
-
counts[a.type] = (counts[a.type] || 0) + 1;
|
|
1193
|
-
if (counts[a.type] > cap) {
|
|
1194
|
-
lines.push(`- ⚠ ${a.type} sub-cap ${cap} hit, dropping extra`);
|
|
1195
|
-
return false;
|
|
1196
|
-
}
|
|
1197
|
-
return true;
|
|
1198
|
-
});
|
|
1199
|
-
const parentId = parentTask.issue_id;
|
|
1200
|
-
const parentProjectId = await (async () => {
|
|
1201
|
-
try {
|
|
1202
|
-
const r = await apiClient.get<any>(`${apiUrl}/api/issues/${parentId}`);
|
|
1203
|
-
return r.data?.project_id;
|
|
1204
|
-
} catch { return null; }
|
|
1205
|
-
})();
|
|
1206
|
-
const headers: Record<string, string> = { 'x-agent-id': parentTask.agent_id, 'x-origin-issue-id': parentTask.issue_id };
|
|
1207
|
-
|
|
1208
|
-
// Refresh the set of agents linked to this device once per plan execution.
|
|
1209
|
-
if (typeof ctx.refreshLocalAgents === 'function') {
|
|
1210
|
-
try { await ctx.refreshLocalAgents(); } catch {}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
for (const a of actions) {
|
|
1214
|
-
try {
|
|
1215
|
-
if (a.type === 'create') {
|
|
1216
|
-
const body = {
|
|
1217
|
-
action: 'create' as const,
|
|
1218
|
-
project_id: a.project_id || parentProjectId,
|
|
1219
|
-
title: a.title, description: a.description,
|
|
1220
|
-
priority: a.priority, assignee_type: a.assignee_type, assignee_id: a.assignee_id,
|
|
1221
|
-
parent_id: a.parent_id || parentId,
|
|
1222
|
-
};
|
|
1223
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, body, { headers });
|
|
1224
|
-
if (!res.success) { lines.push(`- ❌ create "${a.title}": ${res.error || res.status}`); continue; }
|
|
1225
|
-
const created = res.data;
|
|
1226
|
-
lines.push(`- ✓ created **${created.key}** — ${created.title}${created.assignee_id ? ` → @${created.assignee_id}` : ''} (autonomy=${created.autonomy_level || 'auto'})`);
|
|
1227
|
-
} else if (a.type === 'update') {
|
|
1228
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', ...a }, { headers });
|
|
1229
|
-
if (!res.success) { lines.push(`- ❌ update ${a.id}: ${res.error || res.status}`); continue; }
|
|
1230
|
-
lines.push(`- ✓ updated ${res.data.key}`);
|
|
1231
|
-
} else if (a.type === 'delegate') {
|
|
1232
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', id: a.id, assignee_type: 'agent', assignee_id: a.assignee_id, status: 'todo' }, { headers });
|
|
1233
|
-
if (!res.success) { lines.push(`- ❌ delegate ${a.id}: ${res.error || res.status}`); continue; }
|
|
1234
|
-
lines.push(`- ✓ delegated ${res.data.key} → ${a.assignee_id}`);
|
|
1235
|
-
} else if (a.type === 'agent.create') {
|
|
1236
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action: 'create', name: a.name, type: a.agent_type, prompt: a.prompt, skill_ids: a.skill_ids, allowed_tools: a.allowed_tools }, { headers });
|
|
1237
|
-
if (!res.success) { lines.push(`- ❌ agent.create "${a.name}": ${res.error || res.status}`); continue; }
|
|
1238
|
-
if (res.data?.queued) lines.push(`- ⏳ agent.create "${a.name}" queued for human approval (op ${res.data.pending_op_id})`);
|
|
1239
|
-
else lines.push(`- ✓ agent.create "${a.name}" → ${res.data?.agent_id}`);
|
|
1240
|
-
} else if (a.type === 'agent.update') {
|
|
1241
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action: 'update', ...a }, { headers });
|
|
1242
|
-
if (!res.success) { lines.push(`- ❌ agent.update ${a.id}: ${res.error || res.status}`); continue; }
|
|
1243
|
-
if (res.data?.queued) lines.push(`- ⏳ agent.update ${a.id} queued`);
|
|
1244
|
-
else lines.push(`- ✓ agent.update ${a.id}`);
|
|
1245
|
-
} else if (a.type === 'skill.create') {
|
|
1246
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/skills/mutate`, { action: 'create', name: a.name, version: a.version, description: a.description, body: a.body, files: a.files }, { headers });
|
|
1247
|
-
if (!res.success) { lines.push(`- ❌ skill.create "${a.name}": ${res.error || res.status}`); continue; }
|
|
1248
|
-
lines.push(`- ⏳ skill.create "${a.name}" queued for human review (op ${res.data?.pending_op_id})`);
|
|
1249
|
-
} else if (a.type === 'skill.attach' || a.type === 'skill.detach') {
|
|
1250
|
-
const action = a.type === 'skill.attach' ? 'attach_skill' : 'detach_skill';
|
|
1251
|
-
const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action, agent_id: a.agent_id, skill_id: a.skill_id }, { headers });
|
|
1252
|
-
if (!res.success) { lines.push(`- ❌ ${a.type} ${a.skill_id}→${a.agent_id}: ${res.error || res.status}`); continue; }
|
|
1253
|
-
if (res.data?.queued) lines.push(`- ⏳ ${a.type} queued`);
|
|
1254
|
-
else lines.push(`- ✓ ${a.type} ${a.skill_id} ↔ ${a.agent_id}`);
|
|
1255
|
-
}
|
|
1256
|
-
} catch (e) {
|
|
1257
|
-
lines.push(`- ❌ ${a.type} failed: ${String(e)}`);
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
if (truncated) lines.push(`- ⚠ action list truncated at ${PLAN_ACTION_LIMIT}`);
|
|
1261
|
-
if (!lines.length) return '';
|
|
1262
|
-
return `**Planning actions**\n\n${lines.join('\n')}`;
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
function stripMd(s: string): string {
|
|
1266
|
-
return s.replace(/[`*_]/g, '').trim();
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// Strip leading "@agent_name" so the agent CLI doesn't treat it as a file ref (pi)
|
|
1270
|
-
// or get confused by self-address. Universally safe across runners.
|
|
1271
|
-
function stripSelfMention(prompt: string, _agentType?: string): string {
|
|
1272
|
-
return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, '').trim();
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// Best-effort error → string. Plain Error, ACP error envelopes ({error:{message,code,data}}),
|
|
1276
|
-
// fetch Response-shaped rejections, and arbitrary objects all stringify to something readable
|
|
1277
|
-
// instead of "[object Object]".
|
|
1278
|
-
function fmtError(e: any): string {
|
|
1279
|
-
if (e == null) return 'unknown error';
|
|
1280
|
-
if (typeof e === 'string') return e;
|
|
1281
|
-
if (e instanceof Error) return e.stack ? `${e.message}` : String(e);
|
|
1282
|
-
if (typeof e === 'object') {
|
|
1283
|
-
const inner = e.error ?? e.cause ?? e;
|
|
1284
|
-
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
1285
|
-
const code = inner?.code ?? e.code ?? e.status;
|
|
1286
|
-
if (msg) return code ? `${msg} (code ${code})` : String(msg);
|
|
1287
|
-
try { return JSON.stringify(e).slice(0, 500); } catch {}
|
|
1288
|
-
}
|
|
1289
|
-
return String(e);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function statusIcon(status?: string): string {
|
|
1293
|
-
switch (status) {
|
|
1294
|
-
case 'pending': return '⏳';
|
|
1295
|
-
case 'in_progress': return '🔧';
|
|
1296
|
-
case 'completed': return '✓';
|
|
1297
|
-
case 'failed': return '✗';
|
|
1298
|
-
default: return '🔧';
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
async function resolveAcpAdapter(agentType: string, detectedPath?: string): Promise<string[] | null> {
|
|
1303
|
-
// Native ACP agents (like `pi`) — invoke directly with ACP mode flag
|
|
1304
|
-
if (agentType === 'pi' && detectedPath && existsSync(detectedPath)) {
|
|
1305
|
-
return [detectedPath, '--mode', 'rpc'];
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// claude-code → claude-agent-acp adapter wrapper (stdio ACP)
|
|
1309
|
-
const adapterName = 'claude-agent-acp';
|
|
1310
|
-
const candidates = [
|
|
1311
|
-
join(HOME, '.bun', 'install', 'global', 'node_modules', '.bin', adapterName),
|
|
1312
|
-
];
|
|
1313
|
-
try {
|
|
1314
|
-
const here = new URL(import.meta.url).pathname;
|
|
1315
|
-
let dir = here;
|
|
1316
|
-
for (let i = 0; i < 8; i++) {
|
|
1317
|
-
dir = dirname(dir);
|
|
1318
|
-
const bin = join(dir, 'node_modules', '.bin', adapterName);
|
|
1319
|
-
if (existsSync(bin)) return [bin];
|
|
1320
|
-
}
|
|
1321
|
-
} catch {}
|
|
1322
|
-
for (const c of candidates) if (existsSync(c)) return [c];
|
|
1323
|
-
return null;
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
// Ack a dispatch so the worker flips its status from dispatched → acked.
|
|
1327
|
-
// Uses dispatch_secret (Bearer) so the daemon can call an unauthenticated-by-user endpoint.
|
|
1328
|
-
async function ackDispatch(apiUrl: string, dispatchId: string, secret: string) {
|
|
1329
|
-
try {
|
|
1330
|
-
await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
|
|
1331
|
-
method: 'POST',
|
|
1332
|
-
headers: { 'authorization': `Bearer ${secret}` },
|
|
1333
|
-
});
|
|
1334
|
-
} catch (e) {
|
|
1335
|
-
log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// On reconnect, pull dispatches the worker couldn't deliver and enqueue them.
|
|
1340
|
-
// Each call claims the row (offline → dispatched) atomically so parallel daemons
|
|
1341
|
-
// don't double-run the same task.
|
|
1342
|
-
async function drainOfflineDispatches(apiUrl: string, deviceId: string, secret: string, db: Database, onEnqueued: () => void) {
|
|
1343
|
-
let list: any;
|
|
1344
|
-
try {
|
|
1345
|
-
list = await apiClient.get<any>(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
|
|
1346
|
-
} catch (e) {
|
|
1347
|
-
log(`drain list failed: ${String(e)}`);
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
const rows = (list?.data?.results || list?.data || list?.results || list || []) as any[];
|
|
1351
|
-
if (!Array.isArray(rows) || rows.length === 0) return;
|
|
1352
|
-
log(`🪣 draining ${rows.length} offline dispatch(es)`);
|
|
1353
|
-
for (const r of rows) {
|
|
1354
|
-
try {
|
|
1355
|
-
const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
|
|
1356
|
-
method: 'POST',
|
|
1357
|
-
headers: { 'authorization': `Bearer ${secret}` },
|
|
1358
|
-
});
|
|
1359
|
-
if (!res.ok) { log(`claim ${r.id} skipped: ${res.status}`); continue; }
|
|
1360
|
-
const { task } = await res.json() as { task: any };
|
|
1361
|
-
const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
1362
|
-
db.run(
|
|
1363
|
-
"INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
|
|
1364
|
-
[taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null],
|
|
1365
|
-
);
|
|
1366
|
-
if (task.issue_id) void postStream(apiUrl, task.issue_id, 'queued', { drained: true });
|
|
1367
|
-
void ackDispatch(apiUrl, r.id, secret);
|
|
1368
|
-
} catch (e) {
|
|
1369
|
-
log(`drain ${r.id} failed: ${String(e)}`);
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
onEnqueued();
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
async function postStream(apiUrl: string, issueId: string, event_type: StreamEventType, payload: unknown) {
|
|
1376
|
-
// Local ndjson sink for tail -f debugging.
|
|
1377
|
-
try {
|
|
1378
|
-
ensureDirs();
|
|
1379
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
1380
|
-
const path = join(MULTI_DIR, 'logs', `events-${date}.ndjson`);
|
|
1381
|
-
appendFileSync(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + '\n');
|
|
1382
|
-
} catch {}
|
|
1383
|
-
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload }, {
|
|
1384
|
-
headers: { 'X-Stream-Version': String(STREAM_SCHEMA_VERSION) },
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
// Download attachments of a comment to a local dir. Returns list of absolute paths.
|
|
1389
|
-
async function downloadCommentAttachments(apiUrl: string, commentId: string, destDir: string): Promise<{ filename: string; path: string }[]> {
|
|
1390
|
-
try {
|
|
1391
|
-
const list = await apiClient.get<any>(`${apiUrl}/api/attachments/comments/${commentId}`);
|
|
1392
|
-
const items = list.data?.results || list.data || [];
|
|
1393
|
-
if (!Array.isArray(items) || items.length === 0) return [];
|
|
1394
|
-
mkdirSync(destDir, { recursive: true });
|
|
1395
|
-
const token = authTokenHeader();
|
|
1396
|
-
const out: { filename: string; path: string }[] = [];
|
|
1397
|
-
for (const it of items) {
|
|
1398
|
-
const res = await fetch(`${apiUrl}/api/attachments/${it.id}`, { headers: token ? { Authorization: token } : {} });
|
|
1399
|
-
if (!res.ok) continue;
|
|
1400
|
-
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1401
|
-
const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1402
|
-
const p = join(destDir, safe);
|
|
1403
|
-
writeFileSync(p, buf);
|
|
1404
|
-
out.push({ filename: it.filename, path: p });
|
|
1405
|
-
}
|
|
1406
|
-
return out;
|
|
1407
|
-
} catch { return []; }
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function authTokenHeader(): string | null {
|
|
1411
|
-
const cfg = loadConfig();
|
|
1412
|
-
return cfg.token ? `Bearer ${cfg.token}` : null;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// Scan a directory for files (recursive, shallow cap) and upload each as an attachment to a comment.
|
|
1416
|
-
async function uploadOutputDir(apiUrl: string, commentId: string, dir: string): Promise<number> {
|
|
1417
|
-
if (!existsSync(dir)) return 0;
|
|
1418
|
-
const files: string[] = [];
|
|
1419
|
-
const walk = (d: string, depth = 0) => {
|
|
1420
|
-
if (depth > 3) return;
|
|
1421
|
-
for (const name of readdirSync(d)) {
|
|
1422
|
-
const p = join(d, name);
|
|
1423
|
-
try {
|
|
1424
|
-
const st = statSync(p);
|
|
1425
|
-
if (st.isDirectory()) walk(p, depth + 1);
|
|
1426
|
-
else if (st.isFile()) files.push(p);
|
|
1427
|
-
} catch {}
|
|
1428
|
-
}
|
|
1429
|
-
};
|
|
1430
|
-
walk(dir);
|
|
1431
|
-
if (!files.length) return 0;
|
|
1432
|
-
const token = authTokenHeader();
|
|
1433
|
-
let uploaded = 0;
|
|
1434
|
-
for (const f of files) {
|
|
1435
|
-
try {
|
|
1436
|
-
const data = readFileSync(f);
|
|
1437
|
-
const form = new FormData();
|
|
1438
|
-
const blob = new Blob([data]);
|
|
1439
|
-
form.append('file', blob, f.split('/').pop() || 'file');
|
|
1440
|
-
const res = await fetch(`${apiUrl}/api/attachments/comments/${commentId}`, {
|
|
1441
|
-
method: 'POST',
|
|
1442
|
-
body: form,
|
|
1443
|
-
headers: token ? { Authorization: token } : {},
|
|
1444
|
-
});
|
|
1445
|
-
if (res.ok) { uploaded++; try { unlinkSync(f); } catch {} }
|
|
1446
|
-
} catch (e) { log(`upload failed for ${f}: ${String(e)}`); }
|
|
1447
|
-
}
|
|
1448
|
-
return uploaded;
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
type StreamEvent = CliStreamEmit;
|
|
1452
|
-
type Runner = (task: any) => AsyncGenerator<StreamEvent>;
|
|
1453
|
-
|
|
1454
|
-
function pickRunner(detected: { type: string; path: string }[], preferType?: string): Runner {
|
|
1455
|
-
const forceStub = process.env.MULTI_STUB === '1';
|
|
1456
|
-
if (forceStub || !detected.length) return stubRunner;
|
|
1457
|
-
const preferred = (preferType && detected.find(d => d.type === preferType)) || detected.find(d => d.type === 'claude-code') || detected[0];
|
|
1458
|
-
return makeCliRunner(preferred);
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
async function* stubRunner(task: any): AsyncGenerator<StreamEvent> {
|
|
1462
|
-
yield { event_type: 'stdout', payload: { line: `# ${task.key}: ${task.title}` } };
|
|
1463
|
-
await sleep(600);
|
|
1464
|
-
yield { event_type: 'stdout', payload: { line: 'Analyzing task...' } };
|
|
1465
|
-
await sleep(800);
|
|
1466
|
-
yield { event_type: 'progress', payload: { message: 'Generating solution' } };
|
|
1467
|
-
await sleep(800);
|
|
1468
|
-
yield { event_type: 'stdout', payload: { line: 'Solution ready.' } };
|
|
1469
|
-
yield { event_type: 'done', payload: { code: 0 } };
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
function makeCliRunner(agent: { type: string; path: string }): Runner {
|
|
1473
|
-
return async function* (task: any): AsyncGenerator<StreamEvent> {
|
|
1474
|
-
const base = `${task.title}\n\n${task.description || ''}`.trim();
|
|
1475
|
-
let prompt = task.followup
|
|
1476
|
-
? `${task.followup}\n\n---\nContext (original task ${task.key}): ${task.title}`
|
|
1477
|
-
: (base || task.title);
|
|
1478
|
-
prompt = stripSelfMention(prompt, agent.type);
|
|
1479
|
-
const args = buildArgs(agent.type, prompt);
|
|
1480
|
-
yield { event_type: 'progress', payload: { message: `spawning ${agent.type}`, cmd: `${agent.path} ${args.slice(0, 2).join(' ')} …` } };
|
|
1481
|
-
|
|
1482
|
-
let proc: ReturnType<typeof Bun.spawn>;
|
|
1483
|
-
try {
|
|
1484
|
-
proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', cwd: (task as any)?.working_dir || undefined });
|
|
1485
|
-
} catch (e) {
|
|
1486
|
-
yield { event_type: 'error', payload: { message: `spawn failed: ${fmtError(e)}` } };
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
const queue: StreamEvent[] = [];
|
|
1491
|
-
let stdoutDone = false, stderrDone = false;
|
|
1492
|
-
let notify: (() => void) | null = null;
|
|
1493
|
-
const pushWaiter = () => new Promise<void>((r) => { notify = r; });
|
|
1494
|
-
|
|
1495
|
-
const drainStderr = (stream: ReadableStream<Uint8Array>) => {
|
|
1496
|
-
const reader = stream.getReader();
|
|
1497
|
-
const dec = new TextDecoder();
|
|
1498
|
-
let buf = '';
|
|
1499
|
-
(async () => {
|
|
1500
|
-
try {
|
|
1501
|
-
while (true) {
|
|
1502
|
-
const { value, done } = await reader.read();
|
|
1503
|
-
if (done) break;
|
|
1504
|
-
buf += dec.decode(value, { stream: true });
|
|
1505
|
-
let nl: number;
|
|
1506
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
1507
|
-
const line = buf.slice(0, nl);
|
|
1508
|
-
buf = buf.slice(nl + 1);
|
|
1509
|
-
if (line.trim()) queue.push({ event_type: 'stderr', payload: { line } });
|
|
1510
|
-
notify?.();
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
if (buf.trim()) { queue.push({ event_type: 'stderr', payload: { line: buf } }); notify?.(); }
|
|
1514
|
-
} finally { stderrDone = true; notify?.(); }
|
|
1515
|
-
})();
|
|
1516
|
-
};
|
|
1517
|
-
|
|
1518
|
-
const drainStdout = (stream: ReadableStream<Uint8Array>) => {
|
|
1519
|
-
const reader = stream.getReader();
|
|
1520
|
-
const dec = new TextDecoder();
|
|
1521
|
-
let buf = '';
|
|
1522
|
-
(async () => {
|
|
1523
|
-
try {
|
|
1524
|
-
while (true) {
|
|
1525
|
-
const { value, done } = await reader.read();
|
|
1526
|
-
if (done) break;
|
|
1527
|
-
buf += dec.decode(value, { stream: true });
|
|
1528
|
-
let nl: number;
|
|
1529
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
1530
|
-
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
1531
|
-
if (!line.trim()) continue;
|
|
1532
|
-
if (agent.type === 'claude-code') {
|
|
1533
|
-
const events = parseClaudeStreamJson(line);
|
|
1534
|
-
for (const ev of events) queue.push(ev);
|
|
1535
|
-
} else {
|
|
1536
|
-
queue.push({ event_type: 'stdout', payload: { line } });
|
|
1537
|
-
}
|
|
1538
|
-
notify?.();
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
if (buf.trim()) {
|
|
1542
|
-
if (agent.type === 'claude-code') {
|
|
1543
|
-
for (const ev of parseClaudeStreamJson(buf)) queue.push(ev);
|
|
1544
|
-
} else queue.push({ event_type: 'stdout', payload: { line: buf } });
|
|
1545
|
-
notify?.();
|
|
1546
|
-
}
|
|
1547
|
-
} finally { stdoutDone = true; notify?.(); }
|
|
1548
|
-
})();
|
|
1549
|
-
};
|
|
1550
|
-
|
|
1551
|
-
drainStderr(proc.stderr as ReadableStream<Uint8Array>);
|
|
1552
|
-
drainStdout(proc.stdout as ReadableStream<Uint8Array>);
|
|
1553
|
-
|
|
1554
|
-
while (!stdoutDone || !stderrDone || queue.length) {
|
|
1555
|
-
if (queue.length) yield queue.shift()!;
|
|
1556
|
-
else await pushWaiter();
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
const code = await proc.exited;
|
|
1560
|
-
if (code === 0) yield { event_type: 'done', payload: { code } };
|
|
1561
|
-
else yield { event_type: 'error', payload: { code, message: `exited with code ${code}` } };
|
|
1562
|
-
};
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
function parseClaudeStreamJson(line: string): StreamEvent[] {
|
|
1566
|
-
let msg: any;
|
|
1567
|
-
try { msg = JSON.parse(line); } catch { return [{ event_type: 'stdout', payload: { line } }]; }
|
|
1568
|
-
const out: StreamEvent[] = [];
|
|
1569
|
-
const t = msg.type;
|
|
1570
|
-
if (t === 'system') {
|
|
1571
|
-
if (msg.subtype === 'init') {
|
|
1572
|
-
out.push({ event_type: 'progress', payload: { message: `session ${msg.session_id?.slice(0, 8)} started`, model: msg.model } });
|
|
1573
|
-
}
|
|
1574
|
-
// skip hook chatter
|
|
1575
|
-
return out;
|
|
1576
|
-
}
|
|
1577
|
-
if (t === 'assistant') {
|
|
1578
|
-
const content = msg.message?.content || [];
|
|
1579
|
-
for (const c of content) {
|
|
1580
|
-
if (c.type === 'text' && c.text) {
|
|
1581
|
-
out.push({ event_type: 'assistant_text', payload: { text: c.text } });
|
|
1582
|
-
} else if (c.type === 'tool_use') {
|
|
1583
|
-
out.push({ event_type: 'tool_call', payload: { tool: c.name, input: c.input, id: c.id } });
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
return out;
|
|
1587
|
-
}
|
|
1588
|
-
if (t === 'user') {
|
|
1589
|
-
const content = msg.message?.content || [];
|
|
1590
|
-
for (const c of content) {
|
|
1591
|
-
if (c.type === 'tool_result') {
|
|
1592
|
-
const preview = typeof c.content === 'string' ? c.content : JSON.stringify(c.content);
|
|
1593
|
-
out.push({ event_type: 'tool_result', payload: { tool_use_id: c.tool_use_id, content: preview.slice(0, 2000), truncated: preview.length > 2000 } });
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
return out;
|
|
1597
|
-
}
|
|
1598
|
-
if (t === 'result') {
|
|
1599
|
-
out.push({ event_type: 'result', payload: {
|
|
1600
|
-
result: msg.result, duration_ms: msg.duration_ms,
|
|
1601
|
-
num_turns: msg.num_turns, total_cost_usd: msg.total_cost_usd,
|
|
1602
|
-
usage: msg.usage, is_error: !!msg.is_error,
|
|
1603
|
-
}});
|
|
1604
|
-
return out;
|
|
1605
|
-
}
|
|
1606
|
-
return out;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function buildArgs(type: string, prompt: string): string[] {
|
|
1610
|
-
switch (type) {
|
|
1611
|
-
case 'claude-code':
|
|
1612
|
-
return ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
1613
|
-
case 'pi':
|
|
1614
|
-
// pi: non-interactive, plain text mode, no session saved
|
|
1615
|
-
return ['-p', '--mode', 'text', '--no-session', prompt];
|
|
1616
|
-
case 'codex':
|
|
1617
|
-
return ['-q', prompt];
|
|
1618
|
-
case 'opencode':
|
|
1619
|
-
return ['run', prompt];
|
|
1620
|
-
case 'gemini-cli':
|
|
1621
|
-
return ['-p', prompt];
|
|
1622
|
-
default:
|
|
1623
|
-
return [prompt];
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
async function cmdStatus(apiUrl: string, config: Config) {
|
|
1628
|
-
if (!config.deviceId) {
|
|
1629
|
-
console.log('❌ Not registered.');
|
|
1630
|
-
process.exit(1);
|
|
1631
|
-
}
|
|
1632
|
-
const res = await apiClient.get<any>(`${apiUrl}/api/devices/${config.deviceId}`);
|
|
1633
|
-
if (!res.success) { console.log('❌', res.error); process.exit(1); }
|
|
1634
|
-
const d = res.data;
|
|
1635
|
-
const pid = existsSync(PID_PATH) ? readFileSync(PID_PATH, 'utf8').trim() : null;
|
|
1636
|
-
const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : 'stopped';
|
|
1637
|
-
console.log(`
|
|
1638
|
-
Device Status
|
|
1639
|
-
=============
|
|
1640
|
-
ID: ${d.id}
|
|
1641
|
-
Name: ${d.name}
|
|
1642
|
-
Platform: ${d.platform} ${d.arch}
|
|
1643
|
-
Runtimes: ${JSON.parse(d.detected_runtimes || '[]').join(', ') || 'none'}
|
|
1644
|
-
Status: ${d.status}
|
|
1645
|
-
Daemon: ${daemon}
|
|
1646
|
-
`);
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
async function cmdStop() {
|
|
1650
|
-
if (!existsSync(PID_PATH)) { console.log('No daemon running.'); return; }
|
|
1651
|
-
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
1652
|
-
if (!pid || !isRunning(pid)) {
|
|
1653
|
-
unlinkSync(PID_PATH);
|
|
1654
|
-
console.log('Cleaned stale pidfile.');
|
|
1655
|
-
return;
|
|
1656
|
-
}
|
|
1657
|
-
ensureDirs();
|
|
1658
|
-
writeFileSync(STOP_PATH, '1');
|
|
1659
|
-
try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
async function cmdReset(issueId?: string) {
|
|
1663
|
-
if (!issueId) { console.error('Usage: multi-agent reset --issue <issue_id>'); process.exit(2); }
|
|
1664
|
-
if (!existsSync(PORT_PATH)) { console.error('Daemon not running (no port file).'); process.exit(1); }
|
|
1665
|
-
const port = Number(readFileSync(PORT_PATH, 'utf8').trim());
|
|
1666
|
-
const config = loadConfig();
|
|
1667
|
-
if (!config.dispatchSecret) { console.error('No dispatchSecret in config — run `multi-agent setup` first.'); process.exit(1); }
|
|
1668
|
-
const res = await fetch(`http://127.0.0.1:${port}/reset_session`, {
|
|
1669
|
-
method: 'POST',
|
|
1670
|
-
headers: { 'content-type': 'application/json', 'authorization': `Bearer ${config.dispatchSecret}` },
|
|
1671
|
-
body: JSON.stringify({ issue_id: issueId }),
|
|
1672
|
-
});
|
|
1673
|
-
const body = await res.text();
|
|
1674
|
-
if (!res.ok) { console.error(`Reset failed (${res.status}): ${body}`); process.exit(1); }
|
|
1675
|
-
console.log(body);
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
async function cmdRestart(apiUrl: string) {
|
|
1679
|
-
if (existsSync(PID_PATH)) {
|
|
1680
|
-
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
1681
|
-
if (pid && isRunning(pid)) {
|
|
1682
|
-
ensureDirs();
|
|
1683
|
-
writeFileSync(STOP_PATH, '1');
|
|
1684
|
-
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
1685
|
-
console.log(`⏹ Stopping daemon (pid ${pid})...`);
|
|
1686
|
-
const deadline = Date.now() + 10_000;
|
|
1687
|
-
while (Date.now() < deadline && isRunning(pid)) await sleep(200);
|
|
1688
|
-
if (isRunning(pid)) {
|
|
1689
|
-
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
1690
|
-
await sleep(300);
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
1694
|
-
}
|
|
1695
|
-
try { if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH); } catch {}
|
|
1696
|
-
console.log('🔄 Relaunching daemon...');
|
|
1697
|
-
ensureDirs();
|
|
1698
|
-
// Spawn `connect` detached (don't re-exec `restart` — would loop).
|
|
1699
|
-
const args = Bun.argv.slice(1).filter(a => a !== '-d' && a !== '--detach' && a !== 'restart');
|
|
1700
|
-
if (!args.includes('connect')) args.splice(1, 0, 'connect');
|
|
1701
|
-
const proc = Bun.spawn([process.execPath, ...args, '--api', apiUrl], {
|
|
1702
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1703
|
-
env: { ...process.env, MULTI_DETACHED: '1' },
|
|
1704
|
-
});
|
|
1705
|
-
(proc as any).unref?.();
|
|
1706
|
-
await sleep(500);
|
|
1707
|
-
console.log(`✅ Daemon restarted (pid ${proc.pid}).`);
|
|
1708
|
-
console.log(` Tail logs: multi-agent logs`);
|
|
1709
|
-
process.exit(0);
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
async function cmdLogs() {
|
|
1713
|
-
if (!existsSync(LOG_PATH)) { console.log('No logs yet.'); return; }
|
|
1714
|
-
const content = readFileSync(LOG_PATH, 'utf8');
|
|
1715
|
-
console.log(content.split('\n').slice(-100).join('\n'));
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
function isRunning(pid: number): boolean {
|
|
1719
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
interface Config {
|
|
1723
|
-
deviceId?: string;
|
|
1724
|
-
workspaceId?: string;
|
|
1725
|
-
apiUrl?: string;
|
|
1726
|
-
token?: string;
|
|
1727
|
-
dispatchSecret?: string;
|
|
1728
|
-
// legacy
|
|
1729
|
-
agentId?: string;
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
function openTasksDb(): Database {
|
|
1733
|
-
ensureDirs();
|
|
1734
|
-
const db = new Database(TASKS_DB_PATH);
|
|
1735
|
-
db.exec(`
|
|
1736
|
-
CREATE TABLE IF NOT EXISTS tasks (
|
|
1737
|
-
id TEXT PRIMARY KEY,
|
|
1738
|
-
status TEXT NOT NULL DEFAULT 'queued',
|
|
1739
|
-
payload TEXT NOT NULL,
|
|
1740
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1741
|
-
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
1742
|
-
started_at INTEGER,
|
|
1743
|
-
finished_at INTEGER,
|
|
1744
|
-
error TEXT
|
|
1745
|
-
);
|
|
1746
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
1747
|
-
`);
|
|
1748
|
-
// Idempotent migrations: add agent_id/issue_id columns if missing.
|
|
1749
|
-
const cols = db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
|
|
1750
|
-
const have = new Set(cols.map((c) => c.name));
|
|
1751
|
-
if (!have.has('agent_id')) db.exec('ALTER TABLE tasks ADD COLUMN agent_id TEXT');
|
|
1752
|
-
if (!have.has('issue_id')) db.exec('ALTER TABLE tasks ADD COLUMN issue_id TEXT');
|
|
1753
|
-
db.exec(`
|
|
1754
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
|
|
1755
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
1756
|
-
`);
|
|
1757
|
-
// Old rows used 'pending'; normalize to 'queued'.
|
|
1758
|
-
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
1759
|
-
// Backfill agent_id/issue_id from payload JSON for rows predating the columns.
|
|
1760
|
-
// Without this, the scheduler cannot serialize per-agent / per-issue and fires
|
|
1761
|
-
// concurrent dispatches for the same agent on the same issue.
|
|
1762
|
-
db.run("UPDATE tasks SET agent_id = json_extract(payload, '$.agent_id') WHERE agent_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
1763
|
-
db.run("UPDATE tasks SET issue_id = json_extract(payload, '$.issue_id') WHERE issue_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
1764
|
-
return db;
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
function loadConfig(): Config {
|
|
1768
|
-
try {
|
|
1769
|
-
if (!existsSync(CONFIG_PATH)) return {};
|
|
1770
|
-
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
1771
|
-
} catch { return {}; }
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
function saveConfig(config: Config) {
|
|
1775
|
-
ensureDirs();
|
|
1776
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
|
1780
|
-
|
|
1781
|
-
main().catch((err) => {
|
|
1782
|
-
log(`Fatal: ${String(err)}`);
|
|
1783
|
-
process.exit(1);
|
|
1784
|
-
});
|