@shipers-dev/multi 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5185,6 +5185,9 @@ async function runAcp(opts) {
5185
5185
  cwd: opts.cwd || process.cwd(),
5186
5186
  env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode }
5187
5187
  });
5188
+ try {
5189
+ opts.onSpawn?.(child);
5190
+ } catch {}
5188
5191
  const output = new WritableStream({
5189
5192
  write(chunk) {
5190
5193
  child.stdin.write(chunk);
@@ -5449,6 +5452,9 @@ async function runAcpx(opts) {
5449
5452
  args.push(opts.prompt);
5450
5453
  dlog(`[acpx] prompt: ${args.slice(0, 10).join(" ")} ... (prompt len=${opts.prompt.length})`);
5451
5454
  const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
5455
+ try {
5456
+ opts.onSpawn?.(proc);
5457
+ } catch {}
5452
5458
  let stopReason = "end_turn";
5453
5459
  (async () => {
5454
5460
  try {
@@ -5595,31 +5601,108 @@ function extractText2(content) {
5595
5601
  return "";
5596
5602
  }
5597
5603
 
5604
+ // src/worktree.ts
5605
+ import { spawn } from "child_process";
5606
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
5607
+ import { join as join2 } from "path";
5608
+ async function run(cwd, cmd, args) {
5609
+ return await new Promise((resolve) => {
5610
+ const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
5611
+ let stdout = "";
5612
+ let stderr = "";
5613
+ p.stdout.on("data", (d) => {
5614
+ stdout += d.toString();
5615
+ });
5616
+ p.stderr.on("data", (d) => {
5617
+ stderr += d.toString();
5618
+ });
5619
+ p.on("close", (code) => resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }));
5620
+ p.on("error", (e) => resolve({ code: 1, stdout: "", stderr: String(e) }));
5621
+ });
5622
+ }
5623
+ async function isGitRepo(dir) {
5624
+ if (!existsSync2(dir))
5625
+ return false;
5626
+ const r = await run(dir, "git", ["rev-parse", "--is-inside-work-tree"]);
5627
+ return r.code === 0 && r.stdout === "true";
5628
+ }
5629
+ async function branchExists(dir, branch) {
5630
+ const r = await run(dir, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
5631
+ return r.code === 0;
5632
+ }
5633
+ function ensureGitignoreEntry(workingDir, entry) {
5634
+ const gip = join2(workingDir, ".gitignore");
5635
+ let body = "";
5636
+ try {
5637
+ body = existsSync2(gip) ? readFileSync2(gip, "utf8") : "";
5638
+ } catch {
5639
+ body = "";
5640
+ }
5641
+ const lines = body.split(`
5642
+ `);
5643
+ if (lines.some((l) => l.trim() === entry))
5644
+ return;
5645
+ const nextBody = (body.endsWith(`
5646
+ `) || body === "" ? body : body + `
5647
+ `) + entry + `
5648
+ `;
5649
+ try {
5650
+ writeFileSync2(gip, nextBody, "utf8");
5651
+ } catch {}
5652
+ }
5653
+ function normalizeKey(issueKey) {
5654
+ return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, "-");
5655
+ }
5656
+ async function ensureWorktree(workingDir, issueKey) {
5657
+ if (!await isGitRepo(workingDir)) {
5658
+ return { path: workingDir, branch: "", created: false };
5659
+ }
5660
+ ensureGitignoreEntry(workingDir, ".multi/");
5661
+ const key = normalizeKey(issueKey);
5662
+ const branch = `multi/${key}`;
5663
+ const wtDir = join2(workingDir, ".multi", "worktrees");
5664
+ const wtPath = join2(wtDir, key);
5665
+ if (existsSync2(wtPath)) {
5666
+ return { path: wtPath, branch, created: false };
5667
+ }
5668
+ try {
5669
+ mkdirSync2(wtDir, { recursive: true });
5670
+ } catch {}
5671
+ const exists = await branchExists(workingDir, branch);
5672
+ const args = exists ? ["worktree", "add", wtPath, branch] : ["worktree", "add", "-b", branch, wtPath, "HEAD"];
5673
+ const r = await run(workingDir, "git", args);
5674
+ if (r.code !== 0) {
5675
+ throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
5676
+ }
5677
+ return { path: wtPath, branch, created: true };
5678
+ }
5679
+
5598
5680
  // src/index.ts
5599
5681
  import { parseArgs } from "util";
5600
- import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5601
- import { join as join2, dirname as dirname2 } from "path";
5682
+ import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5683
+ import { join as join3, dirname as dirname2 } from "path";
5602
5684
  var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5603
- var MULTI_DIR = join2(HOME2, ".multi");
5604
- var CONFIG_PATH = join2(MULTI_DIR, "config.json");
5605
- var PID_PATH = join2(MULTI_DIR, "agent.pid");
5606
- var LOG_PATH2 = join2(MULTI_DIR, "logs", "agent.log");
5607
- var SKILLS_DIR = join2(MULTI_DIR, "skills");
5608
- var STOP_PATH = join2(MULTI_DIR, "stop.flag");
5609
- var TASKS_DB_PATH = join2(MULTI_DIR, "tasks.db");
5610
- var VERSION = "0.8.0";
5685
+ var MULTI_DIR = join3(HOME2, ".multi");
5686
+ var CONFIG_PATH = join3(MULTI_DIR, "config.json");
5687
+ var PID_PATH = join3(MULTI_DIR, "agent.pid");
5688
+ var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
5689
+ var SKILLS_DIR = join3(MULTI_DIR, "skills");
5690
+ var STOP_PATH = join3(MULTI_DIR, "stop.flag");
5691
+ var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
5692
+ var VERSION = "0.9.1";
5611
5693
  var COMMANDS = {
5612
5694
  setup: "Register this device with a workspace",
5613
5695
  connect: "Connect device to realtime hub and execute assigned tasks",
5614
5696
  link: "Link this device to an agent (agent_id required)",
5615
5697
  status: "Show current status",
5616
5698
  stop: "Stop the running daemon",
5699
+ restart: "Stop and relaunch the daemon in background",
5617
5700
  logs: "View execution logs"
5618
5701
  };
5619
5702
  function ensureDirs() {
5620
- for (const d of [MULTI_DIR, join2(MULTI_DIR, "logs"), SKILLS_DIR]) {
5621
- if (!existsSync2(d))
5622
- mkdirSync2(d, { recursive: true });
5703
+ for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
5704
+ if (!existsSync3(d))
5705
+ mkdirSync3(d, { recursive: true });
5623
5706
  }
5624
5707
  }
5625
5708
  function log(msg) {
@@ -5679,6 +5762,9 @@ async function main() {
5679
5762
  case "stop":
5680
5763
  await cmdStop();
5681
5764
  break;
5765
+ case "restart":
5766
+ await cmdRestart(apiUrl);
5767
+ break;
5682
5768
  case "logs":
5683
5769
  await cmdLogs();
5684
5770
  break;
@@ -5700,6 +5786,7 @@ Commands:
5700
5786
  connect ${COMMANDS.connect}
5701
5787
  status ${COMMANDS.status}
5702
5788
  stop ${COMMANDS.stop}
5789
+ restart ${COMMANDS.restart}
5703
5790
  logs ${COMMANDS.logs}
5704
5791
 
5705
5792
  Options:
@@ -5792,7 +5879,7 @@ async function syncSkills(apiUrl, workspaceId) {
5792
5879
  return;
5793
5880
  ensureDirs();
5794
5881
  for (const skill of res.data) {
5795
- writeFileSync2(join2(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5882
+ writeFileSync3(join3(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5796
5883
  }
5797
5884
  if (res.data.length)
5798
5885
  console.log(` Synced ${res.data.length} skill(s) \u2192 ${SKILLS_DIR}`);
@@ -5822,8 +5909,8 @@ async function cmdConnect(apiUrl, config) {
5822
5909
  console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
5823
5910
  process.exit(1);
5824
5911
  }
5825
- if (existsSync2(PID_PATH)) {
5826
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
5912
+ if (existsSync3(PID_PATH)) {
5913
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5827
5914
  if (pid && isRunning(pid)) {
5828
5915
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5829
5916
  process.exit(1);
@@ -5831,21 +5918,46 @@ async function cmdConnect(apiUrl, config) {
5831
5918
  unlinkSync(PID_PATH);
5832
5919
  }
5833
5920
  ensureDirs();
5834
- writeFileSync2(PID_PATH, String(process.pid));
5835
- if (existsSync2(STOP_PATH))
5921
+ writeFileSync3(PID_PATH, String(process.pid));
5922
+ if (existsSync3(STOP_PATH))
5836
5923
  unlinkSync(STOP_PATH);
5837
5924
  const detected = await detectAgents();
5838
5925
  log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
5839
5926
  log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
5840
5927
  const db = openTasksDb();
5841
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
5842
- let workerWake = null;
5843
- const notifyWorker = () => {
5844
- try {
5845
- workerWake?.();
5846
- workerWake = null;
5847
- } catch {}
5848
- };
5928
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
5929
+ const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
5930
+ const running = new Map;
5931
+ function pickNext() {
5932
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
5933
+ const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5934
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5935
+ return db.query(sql).get(...busyAgents);
5936
+ }
5937
+ function schedule() {
5938
+ while (running.size < MAX_DEVICE) {
5939
+ const row = pickNext();
5940
+ if (!row)
5941
+ return;
5942
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5943
+ const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5944
+ const issueKey = row.issue_id || row.id;
5945
+ running.set(issueKey, entry);
5946
+ (async () => {
5947
+ try {
5948
+ const task = JSON.parse(row.payload);
5949
+ await handleRunTask(apiUrl, config.deviceId, task, detected, { runEntry: entry });
5950
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5951
+ } catch (e) {
5952
+ log(`task ${row.id} error: ${String(e)}`);
5953
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5954
+ } finally {
5955
+ running.delete(issueKey);
5956
+ queueMicrotask(() => schedule());
5957
+ }
5958
+ })();
5959
+ }
5960
+ }
5849
5961
  const port = await pickFreePort();
5850
5962
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
5851
5963
  const server = Bun.serve({
@@ -5861,15 +5973,50 @@ async function cmdConnect(apiUrl, config) {
5861
5973
  return (async () => {
5862
5974
  try {
5863
5975
  const body = await req.json();
5864
- const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
5865
- db.run("INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)", [taskId, "pending", JSON.stringify(body.task)]);
5866
- notifyWorker();
5976
+ const t = body?.task || {};
5977
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
5978
+ db.run("INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)", [taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null]);
5979
+ 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)?.c ?? 1;
5980
+ if (t.issue_id) {
5981
+ postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
5982
+ }
5983
+ queueMicrotask(() => schedule());
5867
5984
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
5868
5985
  } catch (e) {
5869
5986
  return Response.json({ error: String(e) }, { status: 400 });
5870
5987
  }
5871
5988
  })();
5872
5989
  }
5990
+ if (url.pathname === "/stop" && req.method === "POST") {
5991
+ if (req.headers.get("authorization") !== expectedAuth)
5992
+ return new Response("unauthorized", { status: 401 });
5993
+ return (async () => {
5994
+ try {
5995
+ const { issue_id } = await req.json();
5996
+ if (!issue_id)
5997
+ return Response.json({ error: "issue_id required" }, { status: 400 });
5998
+ const entry = running.get(issue_id);
5999
+ if (!entry) {
6000
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
6001
+ await markStopped(apiUrl, issue_id, "stopped before start");
6002
+ return Response.json({ ok: true, state: "queued-cancelled" });
6003
+ }
6004
+ entry.stopped = true;
6005
+ entry.stopReason = "user requested";
6006
+ try {
6007
+ entry.child?.kill("SIGTERM");
6008
+ } catch {}
6009
+ setTimeout(() => {
6010
+ try {
6011
+ entry.child?.kill("SIGKILL");
6012
+ } catch {}
6013
+ }, 5000);
6014
+ return Response.json({ ok: true, state: "running-signalled" });
6015
+ } catch (e) {
6016
+ return Response.json({ error: String(e) }, { status: 400 });
6017
+ }
6018
+ })();
6019
+ }
5873
6020
  return new Response("not found", { status: 404 });
5874
6021
  }
5875
6022
  });
@@ -5892,11 +6039,11 @@ async function cmdConnect(apiUrl, config) {
5892
6039
  }
5893
6040
  log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
5894
6041
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
5895
- let running = true;
6042
+ let alive = true;
5896
6043
  const shutdown = async (reason) => {
5897
- if (!running)
6044
+ if (!alive)
5898
6045
  return;
5899
- running = false;
6046
+ alive = false;
5900
6047
  log(`\uD83D\uDED1 Shutting down (${reason})`);
5901
6048
  try {
5902
6049
  server.stop();
@@ -5907,9 +6054,9 @@ async function cmdConnect(apiUrl, config) {
5907
6054
  try {
5908
6055
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
5909
6056
  } catch {}
5910
- if (existsSync2(PID_PATH))
6057
+ if (existsSync3(PID_PATH))
5911
6058
  unlinkSync(PID_PATH);
5912
- if (existsSync2(STOP_PATH))
6059
+ if (existsSync3(STOP_PATH))
5913
6060
  unlinkSync(STOP_PATH);
5914
6061
  db.close();
5915
6062
  log("\uD83D\uDC4B Disconnected");
@@ -5917,30 +6064,10 @@ async function cmdConnect(apiUrl, config) {
5917
6064
  };
5918
6065
  process.on("SIGINT", () => shutdown("SIGINT"));
5919
6066
  process.on("SIGTERM", () => shutdown("SIGTERM"));
5920
- (async () => {
5921
- while (running) {
5922
- const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get();
5923
- if (!row) {
5924
- await new Promise((resolve) => {
5925
- workerWake = resolve;
5926
- setTimeout(resolve, 5000);
5927
- });
5928
- continue;
5929
- }
5930
- db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5931
- try {
5932
- const task = JSON.parse(row.payload);
5933
- await handleRunTask(apiUrl, config.deviceId, task, detected, {});
5934
- db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5935
- } catch (e) {
5936
- log(`task ${row.id} error: ${String(e)}`);
5937
- db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5938
- }
5939
- }
5940
- })();
5941
- while (running) {
6067
+ schedule();
6068
+ while (alive) {
5942
6069
  await sleep(20000);
5943
- if (existsSync2(STOP_PATH)) {
6070
+ if (existsSync3(STOP_PATH)) {
5944
6071
  await shutdown("stop flag");
5945
6072
  break;
5946
6073
  }
@@ -5952,8 +6079,8 @@ async function cmdConnect(apiUrl, config) {
5952
6079
  }
5953
6080
  }
5954
6081
  async function cmdConnectDetached(apiUrl) {
5955
- if (existsSync2(PID_PATH)) {
5956
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
6082
+ if (existsSync3(PID_PATH)) {
6083
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5957
6084
  if (pid && isRunning(pid)) {
5958
6085
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5959
6086
  process.exit(1);
@@ -6010,22 +6137,48 @@ async function parseTunnelUrl(stream2) {
6010
6137
  }
6011
6138
  return null;
6012
6139
  }
6140
+ async function markStopped(apiUrl, issueId, reason) {
6141
+ try {
6142
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "stopped" });
6143
+ } catch {}
6144
+ try {
6145
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
6146
+ author_type: "agent",
6147
+ author_id: "daemon",
6148
+ author_name: "daemon",
6149
+ body: `\u23F9 Stopped: ${reason}`
6150
+ });
6151
+ } catch {}
6152
+ await postStream(apiUrl, issueId, "stopped", { reason });
6153
+ }
6013
6154
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6014
6155
  const issueId = task.issue_id;
6015
6156
  const isFollowup = !!task.followup;
6016
- const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
6017
- log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ""}`);
6157
+ const baseWorkingDir = task.working_dir && existsSync3(task.working_dir) ? task.working_dir : undefined;
6158
+ let workingDir = baseWorkingDir;
6159
+ let worktreeBranch = "";
6160
+ if (baseWorkingDir) {
6161
+ try {
6162
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
6163
+ workingDir = wt.path;
6164
+ worktreeBranch = wt.branch;
6165
+ await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
6166
+ } catch (e) {
6167
+ await postStream(apiUrl, issueId, "worktree_error", { message: String(e) });
6168
+ }
6169
+ }
6170
+ log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
6018
6171
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
6019
6172
  await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
6020
6173
  let attachmentRefs = [];
6021
6174
  if (task.from_comment_id) {
6022
- const baseDir = workingDir || join2(MULTI_DIR, "tmp", issueId);
6023
- const inDir = join2(baseDir, ".multi-in", task.from_comment_id);
6175
+ const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
6176
+ const inDir = join3(baseDir, ".multi-in", task.from_comment_id);
6024
6177
  attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
6025
6178
  if (attachmentRefs.length)
6026
6179
  log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
6027
6180
  }
6028
- const outDir = join2(workingDir || join2(MULTI_DIR, "tmp", issueId), ".multi-out");
6181
+ const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
6029
6182
  let liveCommentId;
6030
6183
  let liveBody = "";
6031
6184
  let hadError = false;
@@ -6333,6 +6486,7 @@ ${userPart}` : userPart;
6333
6486
  if (!adapterBin)
6334
6487
  throw new Error(`ACP adapter for ${chosen.type} not found`);
6335
6488
  log(` adapter: ${chosen.type} \u2192 ${adapterBin.join(" ")}`);
6489
+ const startedAt = Date.now();
6336
6490
  const { sessionId } = await runAcp({
6337
6491
  apiUrl,
6338
6492
  issueId,
@@ -6347,8 +6501,16 @@ ${userPart}` : userPart;
6347
6501
  try {
6348
6502
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid });
6349
6503
  } catch {}
6504
+ },
6505
+ onSpawn: (child) => {
6506
+ if (ctx?.runEntry) {
6507
+ ctx.runEntry.child = child;
6508
+ ctx.runEntry.worktreePath = workingDir || "";
6509
+ }
6510
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
6350
6511
  }
6351
6512
  });
6513
+ postStream(apiUrl, issueId, "run_finished", { stopReason: typeof sessionId === "string" ? "ok" : "unknown", duration_ms: Date.now() - startedAt });
6352
6514
  log(` acp session ${sessionId.slice(0, 8)}`);
6353
6515
  } else if (useAcpx) {
6354
6516
  let preamble = "";
@@ -6416,13 +6578,22 @@ Write generated files to: ${outDir}`;
6416
6578
 
6417
6579
  ${userPart}` : userPart;
6418
6580
  log(` acpx runner: ${preferType}`);
6581
+ const acpxStartedAt = Date.now();
6419
6582
  await runAcpx({
6420
6583
  agentType: preferType,
6421
6584
  prompt: full,
6422
6585
  cwd: workingDir,
6423
6586
  sessionName: `issue-${issueId}`,
6424
- onEvent: eventHandler
6587
+ onEvent: eventHandler,
6588
+ onSpawn: (child) => {
6589
+ if (ctx?.runEntry) {
6590
+ ctx.runEntry.child = child;
6591
+ ctx.runEntry.worktreePath = workingDir || "";
6592
+ }
6593
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
6594
+ }
6425
6595
  });
6596
+ postStream(apiUrl, issueId, "run_finished", { stopReason: "ok", duration_ms: Date.now() - acpxStartedAt });
6426
6597
  } else {
6427
6598
  const runner = pickRunner(detected, preferType);
6428
6599
  for await (const event of runner(task))
@@ -6451,7 +6622,10 @@ ${userPart}` : userPart;
6451
6622
  await postComment(`\u26A0\uFE0F Agent returned no output (stopReason=${stopReason}). Adapter may be stuck on a stale session \u2014 try starting a new issue or clearing session_id.`);
6452
6623
  log(` \u26A0 ${task.key} produced no assistant output (stopReason=${stopReason})`);
6453
6624
  }
6454
- if (hadError) {
6625
+ if (ctx?.runEntry?.stopped) {
6626
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6627
+ log(` \u23F9 ${task.key} stopped`);
6628
+ } else if (hadError) {
6455
6629
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6456
6630
  log(` \u2717 ${task.key} failed`);
6457
6631
  } else {
@@ -6459,10 +6633,15 @@ ${userPart}` : userPart;
6459
6633
  log(` \u2713 ${task.key} complete`);
6460
6634
  }
6461
6635
  } catch (e) {
6462
- await postStream(apiUrl, issueId, "error", { message: String(e) });
6463
- await postComment(`\u274C spawn error: ${String(e)}`);
6464
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6465
- log(` \u2717 ${task.key} failed: ${String(e)}`);
6636
+ if (ctx?.runEntry?.stopped) {
6637
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6638
+ log(` \u23F9 ${task.key} stopped (${String(e)})`);
6639
+ } else {
6640
+ await postStream(apiUrl, issueId, "error", { message: String(e) });
6641
+ await postComment(`\u274C spawn error: ${String(e)}`);
6642
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6643
+ log(` \u2717 ${task.key} failed: ${String(e)}`);
6644
+ }
6466
6645
  }
6467
6646
  }
6468
6647
  async function buildPlanningPreamble(apiUrl, task) {
@@ -6564,7 +6743,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6564
6743
  }
6565
6744
  })();
6566
6745
  const headers = { "x-agent-id": parentTask.agent_id };
6567
- await ctx.refreshLocalAgents();
6746
+ if (typeof ctx.refreshLocalAgents === "function") {
6747
+ try {
6748
+ await ctx.refreshLocalAgents();
6749
+ } catch {}
6750
+ }
6568
6751
  for (const a of actions) {
6569
6752
  try {
6570
6753
  if (a.type === "create") {
@@ -6634,29 +6817,36 @@ function statusIcon(status) {
6634
6817
  }
6635
6818
  }
6636
6819
  async function resolveAcpAdapter(agentType, detectedPath) {
6637
- if (agentType === "pi" && detectedPath && existsSync2(detectedPath)) {
6820
+ if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
6638
6821
  return [detectedPath, "--mode", "rpc"];
6639
6822
  }
6640
6823
  const adapterName = "claude-code-acp";
6641
6824
  const candidates = [
6642
- join2(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6825
+ join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6643
6826
  ];
6644
6827
  try {
6645
6828
  const here = new URL(import.meta.url).pathname;
6646
6829
  let dir = here;
6647
6830
  for (let i = 0;i < 8; i++) {
6648
6831
  dir = dirname2(dir);
6649
- const bin = join2(dir, "node_modules", ".bin", adapterName);
6650
- if (existsSync2(bin))
6832
+ const bin = join3(dir, "node_modules", ".bin", adapterName);
6833
+ if (existsSync3(bin))
6651
6834
  return [bin];
6652
6835
  }
6653
6836
  } catch {}
6654
6837
  for (const c of candidates)
6655
- if (existsSync2(c))
6838
+ if (existsSync3(c))
6656
6839
  return [c];
6657
6840
  return null;
6658
6841
  }
6659
6842
  async function postStream(apiUrl, issueId, event_type, payload) {
6843
+ try {
6844
+ ensureDirs();
6845
+ const date = new Date().toISOString().slice(0, 10);
6846
+ const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
6847
+ appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
6848
+ `);
6849
+ } catch {}
6660
6850
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
6661
6851
  }
6662
6852
  async function downloadCommentAttachments(apiUrl, commentId, destDir) {
@@ -6665,7 +6855,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6665
6855
  const items = list.data?.results || list.data || [];
6666
6856
  if (!Array.isArray(items) || items.length === 0)
6667
6857
  return [];
6668
- mkdirSync2(destDir, { recursive: true });
6858
+ mkdirSync3(destDir, { recursive: true });
6669
6859
  const token = authTokenHeader();
6670
6860
  const out = [];
6671
6861
  for (const it of items) {
@@ -6674,8 +6864,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6674
6864
  continue;
6675
6865
  const buf = new Uint8Array(await res.arrayBuffer());
6676
6866
  const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
6677
- const p = join2(destDir, safe);
6678
- writeFileSync2(p, buf);
6867
+ const p = join3(destDir, safe);
6868
+ writeFileSync3(p, buf);
6679
6869
  out.push({ filename: it.filename, path: p });
6680
6870
  }
6681
6871
  return out;
@@ -6688,14 +6878,14 @@ function authTokenHeader() {
6688
6878
  return cfg.token ? `Bearer ${cfg.token}` : null;
6689
6879
  }
6690
6880
  async function uploadOutputDir(apiUrl, commentId, dir) {
6691
- if (!existsSync2(dir))
6881
+ if (!existsSync3(dir))
6692
6882
  return 0;
6693
6883
  const files = [];
6694
6884
  const walk = (d, depth = 0) => {
6695
6885
  if (depth > 3)
6696
6886
  return;
6697
6887
  for (const name of readdirSync(d)) {
6698
- const p = join2(d, name);
6888
+ const p = join3(d, name);
6699
6889
  try {
6700
6890
  const st = statSync(p);
6701
6891
  if (st.isDirectory())
@@ -6712,7 +6902,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
6712
6902
  let uploaded = 0;
6713
6903
  for (const f of files) {
6714
6904
  try {
6715
- const data = readFileSync2(f);
6905
+ const data = readFileSync3(f);
6716
6906
  const form = new FormData;
6717
6907
  const blob = new Blob([data]);
6718
6908
  form.append("file", blob, f.split("/").pop() || "file");
@@ -6939,7 +7129,7 @@ async function cmdStatus(apiUrl, config) {
6939
7129
  process.exit(1);
6940
7130
  }
6941
7131
  const d = res.data;
6942
- const pid = existsSync2(PID_PATH) ? readFileSync2(PID_PATH, "utf8").trim() : null;
7132
+ const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
6943
7133
  const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
6944
7134
  console.log(`
6945
7135
  Device Status
@@ -6953,29 +7143,73 @@ Daemon: ${daemon}
6953
7143
  `);
6954
7144
  }
6955
7145
  async function cmdStop() {
6956
- if (!existsSync2(PID_PATH)) {
7146
+ if (!existsSync3(PID_PATH)) {
6957
7147
  console.log("No daemon running.");
6958
7148
  return;
6959
7149
  }
6960
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
7150
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6961
7151
  if (!pid || !isRunning(pid)) {
6962
7152
  unlinkSync(PID_PATH);
6963
7153
  console.log("Cleaned stale pidfile.");
6964
7154
  return;
6965
7155
  }
6966
7156
  ensureDirs();
6967
- writeFileSync2(STOP_PATH, "1");
7157
+ writeFileSync3(STOP_PATH, "1");
6968
7158
  try {
6969
7159
  process.kill(pid, "SIGTERM");
6970
7160
  console.log(`Sent SIGTERM to ${pid}`);
6971
7161
  } catch {}
6972
7162
  }
7163
+ async function cmdRestart(apiUrl) {
7164
+ if (existsSync3(PID_PATH)) {
7165
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
7166
+ if (pid && isRunning(pid)) {
7167
+ ensureDirs();
7168
+ writeFileSync3(STOP_PATH, "1");
7169
+ try {
7170
+ process.kill(pid, "SIGTERM");
7171
+ } catch {}
7172
+ console.log(`\u23F9 Stopping daemon (pid ${pid})...`);
7173
+ const deadline = Date.now() + 1e4;
7174
+ while (Date.now() < deadline && isRunning(pid))
7175
+ await sleep(200);
7176
+ if (isRunning(pid)) {
7177
+ try {
7178
+ process.kill(pid, "SIGKILL");
7179
+ } catch {}
7180
+ await sleep(300);
7181
+ }
7182
+ }
7183
+ try {
7184
+ if (existsSync3(PID_PATH))
7185
+ unlinkSync(PID_PATH);
7186
+ } catch {}
7187
+ }
7188
+ try {
7189
+ if (existsSync3(STOP_PATH))
7190
+ unlinkSync(STOP_PATH);
7191
+ } catch {}
7192
+ console.log("\uD83D\uDD04 Relaunching daemon...");
7193
+ ensureDirs();
7194
+ const args = Bun.argv.slice(1).filter((a) => a !== "-d" && a !== "--detach" && a !== "restart");
7195
+ if (!args.includes("connect"))
7196
+ args.splice(1, 0, "connect");
7197
+ const proc = Bun.spawn([process.execPath, ...args, "--api", apiUrl], {
7198
+ stdio: ["ignore", "ignore", "ignore"],
7199
+ env: { ...process.env, MULTI_DETACHED: "1" }
7200
+ });
7201
+ proc.unref?.();
7202
+ await sleep(500);
7203
+ console.log(`\u2705 Daemon restarted (pid ${proc.pid}).`);
7204
+ console.log(` Tail logs: multi-agent logs`);
7205
+ process.exit(0);
7206
+ }
6973
7207
  async function cmdLogs() {
6974
- if (!existsSync2(LOG_PATH2)) {
7208
+ if (!existsSync3(LOG_PATH2)) {
6975
7209
  console.log("No logs yet.");
6976
7210
  return;
6977
7211
  }
6978
- const content = readFileSync2(LOG_PATH2, "utf8");
7212
+ const content = readFileSync3(LOG_PATH2, "utf8");
6979
7213
  console.log(content.split(`
6980
7214
  `).slice(-100).join(`
6981
7215
  `));
@@ -6994,7 +7228,7 @@ function openTasksDb() {
6994
7228
  db.exec(`
6995
7229
  CREATE TABLE IF NOT EXISTS tasks (
6996
7230
  id TEXT PRIMARY KEY,
6997
- status TEXT NOT NULL DEFAULT 'pending',
7231
+ status TEXT NOT NULL DEFAULT 'queued',
6998
7232
  payload TEXT NOT NULL,
6999
7233
  attempts INTEGER NOT NULL DEFAULT 0,
7000
7234
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -7004,20 +7238,31 @@ function openTasksDb() {
7004
7238
  );
7005
7239
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
7006
7240
  `);
7241
+ const cols = db.query("PRAGMA table_info(tasks)").all();
7242
+ const have = new Set(cols.map((c) => c.name));
7243
+ if (!have.has("agent_id"))
7244
+ db.exec("ALTER TABLE tasks ADD COLUMN agent_id TEXT");
7245
+ if (!have.has("issue_id"))
7246
+ db.exec("ALTER TABLE tasks ADD COLUMN issue_id TEXT");
7247
+ db.exec(`
7248
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
7249
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
7250
+ `);
7251
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
7007
7252
  return db;
7008
7253
  }
7009
7254
  function loadConfig() {
7010
7255
  try {
7011
- if (!existsSync2(CONFIG_PATH))
7256
+ if (!existsSync3(CONFIG_PATH))
7012
7257
  return {};
7013
- return JSON.parse(readFileSync2(CONFIG_PATH, "utf8"));
7258
+ return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
7014
7259
  } catch {
7015
7260
  return {};
7016
7261
  }
7017
7262
  }
7018
7263
  function saveConfig(config) {
7019
7264
  ensureDirs();
7020
- writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2));
7265
+ writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
7021
7266
  }
7022
7267
  function sleep(ms) {
7023
7268
  return new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -27,6 +27,7 @@ export type AcpRunOpts = {
27
27
  autonomy?: 'manual' | 'ask' | 'auto';
28
28
  onEvent: (ev: AcpEvent) => void | Promise<void>;
29
29
  onSession?: (sessionId: string) => void | Promise<void>;
30
+ onSpawn?: (child: any) => void;
30
31
  };
31
32
 
32
33
  export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; sessionId: string }> {
@@ -45,6 +46,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
45
46
  cwd: opts.cwd || process.cwd(),
46
47
  env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode },
47
48
  });
49
+ try { opts.onSpawn?.(child); } catch {}
48
50
 
49
51
  // Adapter expects plain newline-delimited JSON on stdio. Wrap child streams as Web Streams.
50
52
  const output = new WritableStream<Uint8Array>({
@@ -21,6 +21,7 @@ export interface AcpxRunOpts {
21
21
  cwd?: string;
22
22
  sessionName?: string; // reuse same session across follow-ups (e.g. "issue-<id>")
23
23
  onEvent: (ev: AcpEvent) => void | Promise<void>;
24
+ onSpawn?: (child: any) => void;
24
25
  }
25
26
 
26
27
  export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }> {
@@ -49,6 +50,7 @@ export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }
49
50
 
50
51
  dlog(`[acpx] prompt: ${args.slice(0, 10).join(' ')} ... (prompt len=${opts.prompt.length})`);
51
52
  const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
53
+ try { opts.onSpawn?.(proc); } catch {}
52
54
  let stopReason = 'end_turn';
53
55
 
54
56
  // Forward stderr to our progress stream so we can debug in the UI/logs
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { apiClient, setAuthToken } from './client';
5
5
  import { Database } from 'bun:sqlite';
6
6
  import { runAcp } from './acp-runner';
7
7
  import { runAcpx } from './acpx-runner';
8
+ import { ensureWorktree } from './worktree';
8
9
  import { parseArgs } from 'util';
9
10
  import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
10
11
  import { join, dirname } from 'path';
@@ -17,7 +18,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
17
18
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
18
19
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
19
20
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
20
- const VERSION = '0.8.0';
21
+ const VERSION = '0.9.1';
21
22
 
22
23
  const COMMANDS = {
23
24
  setup: 'Register this device with a workspace',
@@ -25,6 +26,7 @@ const COMMANDS = {
25
26
  link: 'Link this device to an agent (agent_id required)',
26
27
  status: 'Show current status',
27
28
  stop: 'Stop the running daemon',
29
+ restart: 'Stop and relaunch the daemon in background',
28
30
  logs: 'View execution logs',
29
31
  } as const;
30
32
 
@@ -97,6 +99,9 @@ async function main() {
97
99
  case 'stop':
98
100
  await cmdStop();
99
101
  break;
102
+ case 'restart':
103
+ await cmdRestart(apiUrl);
104
+ break;
100
105
  case 'logs':
101
106
  await cmdLogs();
102
107
  break;
@@ -119,6 +124,7 @@ Commands:
119
124
  connect ${COMMANDS.connect}
120
125
  status ${COMMANDS.status}
121
126
  stop ${COMMANDS.stop}
127
+ restart ${COMMANDS.restart}
122
128
  logs ${COMMANDS.logs}
123
129
 
124
130
  Options:
@@ -252,10 +258,44 @@ async function cmdConnect(apiUrl: string, config: Config) {
252
258
  const db = openTasksDb();
253
259
 
254
260
  // Requeue orphaned 'running' tasks from previous crash
255
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
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
+ const running = new Map<string, RunEntry>();
265
+
266
+ function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
267
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean) as string[];
268
+ // Emulate NOT IN (:list) via string construction (safe: only ids from DB).
269
+ const notIn = busyAgents.length
270
+ ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
271
+ : '';
272
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
273
+ return db.query(sql).get(...busyAgents) as any;
274
+ }
256
275
 
257
- let workerWake: (() => void) | null = null;
258
- const notifyWorker = () => { try { workerWake?.(); workerWake = null; } catch {} };
276
+ function schedule() {
277
+ while (running.size < MAX_DEVICE) {
278
+ const row = pickNext();
279
+ if (!row) return;
280
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
281
+ const entry: RunEntry = { agentId: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
282
+ const issueKey = row.issue_id || row.id;
283
+ running.set(issueKey, entry);
284
+ void (async () => {
285
+ try {
286
+ const task = JSON.parse(row.payload);
287
+ await handleRunTask(apiUrl, config.deviceId!, task, detected, { runEntry: entry });
288
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
289
+ } catch (e) {
290
+ log(`task ${row.id} error: ${String(e)}`);
291
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
292
+ } finally {
293
+ running.delete(issueKey);
294
+ queueMicrotask(() => schedule());
295
+ }
296
+ })();
297
+ }
298
+ }
259
299
 
260
300
 
261
301
  // Local HTTP server on a free port
@@ -271,15 +311,45 @@ async function cmdConnect(apiUrl: string, config: Config) {
271
311
  return (async () => {
272
312
  try {
273
313
  const body = await req.json() as { task: any };
274
- const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
275
- db.run('INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)', [taskId, 'pending', JSON.stringify(body.task)]);
276
- notifyWorker();
314
+ const t = body?.task || {};
315
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
316
+ db.run(
317
+ "INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
318
+ [taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null],
319
+ );
320
+ 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;
321
+ if (t.issue_id) {
322
+ void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
323
+ }
324
+ queueMicrotask(() => schedule());
277
325
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
278
326
  } catch (e) {
279
327
  return Response.json({ error: String(e) }, { status: 400 });
280
328
  }
281
329
  })();
282
330
  }
331
+ if (url.pathname === '/stop' && req.method === 'POST') {
332
+ if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
333
+ return (async () => {
334
+ try {
335
+ const { issue_id } = await req.json() as { issue_id: string };
336
+ if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
337
+ const entry = running.get(issue_id);
338
+ if (!entry) {
339
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
340
+ await markStopped(apiUrl, issue_id, 'stopped before start');
341
+ return Response.json({ ok: true, state: 'queued-cancelled' });
342
+ }
343
+ entry.stopped = true;
344
+ entry.stopReason = 'user requested';
345
+ try { entry.child?.kill('SIGTERM'); } catch {}
346
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
347
+ return Response.json({ ok: true, state: 'running-signalled' });
348
+ } catch (e) {
349
+ return Response.json({ error: String(e) }, { status: 400 });
350
+ }
351
+ })();
352
+ }
283
353
  return new Response('not found', { status: 404 });
284
354
  },
285
355
  });
@@ -300,11 +370,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
300
370
 
301
371
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
302
372
 
303
- let running = true;
373
+ let alive = true;
304
374
 
305
375
  const shutdown = async (reason: string) => {
306
- if (!running) return;
307
- running = false;
376
+ if (!alive) return;
377
+ alive = false;
308
378
  log(`🛑 Shutting down (${reason})`);
309
379
  try { server.stop(); } catch {}
310
380
  try { cf.kill(); } catch {}
@@ -318,28 +388,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
318
388
  process.on('SIGINT', () => shutdown('SIGINT'));
319
389
  process.on('SIGTERM', () => shutdown('SIGTERM'));
320
390
 
321
- // Worker loop: drain pending tasks
322
- (async () => {
323
- while (running) {
324
- 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;
325
- if (!row) {
326
- await new Promise<void>(resolve => { workerWake = resolve; setTimeout(resolve, 5000); });
327
- continue;
328
- }
329
- db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
330
- try {
331
- const task = JSON.parse(row.payload);
332
- await handleRunTask(apiUrl, config.deviceId!, task, detected, {});
333
- db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
334
- } catch (e) {
335
- log(`task ${row.id} error: ${String(e)}`);
336
- db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
337
- }
338
- }
339
- })();
391
+ // Kick the scheduler on startup to drain any leftover queued rows.
392
+ schedule();
340
393
 
341
394
  // Heartbeat loop
342
- while (running) {
395
+ while (alive) {
343
396
  await sleep(20000);
344
397
  if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
345
398
  try {
@@ -407,15 +460,52 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
407
460
  return null;
408
461
  }
409
462
 
463
+ interface RunEntry {
464
+ agentId: string;
465
+ startedAt: number;
466
+ child: any | null;
467
+ worktreePath: string;
468
+ stopped?: boolean;
469
+ stopReason?: string;
470
+ }
471
+
410
472
  interface RuntimeCtx {
411
- // Reserved for future runtime-scoped helpers.
473
+ runEntry?: RunEntry;
474
+ refreshLocalAgents?: () => Promise<void>;
475
+ }
476
+
477
+ async function markStopped(apiUrl: string, issueId: string, reason: string) {
478
+ try {
479
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'stopped' });
480
+ } catch {}
481
+ try {
482
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
483
+ author_type: 'agent', author_id: 'daemon', author_name: 'daemon', body: `⏹ Stopped: ${reason}`,
484
+ });
485
+ } catch {}
486
+ await postStream(apiUrl, issueId, 'stopped', { reason });
412
487
  }
413
488
 
414
489
  async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
415
490
  const issueId = task.issue_id;
416
491
  const isFollowup = !!task.followup;
417
- const workingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
418
- log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ''}`);
492
+ const baseWorkingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
493
+
494
+ // Per-issue worktree isolation. Falls back to baseWorkingDir on failure or non-git.
495
+ let workingDir = baseWorkingDir;
496
+ let worktreeBranch = '';
497
+ if (baseWorkingDir) {
498
+ try {
499
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
500
+ workingDir = wt.path;
501
+ worktreeBranch = wt.branch;
502
+ await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
503
+ } catch (e) {
504
+ await postStream(apiUrl, issueId, 'worktree_error', { message: String(e) });
505
+ }
506
+ }
507
+
508
+ log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ''}]` : ''}`);
419
509
 
420
510
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
421
511
  await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
@@ -678,6 +768,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
678
768
  if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
679
769
  log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
680
770
 
771
+ const startedAt = Date.now();
681
772
  const { sessionId } = await runAcp({
682
773
  apiUrl, issueId, deviceId, prompt,
683
774
  sessionId: task.session_id || null,
@@ -688,7 +779,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
688
779
  onSession: async (sid) => {
689
780
  try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
690
781
  },
782
+ onSpawn: (child) => {
783
+ if (ctx?.runEntry) {
784
+ ctx.runEntry.child = child;
785
+ ctx.runEntry.worktreePath = workingDir || '';
786
+ }
787
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
788
+ },
691
789
  });
790
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: (typeof sessionId === 'string' ? 'ok' : 'unknown'), duration_ms: Date.now() - startedAt });
692
791
  log(` acp session ${sessionId.slice(0, 8)}`);
693
792
  } else if (useAcpx) {
694
793
  // Build prompt with preamble (same logic as ACP path, but as one string)
@@ -724,13 +823,22 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
724
823
  }
725
824
  const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
726
825
  log(` acpx runner: ${preferType}`);
826
+ const acpxStartedAt = Date.now();
727
827
  await runAcpx({
728
828
  agentType: preferType!,
729
829
  prompt: full,
730
830
  cwd: workingDir,
731
831
  sessionName: `issue-${issueId}`,
732
832
  onEvent: eventHandler,
833
+ onSpawn: (child) => {
834
+ if (ctx?.runEntry) {
835
+ ctx.runEntry.child = child;
836
+ ctx.runEntry.worktreePath = workingDir || '';
837
+ }
838
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
839
+ },
733
840
  });
841
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: 'ok', duration_ms: Date.now() - acpxStartedAt });
734
842
  } else {
735
843
  const runner = pickRunner(detected, preferType);
736
844
  for await (const event of runner(task)) await eventHandler(event);
@@ -761,13 +869,21 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
761
869
  log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
762
870
  }
763
871
 
764
- if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
872
+ if (ctx?.runEntry?.stopped) {
873
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
874
+ log(` ⏹ ${task.key} stopped`);
875
+ } else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
765
876
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
766
877
  } catch (e) {
767
- await postStream(apiUrl, issueId, 'error', { message: String(e) });
768
- await postComment(`❌ spawn error: ${String(e)}`);
769
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
770
- log(` ✗ ${task.key} failed: ${String(e)}`);
878
+ if (ctx?.runEntry?.stopped) {
879
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
880
+ log(` ⏹ ${task.key} stopped (${String(e)})`);
881
+ } else {
882
+ await postStream(apiUrl, issueId, 'error', { message: String(e) });
883
+ await postComment(`❌ spawn error: ${String(e)}`);
884
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
885
+ log(` ✗ ${task.key} failed: ${String(e)}`);
886
+ }
771
887
  }
772
888
  }
773
889
 
@@ -878,7 +994,9 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
878
994
  const headers = { 'x-agent-id': parentTask.agent_id };
879
995
 
880
996
  // Refresh the set of agents linked to this device once per plan execution.
881
- await ctx.refreshLocalAgents();
997
+ if (typeof ctx.refreshLocalAgents === 'function') {
998
+ try { await ctx.refreshLocalAgents(); } catch {}
999
+ }
882
1000
 
883
1001
  for (const a of actions) {
884
1002
  try {
@@ -957,6 +1075,13 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
957
1075
  }
958
1076
 
959
1077
  async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
1078
+ // Local ndjson sink for tail -f debugging.
1079
+ try {
1080
+ ensureDirs();
1081
+ const date = new Date().toISOString().slice(0, 10);
1082
+ const path = join(MULTI_DIR, 'logs', `events-${date}.ndjson`);
1083
+ appendFileSync(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + '\n');
1084
+ } catch {}
960
1085
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
961
1086
  }
962
1087
 
@@ -1234,6 +1359,40 @@ async function cmdStop() {
1234
1359
  try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
1235
1360
  }
1236
1361
 
1362
+ async function cmdRestart(apiUrl: string) {
1363
+ if (existsSync(PID_PATH)) {
1364
+ const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
1365
+ if (pid && isRunning(pid)) {
1366
+ ensureDirs();
1367
+ writeFileSync(STOP_PATH, '1');
1368
+ try { process.kill(pid, 'SIGTERM'); } catch {}
1369
+ console.log(`⏹ Stopping daemon (pid ${pid})...`);
1370
+ const deadline = Date.now() + 10_000;
1371
+ while (Date.now() < deadline && isRunning(pid)) await sleep(200);
1372
+ if (isRunning(pid)) {
1373
+ try { process.kill(pid, 'SIGKILL'); } catch {}
1374
+ await sleep(300);
1375
+ }
1376
+ }
1377
+ try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
1378
+ }
1379
+ try { if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH); } catch {}
1380
+ console.log('🔄 Relaunching daemon...');
1381
+ ensureDirs();
1382
+ // Spawn `connect` detached (don't re-exec `restart` — would loop).
1383
+ const args = Bun.argv.slice(1).filter(a => a !== '-d' && a !== '--detach' && a !== 'restart');
1384
+ if (!args.includes('connect')) args.splice(1, 0, 'connect');
1385
+ const proc = Bun.spawn([process.execPath, ...args, '--api', apiUrl], {
1386
+ stdio: ['ignore', 'ignore', 'ignore'],
1387
+ env: { ...process.env, MULTI_DETACHED: '1' },
1388
+ });
1389
+ (proc as any).unref?.();
1390
+ await sleep(500);
1391
+ console.log(`✅ Daemon restarted (pid ${proc.pid}).`);
1392
+ console.log(` Tail logs: multi-agent logs`);
1393
+ process.exit(0);
1394
+ }
1395
+
1237
1396
  async function cmdLogs() {
1238
1397
  if (!existsSync(LOG_PATH)) { console.log('No logs yet.'); return; }
1239
1398
  const content = readFileSync(LOG_PATH, 'utf8');
@@ -1260,7 +1419,7 @@ function openTasksDb(): Database {
1260
1419
  db.exec(`
1261
1420
  CREATE TABLE IF NOT EXISTS tasks (
1262
1421
  id TEXT PRIMARY KEY,
1263
- status TEXT NOT NULL DEFAULT 'pending',
1422
+ status TEXT NOT NULL DEFAULT 'queued',
1264
1423
  payload TEXT NOT NULL,
1265
1424
  attempts INTEGER NOT NULL DEFAULT 0,
1266
1425
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -1270,6 +1429,17 @@ function openTasksDb(): Database {
1270
1429
  );
1271
1430
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
1272
1431
  `);
1432
+ // Idempotent migrations: add agent_id/issue_id columns if missing.
1433
+ const cols = db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
1434
+ const have = new Set(cols.map((c) => c.name));
1435
+ if (!have.has('agent_id')) db.exec('ALTER TABLE tasks ADD COLUMN agent_id TEXT');
1436
+ if (!have.has('issue_id')) db.exec('ALTER TABLE tasks ADD COLUMN issue_id TEXT');
1437
+ db.exec(`
1438
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
1439
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
1440
+ `);
1441
+ // Old rows used 'pending'; normalize to 'queued'.
1442
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
1273
1443
  return db;
1274
1444
  }
1275
1445
 
@@ -0,0 +1,89 @@
1
+ // Per-issue git worktree manager. Keeps agent work isolated so parallel runs
2
+ // don't clobber each other. Worktrees live at <workingDir>/.multi/worktrees/<key>
3
+ // on branch multi/<key>, created off the current HEAD of workingDir.
4
+ //
5
+ // No automatic teardown: branch + worktree are kept after done/fail for human
6
+ // review.
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ export interface Worktree {
13
+ path: string;
14
+ branch: string;
15
+ created: boolean;
16
+ }
17
+
18
+ async function run(cwd: string, cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
19
+ return await new Promise((resolve) => {
20
+ const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ p.stdout.on('data', (d) => { stdout += d.toString(); });
24
+ p.stderr.on('data', (d) => { stderr += d.toString(); });
25
+ p.on('close', (code) => resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }));
26
+ p.on('error', (e) => resolve({ code: 1, stdout: '', stderr: String(e) }));
27
+ });
28
+ }
29
+
30
+ export async function isGitRepo(dir: string): Promise<boolean> {
31
+ if (!existsSync(dir)) return false;
32
+ const r = await run(dir, 'git', ['rev-parse', '--is-inside-work-tree']);
33
+ return r.code === 0 && r.stdout === 'true';
34
+ }
35
+
36
+ async function branchExists(dir: string, branch: string): Promise<boolean> {
37
+ const r = await run(dir, 'git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
38
+ return r.code === 0;
39
+ }
40
+
41
+ function ensureGitignoreEntry(workingDir: string, entry: string): void {
42
+ const gip = join(workingDir, '.gitignore');
43
+ let body = '';
44
+ try { body = existsSync(gip) ? readFileSync(gip, 'utf8') : ''; } catch { body = ''; }
45
+ const lines = body.split('\n');
46
+ if (lines.some((l) => l.trim() === entry)) return;
47
+ const nextBody = (body.endsWith('\n') || body === '' ? body : body + '\n') + entry + '\n';
48
+ try { writeFileSync(gip, nextBody, 'utf8'); } catch {}
49
+ }
50
+
51
+ function normalizeKey(issueKey: string): string {
52
+ return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, '-');
53
+ }
54
+
55
+ /**
56
+ * Ensure a worktree exists for this issue. Safe to call repeatedly.
57
+ *
58
+ * - Non-git workingDir: returns { path: workingDir, created: false } (no isolation).
59
+ * - Git workingDir: creates or reuses <workingDir>/.multi/worktrees/<key>
60
+ * on branch multi/<key>. On failure returns workingDir fallback.
61
+ */
62
+ export async function ensureWorktree(workingDir: string, issueKey: string): Promise<Worktree> {
63
+ if (!(await isGitRepo(workingDir))) {
64
+ return { path: workingDir, branch: '', created: false };
65
+ }
66
+
67
+ ensureGitignoreEntry(workingDir, '.multi/');
68
+
69
+ const key = normalizeKey(issueKey);
70
+ const branch = `multi/${key}`;
71
+ const wtDir = join(workingDir, '.multi', 'worktrees');
72
+ const wtPath = join(wtDir, key);
73
+
74
+ if (existsSync(wtPath)) {
75
+ return { path: wtPath, branch, created: false };
76
+ }
77
+
78
+ try { mkdirSync(wtDir, { recursive: true }); } catch {}
79
+
80
+ const exists = await branchExists(workingDir, branch);
81
+ const args = exists
82
+ ? ['worktree', 'add', wtPath, branch]
83
+ : ['worktree', 'add', '-b', branch, wtPath, 'HEAD'];
84
+ const r = await run(workingDir, 'git', args);
85
+ if (r.code !== 0) {
86
+ throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
87
+ }
88
+ return { path: wtPath, branch, created: true };
89
+ }