@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.
Files changed (3) hide show
  1. package/dist/index.js +162 -34
  2. package/package.json +1 -1
  3. 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 Connecting device ${config.deviceId} (pid ${process.pid})`);
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online" });
5593
- const wsUrl = apiUrl.replace(/^http/, "ws") + `/ws/devices/${config.deviceId}?token=${encodeURIComponent(config.token || "")}`;
5594
- let ws = null;
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
- ws?.close();
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
- const connect = () => {
5615
- ws = new WebSocket(wsUrl);
5616
- ws.addEventListener("open", () => log(`\uD83D\uDD0C WS connected: ${wsUrl}`));
5617
- ws.addEventListener("message", async (ev) => {
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 msg = JSON.parse(typeof ev.data === "string" ? ev.data : "");
5620
- if (msg.type === "run_task") {
5621
- await handleRunTask(apiUrl, config.deviceId, msg.task, detected);
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(`msg parse error: ${String(e)}`);
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
- ws.addEventListener("close", () => {
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.patch(`${apiUrl}/api/issues/${issueId}`, { session_id: sid });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
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(`🚀 Connecting device ${config.deviceId} (pid ${process.pid})`);
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online' });
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 { ws?.close(); } catch {}
241
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline' });
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
- const connect = () => {
251
- ws = new WebSocket(wsUrl);
252
- ws.addEventListener('open', () => log(`🔌 WS connected: ${wsUrl}`));
253
- ws.addEventListener('message', async (ev) => {
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 msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
256
- if (msg.type === 'run_task') {
257
- await handleRunTask(apiUrl, config.deviceId!, msg.task, detected);
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(`msg parse error: ${String(e)}`);
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
- ws.addEventListener('close', () => {
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.patch(`${apiUrl}/api/issues/${issueId}`, { session_id: sid } as any); } catch {}
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 {};