@shipers-dev/multi 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +162 -34
- package/package.json +1 -1
- package/src/index.ts +146 -33
package/dist/index.js
CHANGED
|
@@ -101,6 +101,9 @@ var apiClient = {
|
|
|
101
101
|
delete: (url) => request(url, { method: "DELETE" })
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
// src/index.ts
|
|
105
|
+
import { Database } from "bun:sqlite";
|
|
106
|
+
|
|
104
107
|
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
105
108
|
var exports_external = {};
|
|
106
109
|
__export(exports_external, {
|
|
@@ -5377,6 +5380,8 @@ var PID_PATH = join(MULTI_DIR, "agent.pid");
|
|
|
5377
5380
|
var LOG_PATH = join(MULTI_DIR, "logs", "agent.log");
|
|
5378
5381
|
var SKILLS_DIR = join(MULTI_DIR, "skills");
|
|
5379
5382
|
var STOP_PATH = join(MULTI_DIR, "stop.flag");
|
|
5383
|
+
var TASKS_DB_PATH = join(MULTI_DIR, "tasks.db");
|
|
5384
|
+
var VERSION = "0.4.2";
|
|
5380
5385
|
var COMMANDS = {
|
|
5381
5386
|
setup: "Register this device with a workspace",
|
|
5382
5387
|
connect: "Connect device to realtime hub and execute assigned tasks",
|
|
@@ -5399,16 +5404,23 @@ function log(msg) {
|
|
|
5399
5404
|
process.stdout.write(line);
|
|
5400
5405
|
}
|
|
5401
5406
|
async function main() {
|
|
5407
|
+
const rawArgs = Bun.argv.slice(2);
|
|
5408
|
+
if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
|
|
5409
|
+
console.log(VERSION);
|
|
5410
|
+
process.exit(0);
|
|
5411
|
+
}
|
|
5402
5412
|
const args = parseArgs({
|
|
5403
5413
|
args: Bun.argv,
|
|
5404
5414
|
options: {
|
|
5405
|
-
help: { type: "boolean", default: false },
|
|
5415
|
+
help: { type: "boolean", default: false, short: "h" },
|
|
5416
|
+
version: { type: "boolean", default: false },
|
|
5406
5417
|
name: { type: "string" },
|
|
5407
5418
|
workspace: { type: "string" },
|
|
5408
5419
|
agent: { type: "string" },
|
|
5409
5420
|
api: { type: "string" }
|
|
5410
5421
|
},
|
|
5411
|
-
allowPositionals: true
|
|
5422
|
+
allowPositionals: true,
|
|
5423
|
+
strict: false
|
|
5412
5424
|
});
|
|
5413
5425
|
const [command] = args.positionals.slice(2);
|
|
5414
5426
|
if (args.values.help || !command) {
|
|
@@ -5447,7 +5459,7 @@ async function main() {
|
|
|
5447
5459
|
}
|
|
5448
5460
|
function printHelp() {
|
|
5449
5461
|
console.log(`
|
|
5450
|
-
multi-agent - Device CLI for Multi platform
|
|
5462
|
+
multi-agent v${VERSION} - Device CLI for Multi platform
|
|
5451
5463
|
|
|
5452
5464
|
Usage: multi-agent <command> [options]
|
|
5453
5465
|
|
|
@@ -5519,7 +5531,7 @@ async function cmdSetup(name, apiUrl) {
|
|
|
5519
5531
|
continue;
|
|
5520
5532
|
}
|
|
5521
5533
|
if (poll.data?.status === "approved") {
|
|
5522
|
-
approved = { device_id: poll.data.device_id, token: poll.data.token };
|
|
5534
|
+
approved = { device_id: poll.data.device_id, token: poll.data.token, dispatch_secret: poll.data.dispatch_secret };
|
|
5523
5535
|
break;
|
|
5524
5536
|
}
|
|
5525
5537
|
}
|
|
@@ -5530,12 +5542,12 @@ async function cmdSetup(name, apiUrl) {
|
|
|
5530
5542
|
}
|
|
5531
5543
|
console.log(`
|
|
5532
5544
|
\u2705 Device paired. ID: ${approved.device_id}`);
|
|
5533
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, apiUrl });
|
|
5545
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, apiUrl });
|
|
5534
5546
|
setAuthToken(approved.token);
|
|
5535
5547
|
const dev = await apiClient.get(`${apiUrl}/api/devices/${approved.device_id}`);
|
|
5536
5548
|
const workspaceId = dev.data?.workspace_id;
|
|
5537
5549
|
if (workspaceId) {
|
|
5538
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, workspaceId, apiUrl });
|
|
5550
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, workspaceId, apiUrl });
|
|
5539
5551
|
await syncSkills(apiUrl, workspaceId);
|
|
5540
5552
|
}
|
|
5541
5553
|
console.log(`
|
|
@@ -5570,10 +5582,14 @@ async function cmdLink(apiUrl, config, agentId) {
|
|
|
5570
5582
|
console.log(`\u2705 Linked agent ${agentId} \u2194 device ${config.deviceId}`);
|
|
5571
5583
|
}
|
|
5572
5584
|
async function cmdConnect(apiUrl, config) {
|
|
5573
|
-
if (!config.deviceId) {
|
|
5585
|
+
if (!config.deviceId || !config.token) {
|
|
5574
5586
|
console.log('\u274C Not registered. Run "multi-agent setup" first.');
|
|
5575
5587
|
process.exit(1);
|
|
5576
5588
|
}
|
|
5589
|
+
if (!config.dispatchSecret) {
|
|
5590
|
+
console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
|
|
5591
|
+
process.exit(1);
|
|
5592
|
+
}
|
|
5577
5593
|
if (existsSync2(PID_PATH)) {
|
|
5578
5594
|
const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
|
|
5579
5595
|
if (pid && isRunning(pid)) {
|
|
@@ -5587,11 +5603,63 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5587
5603
|
if (existsSync2(STOP_PATH))
|
|
5588
5604
|
unlinkSync(STOP_PATH);
|
|
5589
5605
|
const detected = await detectAgents();
|
|
5590
|
-
log(`\uD83D\uDE80
|
|
5606
|
+
log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
|
|
5591
5607
|
log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
let
|
|
5608
|
+
const db = openTasksDb();
|
|
5609
|
+
db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
|
|
5610
|
+
let workerWake = null;
|
|
5611
|
+
const notifyWorker = () => {
|
|
5612
|
+
try {
|
|
5613
|
+
workerWake?.();
|
|
5614
|
+
workerWake = null;
|
|
5615
|
+
} catch {}
|
|
5616
|
+
};
|
|
5617
|
+
const port = await pickFreePort();
|
|
5618
|
+
const expectedAuth = `Bearer ${config.dispatchSecret}`;
|
|
5619
|
+
const server = Bun.serve({
|
|
5620
|
+
port,
|
|
5621
|
+
hostname: "127.0.0.1",
|
|
5622
|
+
fetch(req) {
|
|
5623
|
+
const url = new URL(req.url);
|
|
5624
|
+
if (url.pathname === "/health")
|
|
5625
|
+
return Response.json({ ok: true, device_id: config.deviceId });
|
|
5626
|
+
if (url.pathname === "/run" && req.method === "POST") {
|
|
5627
|
+
if (req.headers.get("authorization") !== expectedAuth)
|
|
5628
|
+
return new Response("unauthorized", { status: 401 });
|
|
5629
|
+
return (async () => {
|
|
5630
|
+
try {
|
|
5631
|
+
const body = await req.json();
|
|
5632
|
+
const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
5633
|
+
db.run("INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)", [taskId, "pending", JSON.stringify(body.task)]);
|
|
5634
|
+
notifyWorker();
|
|
5635
|
+
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
5636
|
+
} catch (e) {
|
|
5637
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
5638
|
+
}
|
|
5639
|
+
})();
|
|
5640
|
+
}
|
|
5641
|
+
return new Response("not found", { status: 404 });
|
|
5642
|
+
}
|
|
5643
|
+
});
|
|
5644
|
+
log(`\uD83C\uDF10 Local server: http://127.0.0.1:${port}`);
|
|
5645
|
+
const cf = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
|
|
5646
|
+
stdout: "pipe",
|
|
5647
|
+
stderr: "pipe",
|
|
5648
|
+
stdin: "ignore"
|
|
5649
|
+
});
|
|
5650
|
+
const tunnelUrl = await parseTunnelUrl(cf.stderr);
|
|
5651
|
+
if (!tunnelUrl) {
|
|
5652
|
+
log("\u274C cloudflared did not emit a tunnel URL \u2014 is `cloudflared` installed? (`brew install cloudflared`)");
|
|
5653
|
+
try {
|
|
5654
|
+
cf.kill();
|
|
5655
|
+
} catch {}
|
|
5656
|
+
try {
|
|
5657
|
+
server.stop();
|
|
5658
|
+
} catch {}
|
|
5659
|
+
process.exit(1);
|
|
5660
|
+
}
|
|
5661
|
+
log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
|
|
5662
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
|
|
5595
5663
|
let running = true;
|
|
5596
5664
|
const shutdown = async (reason) => {
|
|
5597
5665
|
if (!running)
|
|
@@ -5599,40 +5667,45 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5599
5667
|
running = false;
|
|
5600
5668
|
log(`\uD83D\uDED1 Shutting down (${reason})`);
|
|
5601
5669
|
try {
|
|
5602
|
-
|
|
5670
|
+
server.stop();
|
|
5671
|
+
} catch {}
|
|
5672
|
+
try {
|
|
5673
|
+
cf.kill();
|
|
5674
|
+
} catch {}
|
|
5675
|
+
try {
|
|
5676
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
|
|
5603
5677
|
} catch {}
|
|
5604
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline" });
|
|
5605
5678
|
if (existsSync2(PID_PATH))
|
|
5606
5679
|
unlinkSync(PID_PATH);
|
|
5607
5680
|
if (existsSync2(STOP_PATH))
|
|
5608
5681
|
unlinkSync(STOP_PATH);
|
|
5682
|
+
db.close();
|
|
5609
5683
|
log("\uD83D\uDC4B Disconnected");
|
|
5610
5684
|
process.exit(0);
|
|
5611
5685
|
};
|
|
5612
5686
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
5613
5687
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5688
|
+
(async () => {
|
|
5689
|
+
while (running) {
|
|
5690
|
+
const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get();
|
|
5691
|
+
if (!row) {
|
|
5692
|
+
await new Promise((resolve) => {
|
|
5693
|
+
workerWake = resolve;
|
|
5694
|
+
setTimeout(resolve, 5000);
|
|
5695
|
+
});
|
|
5696
|
+
continue;
|
|
5697
|
+
}
|
|
5698
|
+
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
5618
5699
|
try {
|
|
5619
|
-
const
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
}
|
|
5700
|
+
const task = JSON.parse(row.payload);
|
|
5701
|
+
await handleRunTask(apiUrl, config.deviceId, task, detected);
|
|
5702
|
+
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
5623
5703
|
} catch (e) {
|
|
5624
|
-
log(`
|
|
5704
|
+
log(`task ${row.id} error: ${String(e)}`);
|
|
5705
|
+
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
5625
5706
|
}
|
|
5626
|
-
}
|
|
5627
|
-
|
|
5628
|
-
if (!running)
|
|
5629
|
-
return;
|
|
5630
|
-
log("\u26A0\uFE0F WS closed, reconnecting in 3s");
|
|
5631
|
-
setTimeout(connect, 3000);
|
|
5632
|
-
});
|
|
5633
|
-
ws.addEventListener("error", (e) => log(`WS error: ${String(e.message || e)}`));
|
|
5634
|
-
};
|
|
5635
|
-
connect();
|
|
5707
|
+
}
|
|
5708
|
+
})();
|
|
5636
5709
|
while (running) {
|
|
5637
5710
|
await sleep(20000);
|
|
5638
5711
|
if (existsSync2(STOP_PATH)) {
|
|
@@ -5640,12 +5713,49 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5640
5713
|
break;
|
|
5641
5714
|
}
|
|
5642
5715
|
try {
|
|
5643
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online" });
|
|
5716
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
|
|
5644
5717
|
} catch (e) {
|
|
5645
5718
|
log(`heartbeat error: ${String(e)}`);
|
|
5646
5719
|
}
|
|
5647
5720
|
}
|
|
5648
5721
|
}
|
|
5722
|
+
async function pickFreePort() {
|
|
5723
|
+
for (let i = 0;i < 10; i++) {
|
|
5724
|
+
const p = 40000 + Math.floor(Math.random() * 20000);
|
|
5725
|
+
try {
|
|
5726
|
+
const s = Bun.serve({ port: p, hostname: "127.0.0.1", fetch: () => new Response("ok") });
|
|
5727
|
+
s.stop();
|
|
5728
|
+
return p;
|
|
5729
|
+
} catch {}
|
|
5730
|
+
}
|
|
5731
|
+
return 47832;
|
|
5732
|
+
}
|
|
5733
|
+
async function parseTunnelUrl(stream2) {
|
|
5734
|
+
const reader = stream2.getReader();
|
|
5735
|
+
const dec = new TextDecoder;
|
|
5736
|
+
const deadline = Date.now() + 30000;
|
|
5737
|
+
let buf = "";
|
|
5738
|
+
while (Date.now() < deadline) {
|
|
5739
|
+
const { value, done } = await reader.read();
|
|
5740
|
+
if (done)
|
|
5741
|
+
break;
|
|
5742
|
+
buf += dec.decode(value, { stream: true });
|
|
5743
|
+
const m = buf.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/i);
|
|
5744
|
+
if (m) {
|
|
5745
|
+
(async () => {
|
|
5746
|
+
try {
|
|
5747
|
+
while (true) {
|
|
5748
|
+
const { done: done2 } = await reader.read();
|
|
5749
|
+
if (done2)
|
|
5750
|
+
break;
|
|
5751
|
+
}
|
|
5752
|
+
} catch {}
|
|
5753
|
+
})();
|
|
5754
|
+
return m[1];
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
5757
|
+
return null;
|
|
5758
|
+
}
|
|
5649
5759
|
async function handleRunTask(apiUrl, deviceId, task, detected) {
|
|
5650
5760
|
const issueId = task.issue_id;
|
|
5651
5761
|
const isFollowup = !!task.followup;
|
|
@@ -5935,7 +6045,7 @@ ${userPart}` : userPart;
|
|
|
5935
6045
|
onEvent: eventHandler,
|
|
5936
6046
|
onSession: async (sid) => {
|
|
5937
6047
|
try {
|
|
5938
|
-
await apiClient.
|
|
6048
|
+
await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid });
|
|
5939
6049
|
} catch {}
|
|
5940
6050
|
}
|
|
5941
6051
|
});
|
|
@@ -6345,6 +6455,24 @@ function isRunning(pid) {
|
|
|
6345
6455
|
return false;
|
|
6346
6456
|
}
|
|
6347
6457
|
}
|
|
6458
|
+
function openTasksDb() {
|
|
6459
|
+
ensureDirs();
|
|
6460
|
+
const db = new Database(TASKS_DB_PATH);
|
|
6461
|
+
db.exec(`
|
|
6462
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
6463
|
+
id TEXT PRIMARY KEY,
|
|
6464
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
6465
|
+
payload TEXT NOT NULL,
|
|
6466
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
6467
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
6468
|
+
started_at INTEGER,
|
|
6469
|
+
finished_at INTEGER,
|
|
6470
|
+
error TEXT
|
|
6471
|
+
);
|
|
6472
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
6473
|
+
`);
|
|
6474
|
+
return db;
|
|
6475
|
+
}
|
|
6348
6476
|
function loadConfig() {
|
|
6349
6477
|
try {
|
|
6350
6478
|
if (!existsSync2(CONFIG_PATH))
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { detectAgents } from './detect';
|
|
4
4
|
import { apiClient, setAuthToken } from './client';
|
|
5
|
+
import { Database } from 'bun:sqlite';
|
|
5
6
|
import { runAcp } from './acp-runner';
|
|
6
7
|
import { parseArgs } from 'util';
|
|
7
8
|
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
@@ -14,6 +15,8 @@ const PID_PATH = join(MULTI_DIR, 'agent.pid');
|
|
|
14
15
|
const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
15
16
|
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
16
17
|
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
18
|
+
const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
|
|
19
|
+
const VERSION = '0.4.2';
|
|
17
20
|
|
|
18
21
|
const COMMANDS = {
|
|
19
22
|
setup: 'Register this device with a workspace',
|
|
@@ -40,16 +43,24 @@ function log(msg: string) {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
async function main() {
|
|
46
|
+
const rawArgs = Bun.argv.slice(2);
|
|
47
|
+
if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
|
|
48
|
+
console.log(VERSION);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
const args = parseArgs({
|
|
44
53
|
args: Bun.argv,
|
|
45
54
|
options: {
|
|
46
|
-
help: { type: 'boolean', default: false },
|
|
55
|
+
help: { type: 'boolean', default: false, short: 'h' },
|
|
56
|
+
version: { type: 'boolean', default: false },
|
|
47
57
|
name: { type: 'string' },
|
|
48
58
|
workspace: { type: 'string' },
|
|
49
59
|
agent: { type: 'string' },
|
|
50
60
|
api: { type: 'string' },
|
|
51
61
|
},
|
|
52
62
|
allowPositionals: true,
|
|
63
|
+
strict: false,
|
|
53
64
|
});
|
|
54
65
|
|
|
55
66
|
const [command] = args.positionals.slice(2) as Command[];
|
|
@@ -92,7 +103,7 @@ async function main() {
|
|
|
92
103
|
|
|
93
104
|
function printHelp() {
|
|
94
105
|
console.log(`
|
|
95
|
-
multi-agent - Device CLI for Multi platform
|
|
106
|
+
multi-agent v${VERSION} - Device CLI for Multi platform
|
|
96
107
|
|
|
97
108
|
Usage: multi-agent <command> [options]
|
|
98
109
|
|
|
@@ -148,7 +159,7 @@ async function cmdSetup(name?: string, apiUrl?: string) {
|
|
|
148
159
|
console.log('\n⏳ Waiting for approval (10 min timeout)...');
|
|
149
160
|
|
|
150
161
|
const deadline = Date.now() + 10 * 60 * 1000;
|
|
151
|
-
let approved: { device_id: string; token: string } | null = null;
|
|
162
|
+
let approved: { device_id: string; token: string; dispatch_secret: string } | null = null;
|
|
152
163
|
while (Date.now() < deadline) {
|
|
153
164
|
await sleep(3000);
|
|
154
165
|
const poll = await apiClient.get<any>(`${apiUrl}/api/pair/poll/${code}`);
|
|
@@ -157,21 +168,21 @@ async function cmdSetup(name?: string, apiUrl?: string) {
|
|
|
157
168
|
continue;
|
|
158
169
|
}
|
|
159
170
|
if (poll.data?.status === 'approved') {
|
|
160
|
-
approved = { device_id: poll.data.device_id, token: poll.data.token };
|
|
171
|
+
approved = { device_id: poll.data.device_id, token: poll.data.token, dispatch_secret: poll.data.dispatch_secret };
|
|
161
172
|
break;
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
175
|
if (!approved) { console.log('\n❌ Timed out.'); process.exit(1); }
|
|
165
176
|
|
|
166
177
|
console.log(`\n✅ Device paired. ID: ${approved.device_id}`);
|
|
167
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, apiUrl });
|
|
178
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, apiUrl });
|
|
168
179
|
setAuthToken(approved.token);
|
|
169
180
|
|
|
170
181
|
// Fetch workspace_id from device (now authed)
|
|
171
182
|
const dev = await apiClient.get<any>(`${apiUrl}/api/devices/${approved.device_id}`);
|
|
172
183
|
const workspaceId = dev.data?.workspace_id;
|
|
173
184
|
if (workspaceId) {
|
|
174
|
-
saveConfig({ deviceId: approved.device_id, token: approved.token, workspaceId, apiUrl });
|
|
185
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, workspaceId, apiUrl });
|
|
175
186
|
await syncSkills(apiUrl!, workspaceId);
|
|
176
187
|
}
|
|
177
188
|
console.log('\nNext: link to an agent with: multi-agent link --agent <agentId>');
|
|
@@ -206,10 +217,14 @@ async function cmdLink(apiUrl: string, config: Config, agentId?: string) {
|
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
async function cmdConnect(apiUrl: string, config: Config) {
|
|
209
|
-
if (!config.deviceId) {
|
|
220
|
+
if (!config.deviceId || !config.token) {
|
|
210
221
|
console.log('❌ Not registered. Run "multi-agent setup" first.');
|
|
211
222
|
process.exit(1);
|
|
212
223
|
}
|
|
224
|
+
if (!config.dispatchSecret) {
|
|
225
|
+
console.log('❌ Missing dispatch secret. Re-pair via "multi-agent setup".');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
213
228
|
|
|
214
229
|
if (existsSync(PID_PATH)) {
|
|
215
230
|
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
@@ -224,63 +239,141 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
224
239
|
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
225
240
|
|
|
226
241
|
const detected = await detectAgents();
|
|
227
|
-
log(`🚀
|
|
242
|
+
log(`🚀 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
|
|
228
243
|
log(` runtimes: ${detected.map(d => d.type).join(', ') || 'stub'}`);
|
|
229
244
|
|
|
230
|
-
|
|
245
|
+
const db = openTasksDb();
|
|
246
|
+
|
|
247
|
+
// Requeue orphaned 'running' tasks from previous crash
|
|
248
|
+
db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
|
|
249
|
+
|
|
250
|
+
let workerWake: (() => void) | null = null;
|
|
251
|
+
const notifyWorker = () => { try { workerWake?.(); workerWake = null; } catch {} };
|
|
252
|
+
|
|
253
|
+
// Local HTTP server on a free port
|
|
254
|
+
const port = await pickFreePort();
|
|
255
|
+
const expectedAuth = `Bearer ${config.dispatchSecret}`;
|
|
256
|
+
const server = Bun.serve({
|
|
257
|
+
port, hostname: '127.0.0.1',
|
|
258
|
+
fetch(req) {
|
|
259
|
+
const url = new URL(req.url);
|
|
260
|
+
if (url.pathname === '/health') return Response.json({ ok: true, device_id: config.deviceId });
|
|
261
|
+
if (url.pathname === '/run' && req.method === 'POST') {
|
|
262
|
+
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
263
|
+
return (async () => {
|
|
264
|
+
try {
|
|
265
|
+
const body = await req.json() as { task: any };
|
|
266
|
+
const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
267
|
+
db.run('INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)', [taskId, 'pending', JSON.stringify(body.task)]);
|
|
268
|
+
notifyWorker();
|
|
269
|
+
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
}
|
|
275
|
+
return new Response('not found', { status: 404 });
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
log(`🌐 Local server: http://127.0.0.1:${port}`);
|
|
279
|
+
|
|
280
|
+
// Spawn cloudflared quick tunnel
|
|
281
|
+
const cf = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
|
|
282
|
+
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
283
|
+
});
|
|
284
|
+
const tunnelUrl = await parseTunnelUrl(cf.stderr as ReadableStream<Uint8Array>);
|
|
285
|
+
if (!tunnelUrl) {
|
|
286
|
+
log('❌ cloudflared did not emit a tunnel URL — is `cloudflared` installed? (`brew install cloudflared`)');
|
|
287
|
+
try { cf.kill(); } catch {}
|
|
288
|
+
try { server.stop(); } catch {}
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
log(`☁️ Tunnel up: ${tunnelUrl}`);
|
|
292
|
+
|
|
293
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
|
|
231
294
|
|
|
232
|
-
const wsUrl = apiUrl.replace(/^http/, 'ws') + `/ws/devices/${config.deviceId}?token=${encodeURIComponent(config.token || '')}`;
|
|
233
|
-
let ws: WebSocket | null = null;
|
|
234
295
|
let running = true;
|
|
235
296
|
|
|
236
297
|
const shutdown = async (reason: string) => {
|
|
237
298
|
if (!running) return;
|
|
238
299
|
running = false;
|
|
239
300
|
log(`🛑 Shutting down (${reason})`);
|
|
240
|
-
try {
|
|
241
|
-
|
|
301
|
+
try { server.stop(); } catch {}
|
|
302
|
+
try { cf.kill(); } catch {}
|
|
303
|
+
try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
|
|
242
304
|
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
243
305
|
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
306
|
+
db.close();
|
|
244
307
|
log('👋 Disconnected');
|
|
245
308
|
process.exit(0);
|
|
246
309
|
};
|
|
247
310
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
248
311
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
249
312
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
313
|
+
// Worker loop: drain pending tasks
|
|
314
|
+
(async () => {
|
|
315
|
+
while (running) {
|
|
316
|
+
const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get() as { id: string; payload: string } | null;
|
|
317
|
+
if (!row) {
|
|
318
|
+
await new Promise<void>(resolve => { workerWake = resolve; setTimeout(resolve, 5000); });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
254
322
|
try {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
323
|
+
const task = JSON.parse(row.payload);
|
|
324
|
+
await handleRunTask(apiUrl, config.deviceId!, task, detected);
|
|
325
|
+
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
259
326
|
} catch (e) {
|
|
260
|
-
log(`
|
|
327
|
+
log(`task ${row.id} error: ${String(e)}`);
|
|
328
|
+
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
261
329
|
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (!running) return;
|
|
265
|
-
log('⚠️ WS closed, reconnecting in 3s');
|
|
266
|
-
setTimeout(connect, 3000);
|
|
267
|
-
});
|
|
268
|
-
ws.addEventListener('error', (e) => log(`WS error: ${String((e as any).message || e)}`));
|
|
269
|
-
};
|
|
270
|
-
connect();
|
|
330
|
+
}
|
|
331
|
+
})();
|
|
271
332
|
|
|
272
333
|
// Heartbeat loop
|
|
273
334
|
while (running) {
|
|
274
335
|
await sleep(20000);
|
|
275
336
|
if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
|
|
276
337
|
try {
|
|
277
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online' });
|
|
338
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
|
|
278
339
|
} catch (e) {
|
|
279
340
|
log(`heartbeat error: ${String(e)}`);
|
|
280
341
|
}
|
|
281
342
|
}
|
|
282
343
|
}
|
|
283
344
|
|
|
345
|
+
async function pickFreePort(): Promise<number> {
|
|
346
|
+
// Bind to 0, read assigned port, close immediately.
|
|
347
|
+
for (let i = 0; i < 10; i++) {
|
|
348
|
+
const p = 40000 + Math.floor(Math.random() * 20000);
|
|
349
|
+
try {
|
|
350
|
+
const s = Bun.serve({ port: p, hostname: '127.0.0.1', fetch: () => new Response('ok') });
|
|
351
|
+
s.stop();
|
|
352
|
+
return p;
|
|
353
|
+
} catch {}
|
|
354
|
+
}
|
|
355
|
+
return 47832;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<string | null> {
|
|
359
|
+
const reader = stream.getReader();
|
|
360
|
+
const dec = new TextDecoder();
|
|
361
|
+
const deadline = Date.now() + 30000;
|
|
362
|
+
let buf = '';
|
|
363
|
+
while (Date.now() < deadline) {
|
|
364
|
+
const { value, done } = await reader.read();
|
|
365
|
+
if (done) break;
|
|
366
|
+
buf += dec.decode(value, { stream: true });
|
|
367
|
+
const m = buf.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/i);
|
|
368
|
+
if (m) {
|
|
369
|
+
// Keep draining in background so pipe doesn't block
|
|
370
|
+
(async () => { try { while (true) { const { done } = await reader.read(); if (done) break; } } catch {} })();
|
|
371
|
+
return m[1];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
284
377
|
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
|
|
285
378
|
const issueId = task.issue_id;
|
|
286
379
|
const isFollowup = !!task.followup;
|
|
@@ -522,7 +615,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
522
615
|
cwd: workingDir,
|
|
523
616
|
onEvent: eventHandler,
|
|
524
617
|
onSession: async (sid) => {
|
|
525
|
-
try { await apiClient.
|
|
618
|
+
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
|
|
526
619
|
},
|
|
527
620
|
});
|
|
528
621
|
log(` acp session ${sessionId.slice(0, 8)}`);
|
|
@@ -890,10 +983,30 @@ interface Config {
|
|
|
890
983
|
workspaceId?: string;
|
|
891
984
|
apiUrl?: string;
|
|
892
985
|
token?: string;
|
|
986
|
+
dispatchSecret?: string;
|
|
893
987
|
// legacy
|
|
894
988
|
agentId?: string;
|
|
895
989
|
}
|
|
896
990
|
|
|
991
|
+
function openTasksDb(): Database {
|
|
992
|
+
ensureDirs();
|
|
993
|
+
const db = new Database(TASKS_DB_PATH);
|
|
994
|
+
db.exec(`
|
|
995
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
996
|
+
id TEXT PRIMARY KEY,
|
|
997
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
998
|
+
payload TEXT NOT NULL,
|
|
999
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1000
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
1001
|
+
started_at INTEGER,
|
|
1002
|
+
finished_at INTEGER,
|
|
1003
|
+
error TEXT
|
|
1004
|
+
);
|
|
1005
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
1006
|
+
`);
|
|
1007
|
+
return db;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
897
1010
|
function loadConfig(): Config {
|
|
898
1011
|
try {
|
|
899
1012
|
if (!existsSync(CONFIG_PATH)) return {};
|