@shipers-dev/multi 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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);
@@ -5355,6 +5358,14 @@ ${entries}` } });
5355
5358
  async function handleRequestPermission(params, o, allowCache) {
5356
5359
  const tc = params.toolCall || {};
5357
5360
  const toolKey = `${tc.toolName || tc.title || ""}|${tc.kind || ""}`.toLowerCase();
5361
+ if (o.autonomy === "auto") {
5362
+ const opts2 = params.options;
5363
+ const alwaysOpt = opts2.find((op) => /always/i.test(op.name || "") || /allow_always/i.test(op.kind || ""));
5364
+ const allowOpt = opts2.find((op) => /allow/i.test(op.kind || "") || /allow/i.test(op.name || ""));
5365
+ const chosen = alwaysOpt || allowOpt;
5366
+ if (chosen)
5367
+ return { outcome: { outcome: "selected", optionId: chosen.optionId } };
5368
+ }
5358
5369
  if (toolKey && allowCache.has(toolKey)) {
5359
5370
  const allowOpt = params.options.find((op) => /allow/i.test(op.kind || "") || /allow/i.test(op.name || ""));
5360
5371
  if (allowOpt)
@@ -5441,6 +5452,9 @@ async function runAcpx(opts) {
5441
5452
  args.push(opts.prompt);
5442
5453
  dlog(`[acpx] prompt: ${args.slice(0, 10).join(" ")} ... (prompt len=${opts.prompt.length})`);
5443
5454
  const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
5455
+ try {
5456
+ opts.onSpawn?.(proc);
5457
+ } catch {}
5444
5458
  let stopReason = "end_turn";
5445
5459
  (async () => {
5446
5460
  try {
@@ -5587,18 +5601,94 @@ function extractText2(content) {
5587
5601
  return "";
5588
5602
  }
5589
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
+
5590
5680
  // src/index.ts
5591
5681
  import { parseArgs } from "util";
5592
- import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5593
- 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";
5594
5684
  var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5595
- var MULTI_DIR = join2(HOME2, ".multi");
5596
- var CONFIG_PATH = join2(MULTI_DIR, "config.json");
5597
- var PID_PATH = join2(MULTI_DIR, "agent.pid");
5598
- var LOG_PATH2 = join2(MULTI_DIR, "logs", "agent.log");
5599
- var SKILLS_DIR = join2(MULTI_DIR, "skills");
5600
- var STOP_PATH = join2(MULTI_DIR, "stop.flag");
5601
- var TASKS_DB_PATH = join2(MULTI_DIR, "tasks.db");
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");
5602
5692
  var VERSION = "0.8.0";
5603
5693
  var COMMANDS = {
5604
5694
  setup: "Register this device with a workspace",
@@ -5609,9 +5699,9 @@ var COMMANDS = {
5609
5699
  logs: "View execution logs"
5610
5700
  };
5611
5701
  function ensureDirs() {
5612
- for (const d of [MULTI_DIR, join2(MULTI_DIR, "logs"), SKILLS_DIR]) {
5613
- if (!existsSync2(d))
5614
- mkdirSync2(d, { recursive: true });
5702
+ for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
5703
+ if (!existsSync3(d))
5704
+ mkdirSync3(d, { recursive: true });
5615
5705
  }
5616
5706
  }
5617
5707
  function log(msg) {
@@ -5784,7 +5874,7 @@ async function syncSkills(apiUrl, workspaceId) {
5784
5874
  return;
5785
5875
  ensureDirs();
5786
5876
  for (const skill of res.data) {
5787
- writeFileSync2(join2(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5877
+ writeFileSync3(join3(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5788
5878
  }
5789
5879
  if (res.data.length)
5790
5880
  console.log(` Synced ${res.data.length} skill(s) \u2192 ${SKILLS_DIR}`);
@@ -5814,8 +5904,8 @@ async function cmdConnect(apiUrl, config) {
5814
5904
  console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
5815
5905
  process.exit(1);
5816
5906
  }
5817
- if (existsSync2(PID_PATH)) {
5818
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
5907
+ if (existsSync3(PID_PATH)) {
5908
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5819
5909
  if (pid && isRunning(pid)) {
5820
5910
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5821
5911
  process.exit(1);
@@ -5823,21 +5913,46 @@ async function cmdConnect(apiUrl, config) {
5823
5913
  unlinkSync(PID_PATH);
5824
5914
  }
5825
5915
  ensureDirs();
5826
- writeFileSync2(PID_PATH, String(process.pid));
5827
- if (existsSync2(STOP_PATH))
5916
+ writeFileSync3(PID_PATH, String(process.pid));
5917
+ if (existsSync3(STOP_PATH))
5828
5918
  unlinkSync(STOP_PATH);
5829
5919
  const detected = await detectAgents();
5830
5920
  log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
5831
5921
  log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
5832
5922
  const db = openTasksDb();
5833
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
5834
- let workerWake = null;
5835
- const notifyWorker = () => {
5836
- try {
5837
- workerWake?.();
5838
- workerWake = null;
5839
- } catch {}
5840
- };
5923
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
5924
+ const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
5925
+ const running = new Map;
5926
+ function pickNext() {
5927
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean);
5928
+ const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5929
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5930
+ return db.query(sql).get(...busyAgents);
5931
+ }
5932
+ function schedule() {
5933
+ while (running.size < MAX_DEVICE) {
5934
+ const row = pickNext();
5935
+ if (!row)
5936
+ return;
5937
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5938
+ const entry = { agentId: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5939
+ const issueKey = row.issue_id || row.id;
5940
+ running.set(issueKey, entry);
5941
+ (async () => {
5942
+ try {
5943
+ const task = JSON.parse(row.payload);
5944
+ await handleRunTask(apiUrl, config.deviceId, task, detected, { runEntry: entry });
5945
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5946
+ } catch (e) {
5947
+ log(`task ${row.id} error: ${String(e)}`);
5948
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5949
+ } finally {
5950
+ running.delete(issueKey);
5951
+ queueMicrotask(() => schedule());
5952
+ }
5953
+ })();
5954
+ }
5955
+ }
5841
5956
  const port = await pickFreePort();
5842
5957
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
5843
5958
  const server = Bun.serve({
@@ -5853,15 +5968,50 @@ async function cmdConnect(apiUrl, config) {
5853
5968
  return (async () => {
5854
5969
  try {
5855
5970
  const body = await req.json();
5856
- const taskId = body?.task?.issue_id ? `${body.task.issue_id}-${Date.now()}` : crypto.randomUUID();
5857
- db.run("INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)", [taskId, "pending", JSON.stringify(body.task)]);
5858
- notifyWorker();
5971
+ const t = body?.task || {};
5972
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
5973
+ 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]);
5974
+ 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;
5975
+ if (t.issue_id) {
5976
+ postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
5977
+ }
5978
+ queueMicrotask(() => schedule());
5859
5979
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
5860
5980
  } catch (e) {
5861
5981
  return Response.json({ error: String(e) }, { status: 400 });
5862
5982
  }
5863
5983
  })();
5864
5984
  }
5985
+ if (url.pathname === "/stop" && req.method === "POST") {
5986
+ if (req.headers.get("authorization") !== expectedAuth)
5987
+ return new Response("unauthorized", { status: 401 });
5988
+ return (async () => {
5989
+ try {
5990
+ const { issue_id } = await req.json();
5991
+ if (!issue_id)
5992
+ return Response.json({ error: "issue_id required" }, { status: 400 });
5993
+ const entry = running.get(issue_id);
5994
+ if (!entry) {
5995
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
5996
+ await markStopped(apiUrl, issue_id, "stopped before start");
5997
+ return Response.json({ ok: true, state: "queued-cancelled" });
5998
+ }
5999
+ entry.stopped = true;
6000
+ entry.stopReason = "user requested";
6001
+ try {
6002
+ entry.child?.kill("SIGTERM");
6003
+ } catch {}
6004
+ setTimeout(() => {
6005
+ try {
6006
+ entry.child?.kill("SIGKILL");
6007
+ } catch {}
6008
+ }, 5000);
6009
+ return Response.json({ ok: true, state: "running-signalled" });
6010
+ } catch (e) {
6011
+ return Response.json({ error: String(e) }, { status: 400 });
6012
+ }
6013
+ })();
6014
+ }
5865
6015
  return new Response("not found", { status: 404 });
5866
6016
  }
5867
6017
  });
@@ -5884,11 +6034,11 @@ async function cmdConnect(apiUrl, config) {
5884
6034
  }
5885
6035
  log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
5886
6036
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
5887
- let running = true;
6037
+ let alive = true;
5888
6038
  const shutdown = async (reason) => {
5889
- if (!running)
6039
+ if (!alive)
5890
6040
  return;
5891
- running = false;
6041
+ alive = false;
5892
6042
  log(`\uD83D\uDED1 Shutting down (${reason})`);
5893
6043
  try {
5894
6044
  server.stop();
@@ -5899,9 +6049,9 @@ async function cmdConnect(apiUrl, config) {
5899
6049
  try {
5900
6050
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
5901
6051
  } catch {}
5902
- if (existsSync2(PID_PATH))
6052
+ if (existsSync3(PID_PATH))
5903
6053
  unlinkSync(PID_PATH);
5904
- if (existsSync2(STOP_PATH))
6054
+ if (existsSync3(STOP_PATH))
5905
6055
  unlinkSync(STOP_PATH);
5906
6056
  db.close();
5907
6057
  log("\uD83D\uDC4B Disconnected");
@@ -5909,30 +6059,10 @@ async function cmdConnect(apiUrl, config) {
5909
6059
  };
5910
6060
  process.on("SIGINT", () => shutdown("SIGINT"));
5911
6061
  process.on("SIGTERM", () => shutdown("SIGTERM"));
5912
- (async () => {
5913
- while (running) {
5914
- const row = db.query("SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1").get();
5915
- if (!row) {
5916
- await new Promise((resolve) => {
5917
- workerWake = resolve;
5918
- setTimeout(resolve, 5000);
5919
- });
5920
- continue;
5921
- }
5922
- db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5923
- try {
5924
- const task = JSON.parse(row.payload);
5925
- await handleRunTask(apiUrl, config.deviceId, task, detected, {});
5926
- db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5927
- } catch (e) {
5928
- log(`task ${row.id} error: ${String(e)}`);
5929
- db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
5930
- }
5931
- }
5932
- })();
5933
- while (running) {
6062
+ schedule();
6063
+ while (alive) {
5934
6064
  await sleep(20000);
5935
- if (existsSync2(STOP_PATH)) {
6065
+ if (existsSync3(STOP_PATH)) {
5936
6066
  await shutdown("stop flag");
5937
6067
  break;
5938
6068
  }
@@ -5944,8 +6074,8 @@ async function cmdConnect(apiUrl, config) {
5944
6074
  }
5945
6075
  }
5946
6076
  async function cmdConnectDetached(apiUrl) {
5947
- if (existsSync2(PID_PATH)) {
5948
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
6077
+ if (existsSync3(PID_PATH)) {
6078
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
5949
6079
  if (pid && isRunning(pid)) {
5950
6080
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5951
6081
  process.exit(1);
@@ -6002,22 +6132,48 @@ async function parseTunnelUrl(stream2) {
6002
6132
  }
6003
6133
  return null;
6004
6134
  }
6135
+ async function markStopped(apiUrl, issueId, reason) {
6136
+ try {
6137
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "stopped" });
6138
+ } catch {}
6139
+ try {
6140
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
6141
+ author_type: "agent",
6142
+ author_id: "daemon",
6143
+ author_name: "daemon",
6144
+ body: `\u23F9 Stopped: ${reason}`
6145
+ });
6146
+ } catch {}
6147
+ await postStream(apiUrl, issueId, "stopped", { reason });
6148
+ }
6005
6149
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6006
6150
  const issueId = task.issue_id;
6007
6151
  const isFollowup = !!task.followup;
6008
- const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
6009
- log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ""}`);
6152
+ const baseWorkingDir = task.working_dir && existsSync3(task.working_dir) ? task.working_dir : undefined;
6153
+ let workingDir = baseWorkingDir;
6154
+ let worktreeBranch = "";
6155
+ if (baseWorkingDir) {
6156
+ try {
6157
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
6158
+ workingDir = wt.path;
6159
+ worktreeBranch = wt.branch;
6160
+ await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
6161
+ } catch (e) {
6162
+ await postStream(apiUrl, issueId, "worktree_error", { message: String(e) });
6163
+ }
6164
+ }
6165
+ log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
6010
6166
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
6011
6167
  await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
6012
6168
  let attachmentRefs = [];
6013
6169
  if (task.from_comment_id) {
6014
- const baseDir = workingDir || join2(MULTI_DIR, "tmp", issueId);
6015
- const inDir = join2(baseDir, ".multi-in", task.from_comment_id);
6170
+ const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
6171
+ const inDir = join3(baseDir, ".multi-in", task.from_comment_id);
6016
6172
  attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
6017
6173
  if (attachmentRefs.length)
6018
6174
  log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
6019
6175
  }
6020
- const outDir = join2(workingDir || join2(MULTI_DIR, "tmp", issueId), ".multi-out");
6176
+ const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
6021
6177
  let liveCommentId;
6022
6178
  let liveBody = "";
6023
6179
  let hadError = false;
@@ -6325,6 +6481,7 @@ ${userPart}` : userPart;
6325
6481
  if (!adapterBin)
6326
6482
  throw new Error(`ACP adapter for ${chosen.type} not found`);
6327
6483
  log(` adapter: ${chosen.type} \u2192 ${adapterBin.join(" ")}`);
6484
+ const startedAt = Date.now();
6328
6485
  const { sessionId } = await runAcp({
6329
6486
  apiUrl,
6330
6487
  issueId,
@@ -6332,14 +6489,23 @@ ${userPart}` : userPart;
6332
6489
  prompt,
6333
6490
  sessionId: task.session_id || null,
6334
6491
  adapterBin,
6492
+ autonomy: task.autonomy_level,
6335
6493
  cwd: workingDir,
6336
6494
  onEvent: eventHandler,
6337
6495
  onSession: async (sid) => {
6338
6496
  try {
6339
6497
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid });
6340
6498
  } catch {}
6499
+ },
6500
+ onSpawn: (child) => {
6501
+ if (ctx?.runEntry) {
6502
+ ctx.runEntry.child = child;
6503
+ ctx.runEntry.worktreePath = workingDir || "";
6504
+ }
6505
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
6341
6506
  }
6342
6507
  });
6508
+ postStream(apiUrl, issueId, "run_finished", { stopReason: typeof sessionId === "string" ? "ok" : "unknown", duration_ms: Date.now() - startedAt });
6343
6509
  log(` acp session ${sessionId.slice(0, 8)}`);
6344
6510
  } else if (useAcpx) {
6345
6511
  let preamble = "";
@@ -6407,13 +6573,22 @@ Write generated files to: ${outDir}`;
6407
6573
 
6408
6574
  ${userPart}` : userPart;
6409
6575
  log(` acpx runner: ${preferType}`);
6576
+ const acpxStartedAt = Date.now();
6410
6577
  await runAcpx({
6411
6578
  agentType: preferType,
6412
6579
  prompt: full,
6413
6580
  cwd: workingDir,
6414
6581
  sessionName: `issue-${issueId}`,
6415
- onEvent: eventHandler
6582
+ onEvent: eventHandler,
6583
+ onSpawn: (child) => {
6584
+ if (ctx?.runEntry) {
6585
+ ctx.runEntry.child = child;
6586
+ ctx.runEntry.worktreePath = workingDir || "";
6587
+ }
6588
+ postStream(apiUrl, issueId, "run_started", { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
6589
+ }
6416
6590
  });
6591
+ postStream(apiUrl, issueId, "run_finished", { stopReason: "ok", duration_ms: Date.now() - acpxStartedAt });
6417
6592
  } else {
6418
6593
  const runner = pickRunner(detected, preferType);
6419
6594
  for await (const event of runner(task))
@@ -6442,7 +6617,10 @@ ${userPart}` : userPart;
6442
6617
  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.`);
6443
6618
  log(` \u26A0 ${task.key} produced no assistant output (stopReason=${stopReason})`);
6444
6619
  }
6445
- if (hadError) {
6620
+ if (ctx?.runEntry?.stopped) {
6621
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6622
+ log(` \u23F9 ${task.key} stopped`);
6623
+ } else if (hadError) {
6446
6624
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6447
6625
  log(` \u2717 ${task.key} failed`);
6448
6626
  } else {
@@ -6450,10 +6628,15 @@ ${userPart}` : userPart;
6450
6628
  log(` \u2713 ${task.key} complete`);
6451
6629
  }
6452
6630
  } catch (e) {
6453
- await postStream(apiUrl, issueId, "error", { message: String(e) });
6454
- await postComment(`\u274C spawn error: ${String(e)}`);
6455
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6456
- log(` \u2717 ${task.key} failed: ${String(e)}`);
6631
+ if (ctx?.runEntry?.stopped) {
6632
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
6633
+ log(` \u23F9 ${task.key} stopped (${String(e)})`);
6634
+ } else {
6635
+ await postStream(apiUrl, issueId, "error", { message: String(e) });
6636
+ await postComment(`\u274C spawn error: ${String(e)}`);
6637
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6638
+ log(` \u2717 ${task.key} failed: ${String(e)}`);
6639
+ }
6457
6640
  }
6458
6641
  }
6459
6642
  async function buildPlanningPreamble(apiUrl, task) {
@@ -6575,7 +6758,7 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6575
6758
  continue;
6576
6759
  }
6577
6760
  const created = res.data;
6578
- lines.push(`- \u2713 created **${created.key}** \u2014 ${created.title}${created.assignee_id ? ` \u2192 @${created.assignee_id}` : ""} (autonomy=${created.autonomy_level || "ask"})`);
6761
+ lines.push(`- \u2713 created **${created.key}** \u2014 ${created.title}${created.assignee_id ? ` \u2192 @${created.assignee_id}` : ""} (autonomy=${created.autonomy_level || "auto"})`);
6579
6762
  } else if (a.type === "update") {
6580
6763
  const res = await apiClient.post(`${apiUrl}/api/issues/agent/mutate`, { action: "update", ...a }, { headers });
6581
6764
  if (!res.success) {
@@ -6625,29 +6808,36 @@ function statusIcon(status) {
6625
6808
  }
6626
6809
  }
6627
6810
  async function resolveAcpAdapter(agentType, detectedPath) {
6628
- if (agentType === "pi" && detectedPath && existsSync2(detectedPath)) {
6811
+ if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
6629
6812
  return [detectedPath, "--mode", "rpc"];
6630
6813
  }
6631
6814
  const adapterName = "claude-code-acp";
6632
6815
  const candidates = [
6633
- join2(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6816
+ join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
6634
6817
  ];
6635
6818
  try {
6636
6819
  const here = new URL(import.meta.url).pathname;
6637
6820
  let dir = here;
6638
6821
  for (let i = 0;i < 8; i++) {
6639
6822
  dir = dirname2(dir);
6640
- const bin = join2(dir, "node_modules", ".bin", adapterName);
6641
- if (existsSync2(bin))
6823
+ const bin = join3(dir, "node_modules", ".bin", adapterName);
6824
+ if (existsSync3(bin))
6642
6825
  return [bin];
6643
6826
  }
6644
6827
  } catch {}
6645
6828
  for (const c of candidates)
6646
- if (existsSync2(c))
6829
+ if (existsSync3(c))
6647
6830
  return [c];
6648
6831
  return null;
6649
6832
  }
6650
6833
  async function postStream(apiUrl, issueId, event_type, payload) {
6834
+ try {
6835
+ ensureDirs();
6836
+ const date = new Date().toISOString().slice(0, 10);
6837
+ const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
6838
+ appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
6839
+ `);
6840
+ } catch {}
6651
6841
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
6652
6842
  }
6653
6843
  async function downloadCommentAttachments(apiUrl, commentId, destDir) {
@@ -6656,7 +6846,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6656
6846
  const items = list.data?.results || list.data || [];
6657
6847
  if (!Array.isArray(items) || items.length === 0)
6658
6848
  return [];
6659
- mkdirSync2(destDir, { recursive: true });
6849
+ mkdirSync3(destDir, { recursive: true });
6660
6850
  const token = authTokenHeader();
6661
6851
  const out = [];
6662
6852
  for (const it of items) {
@@ -6665,8 +6855,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6665
6855
  continue;
6666
6856
  const buf = new Uint8Array(await res.arrayBuffer());
6667
6857
  const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
6668
- const p = join2(destDir, safe);
6669
- writeFileSync2(p, buf);
6858
+ const p = join3(destDir, safe);
6859
+ writeFileSync3(p, buf);
6670
6860
  out.push({ filename: it.filename, path: p });
6671
6861
  }
6672
6862
  return out;
@@ -6679,14 +6869,14 @@ function authTokenHeader() {
6679
6869
  return cfg.token ? `Bearer ${cfg.token}` : null;
6680
6870
  }
6681
6871
  async function uploadOutputDir(apiUrl, commentId, dir) {
6682
- if (!existsSync2(dir))
6872
+ if (!existsSync3(dir))
6683
6873
  return 0;
6684
6874
  const files = [];
6685
6875
  const walk = (d, depth = 0) => {
6686
6876
  if (depth > 3)
6687
6877
  return;
6688
6878
  for (const name of readdirSync(d)) {
6689
- const p = join2(d, name);
6879
+ const p = join3(d, name);
6690
6880
  try {
6691
6881
  const st = statSync(p);
6692
6882
  if (st.isDirectory())
@@ -6703,7 +6893,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
6703
6893
  let uploaded = 0;
6704
6894
  for (const f of files) {
6705
6895
  try {
6706
- const data = readFileSync2(f);
6896
+ const data = readFileSync3(f);
6707
6897
  const form = new FormData;
6708
6898
  const blob = new Blob([data]);
6709
6899
  form.append("file", blob, f.split("/").pop() || "file");
@@ -6930,7 +7120,7 @@ async function cmdStatus(apiUrl, config) {
6930
7120
  process.exit(1);
6931
7121
  }
6932
7122
  const d = res.data;
6933
- const pid = existsSync2(PID_PATH) ? readFileSync2(PID_PATH, "utf8").trim() : null;
7123
+ const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
6934
7124
  const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
6935
7125
  console.log(`
6936
7126
  Device Status
@@ -6944,29 +7134,29 @@ Daemon: ${daemon}
6944
7134
  `);
6945
7135
  }
6946
7136
  async function cmdStop() {
6947
- if (!existsSync2(PID_PATH)) {
7137
+ if (!existsSync3(PID_PATH)) {
6948
7138
  console.log("No daemon running.");
6949
7139
  return;
6950
7140
  }
6951
- const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
7141
+ const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6952
7142
  if (!pid || !isRunning(pid)) {
6953
7143
  unlinkSync(PID_PATH);
6954
7144
  console.log("Cleaned stale pidfile.");
6955
7145
  return;
6956
7146
  }
6957
7147
  ensureDirs();
6958
- writeFileSync2(STOP_PATH, "1");
7148
+ writeFileSync3(STOP_PATH, "1");
6959
7149
  try {
6960
7150
  process.kill(pid, "SIGTERM");
6961
7151
  console.log(`Sent SIGTERM to ${pid}`);
6962
7152
  } catch {}
6963
7153
  }
6964
7154
  async function cmdLogs() {
6965
- if (!existsSync2(LOG_PATH2)) {
7155
+ if (!existsSync3(LOG_PATH2)) {
6966
7156
  console.log("No logs yet.");
6967
7157
  return;
6968
7158
  }
6969
- const content = readFileSync2(LOG_PATH2, "utf8");
7159
+ const content = readFileSync3(LOG_PATH2, "utf8");
6970
7160
  console.log(content.split(`
6971
7161
  `).slice(-100).join(`
6972
7162
  `));
@@ -6985,7 +7175,7 @@ function openTasksDb() {
6985
7175
  db.exec(`
6986
7176
  CREATE TABLE IF NOT EXISTS tasks (
6987
7177
  id TEXT PRIMARY KEY,
6988
- status TEXT NOT NULL DEFAULT 'pending',
7178
+ status TEXT NOT NULL DEFAULT 'queued',
6989
7179
  payload TEXT NOT NULL,
6990
7180
  attempts INTEGER NOT NULL DEFAULT 0,
6991
7181
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -6995,20 +7185,31 @@ function openTasksDb() {
6995
7185
  );
6996
7186
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
6997
7187
  `);
7188
+ const cols = db.query("PRAGMA table_info(tasks)").all();
7189
+ const have = new Set(cols.map((c) => c.name));
7190
+ if (!have.has("agent_id"))
7191
+ db.exec("ALTER TABLE tasks ADD COLUMN agent_id TEXT");
7192
+ if (!have.has("issue_id"))
7193
+ db.exec("ALTER TABLE tasks ADD COLUMN issue_id TEXT");
7194
+ db.exec(`
7195
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
7196
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
7197
+ `);
7198
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
6998
7199
  return db;
6999
7200
  }
7000
7201
  function loadConfig() {
7001
7202
  try {
7002
- if (!existsSync2(CONFIG_PATH))
7203
+ if (!existsSync3(CONFIG_PATH))
7003
7204
  return {};
7004
- return JSON.parse(readFileSync2(CONFIG_PATH, "utf8"));
7205
+ return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
7005
7206
  } catch {
7006
7207
  return {};
7007
7208
  }
7008
7209
  }
7009
7210
  function saveConfig(config) {
7010
7211
  ensureDirs();
7011
- writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2));
7212
+ writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
7012
7213
  }
7013
7214
  function sleep(ms) {
7014
7215
  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.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -24,8 +24,10 @@ export type AcpRunOpts = {
24
24
  cwd?: string;
25
25
  sessionId?: string | null;
26
26
  adapterBin: string | string[]; // command + args to spawn ACP agent
27
+ autonomy?: 'manual' | 'ask' | 'auto';
27
28
  onEvent: (ev: AcpEvent) => void | Promise<void>;
28
29
  onSession?: (sessionId: string) => void | Promise<void>;
30
+ onSpawn?: (child: any) => void;
29
31
  };
30
32
 
31
33
  export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; sessionId: string }> {
@@ -44,6 +46,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
44
46
  cwd: opts.cwd || process.cwd(),
45
47
  env: { ...cleanEnv, ACP_PERMISSION_MODE: permMode },
46
48
  });
49
+ try { opts.onSpawn?.(child); } catch {}
47
50
 
48
51
  // Adapter expects plain newline-delimited JSON on stdio. Wrap child streams as Web Streams.
49
52
  const output = new WritableStream<Uint8Array>({
@@ -205,6 +208,15 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
205
208
  const tc: any = params.toolCall || {};
206
209
  const toolKey = `${tc.toolName || tc.title || ''}|${tc.kind || ''}`.toLowerCase();
207
210
 
211
+ // Autonomy=auto: auto-approve everything, prefer "always" variant so adapter caches it.
212
+ if (o.autonomy === 'auto') {
213
+ const opts = params.options as any[];
214
+ const alwaysOpt = opts.find(op => /always/i.test(op.name || '') || /allow_always/i.test(op.kind || ''));
215
+ const allowOpt = opts.find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
216
+ const chosen = alwaysOpt || allowOpt;
217
+ if (chosen) return { outcome: { outcome: 'selected', optionId: chosen.optionId } as any };
218
+ }
219
+
208
220
  // Auto-approve if user previously chose "always allow" for same tool/kind
209
221
  if (toolKey && allowCache.has(toolKey)) {
210
222
  const allowOpt = (params.options as any[]).find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
@@ -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';
@@ -252,10 +253,44 @@ async function cmdConnect(apiUrl: string, config: Config) {
252
253
  const db = openTasksDb();
253
254
 
254
255
  // Requeue orphaned 'running' tasks from previous crash
255
- db.run("UPDATE tasks SET status = 'pending' WHERE status = 'running'");
256
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
257
+
258
+ const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
259
+ const running = new Map<string, RunEntry>();
260
+
261
+ function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
262
+ const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(Boolean) as string[];
263
+ // Emulate NOT IN (:list) via string construction (safe: only ids from DB).
264
+ const notIn = busyAgents.length
265
+ ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
266
+ : '';
267
+ const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
268
+ return db.query(sql).get(...busyAgents) as any;
269
+ }
256
270
 
257
- let workerWake: (() => void) | null = null;
258
- const notifyWorker = () => { try { workerWake?.(); workerWake = null; } catch {} };
271
+ function schedule() {
272
+ while (running.size < MAX_DEVICE) {
273
+ const row = pickNext();
274
+ if (!row) return;
275
+ db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
276
+ const entry: RunEntry = { agentId: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
277
+ const issueKey = row.issue_id || row.id;
278
+ running.set(issueKey, entry);
279
+ void (async () => {
280
+ try {
281
+ const task = JSON.parse(row.payload);
282
+ await handleRunTask(apiUrl, config.deviceId!, task, detected, { runEntry: entry });
283
+ db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
284
+ } catch (e) {
285
+ log(`task ${row.id} error: ${String(e)}`);
286
+ db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
287
+ } finally {
288
+ running.delete(issueKey);
289
+ queueMicrotask(() => schedule());
290
+ }
291
+ })();
292
+ }
293
+ }
259
294
 
260
295
 
261
296
  // Local HTTP server on a free port
@@ -271,15 +306,45 @@ async function cmdConnect(apiUrl: string, config: Config) {
271
306
  return (async () => {
272
307
  try {
273
308
  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();
309
+ const t = body?.task || {};
310
+ const taskId = t.issue_id ? `${t.issue_id}-${Date.now()}` : crypto.randomUUID();
311
+ db.run(
312
+ "INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
313
+ [taskId, JSON.stringify(t), t.agent_id ?? null, t.issue_id ?? null],
314
+ );
315
+ 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;
316
+ if (t.issue_id) {
317
+ void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
318
+ }
319
+ queueMicrotask(() => schedule());
277
320
  return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
278
321
  } catch (e) {
279
322
  return Response.json({ error: String(e) }, { status: 400 });
280
323
  }
281
324
  })();
282
325
  }
326
+ if (url.pathname === '/stop' && req.method === 'POST') {
327
+ if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
328
+ return (async () => {
329
+ try {
330
+ const { issue_id } = await req.json() as { issue_id: string };
331
+ if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
332
+ const entry = running.get(issue_id);
333
+ if (!entry) {
334
+ db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
335
+ await markStopped(apiUrl, issue_id, 'stopped before start');
336
+ return Response.json({ ok: true, state: 'queued-cancelled' });
337
+ }
338
+ entry.stopped = true;
339
+ entry.stopReason = 'user requested';
340
+ try { entry.child?.kill('SIGTERM'); } catch {}
341
+ setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
342
+ return Response.json({ ok: true, state: 'running-signalled' });
343
+ } catch (e) {
344
+ return Response.json({ error: String(e) }, { status: 400 });
345
+ }
346
+ })();
347
+ }
283
348
  return new Response('not found', { status: 404 });
284
349
  },
285
350
  });
@@ -300,11 +365,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
300
365
 
301
366
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
302
367
 
303
- let running = true;
368
+ let alive = true;
304
369
 
305
370
  const shutdown = async (reason: string) => {
306
- if (!running) return;
307
- running = false;
371
+ if (!alive) return;
372
+ alive = false;
308
373
  log(`🛑 Shutting down (${reason})`);
309
374
  try { server.stop(); } catch {}
310
375
  try { cf.kill(); } catch {}
@@ -318,28 +383,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
318
383
  process.on('SIGINT', () => shutdown('SIGINT'));
319
384
  process.on('SIGTERM', () => shutdown('SIGTERM'));
320
385
 
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
- })();
386
+ // Kick the scheduler on startup to drain any leftover queued rows.
387
+ schedule();
340
388
 
341
389
  // Heartbeat loop
342
- while (running) {
390
+ while (alive) {
343
391
  await sleep(20000);
344
392
  if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
345
393
  try {
@@ -407,15 +455,52 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
407
455
  return null;
408
456
  }
409
457
 
458
+ interface RunEntry {
459
+ agentId: string;
460
+ startedAt: number;
461
+ child: any | null;
462
+ worktreePath: string;
463
+ stopped?: boolean;
464
+ stopReason?: string;
465
+ }
466
+
410
467
  interface RuntimeCtx {
411
- // Reserved for future runtime-scoped helpers.
468
+ runEntry?: RunEntry;
469
+ refreshLocalAgents?: () => Promise<void>;
470
+ }
471
+
472
+ async function markStopped(apiUrl: string, issueId: string, reason: string) {
473
+ try {
474
+ await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'stopped' });
475
+ } catch {}
476
+ try {
477
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, {
478
+ author_type: 'agent', author_id: 'daemon', author_name: 'daemon', body: `⏹ Stopped: ${reason}`,
479
+ });
480
+ } catch {}
481
+ await postStream(apiUrl, issueId, 'stopped', { reason });
412
482
  }
413
483
 
414
484
  async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
415
485
  const issueId = task.issue_id;
416
486
  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}]` : ''}`);
487
+ const baseWorkingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
488
+
489
+ // Per-issue worktree isolation. Falls back to baseWorkingDir on failure or non-git.
490
+ let workingDir = baseWorkingDir;
491
+ let worktreeBranch = '';
492
+ if (baseWorkingDir) {
493
+ try {
494
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
495
+ workingDir = wt.path;
496
+ worktreeBranch = wt.branch;
497
+ await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
498
+ } catch (e) {
499
+ await postStream(apiUrl, issueId, 'worktree_error', { message: String(e) });
500
+ }
501
+ }
502
+
503
+ log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ''}]` : ''}`);
419
504
 
420
505
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
421
506
  await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
@@ -678,16 +763,26 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
678
763
  if (!adapterBin) throw new Error(`ACP adapter for ${chosen.type} not found`);
679
764
  log(` adapter: ${chosen.type} → ${adapterBin.join(' ')}`);
680
765
 
766
+ const startedAt = Date.now();
681
767
  const { sessionId } = await runAcp({
682
768
  apiUrl, issueId, deviceId, prompt,
683
769
  sessionId: task.session_id || null,
684
770
  adapterBin,
771
+ autonomy: task.autonomy_level,
685
772
  cwd: workingDir,
686
773
  onEvent: eventHandler,
687
774
  onSession: async (sid) => {
688
775
  try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/session`, { session_id: sid }); } catch {}
689
776
  },
777
+ onSpawn: (child) => {
778
+ if (ctx?.runEntry) {
779
+ ctx.runEntry.child = child;
780
+ ctx.runEntry.worktreePath = workingDir || '';
781
+ }
782
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: chosen.type });
783
+ },
690
784
  });
785
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: (typeof sessionId === 'string' ? 'ok' : 'unknown'), duration_ms: Date.now() - startedAt });
691
786
  log(` acp session ${sessionId.slice(0, 8)}`);
692
787
  } else if (useAcpx) {
693
788
  // Build prompt with preamble (same logic as ACP path, but as one string)
@@ -723,13 +818,22 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
723
818
  }
724
819
  const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
725
820
  log(` acpx runner: ${preferType}`);
821
+ const acpxStartedAt = Date.now();
726
822
  await runAcpx({
727
823
  agentType: preferType!,
728
824
  prompt: full,
729
825
  cwd: workingDir,
730
826
  sessionName: `issue-${issueId}`,
731
827
  onEvent: eventHandler,
828
+ onSpawn: (child) => {
829
+ if (ctx?.runEntry) {
830
+ ctx.runEntry.child = child;
831
+ ctx.runEntry.worktreePath = workingDir || '';
832
+ }
833
+ void postStream(apiUrl, issueId, 'run_started', { pid: child?.pid, worktree_path: workingDir, branch: worktreeBranch, adapter: preferType });
834
+ },
732
835
  });
836
+ void postStream(apiUrl, issueId, 'run_finished', { stopReason: 'ok', duration_ms: Date.now() - acpxStartedAt });
733
837
  } else {
734
838
  const runner = pickRunner(detected, preferType);
735
839
  for await (const event of runner(task)) await eventHandler(event);
@@ -760,13 +864,21 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
760
864
  log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
761
865
  }
762
866
 
763
- if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
867
+ if (ctx?.runEntry?.stopped) {
868
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
869
+ log(` ⏹ ${task.key} stopped`);
870
+ } else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
764
871
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
765
872
  } catch (e) {
766
- await postStream(apiUrl, issueId, 'error', { message: String(e) });
767
- await postComment(`❌ spawn error: ${String(e)}`);
768
- await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
769
- log(` ✗ ${task.key} failed: ${String(e)}`);
873
+ if (ctx?.runEntry?.stopped) {
874
+ await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
875
+ log(` ⏹ ${task.key} stopped (${String(e)})`);
876
+ } else {
877
+ await postStream(apiUrl, issueId, 'error', { message: String(e) });
878
+ await postComment(`❌ spawn error: ${String(e)}`);
879
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
880
+ log(` ✗ ${task.key} failed: ${String(e)}`);
881
+ }
770
882
  }
771
883
  }
772
884
 
@@ -892,7 +1004,7 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
892
1004
  const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, body, { headers });
893
1005
  if (!res.success) { lines.push(`- ❌ create "${a.title}": ${res.error || res.status}`); continue; }
894
1006
  const created = res.data;
895
- lines.push(`- ✓ created **${created.key}** — ${created.title}${created.assignee_id ? ` → @${created.assignee_id}` : ''} (autonomy=${created.autonomy_level || 'ask'})`);
1007
+ lines.push(`- ✓ created **${created.key}** — ${created.title}${created.assignee_id ? ` → @${created.assignee_id}` : ''} (autonomy=${created.autonomy_level || 'auto'})`);
896
1008
  } else if (a.type === 'update') {
897
1009
  const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', ...a }, { headers });
898
1010
  if (!res.success) { lines.push(`- ❌ update ${a.id}: ${res.error || res.status}`); continue; }
@@ -956,6 +1068,13 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
956
1068
  }
957
1069
 
958
1070
  async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
1071
+ // Local ndjson sink for tail -f debugging.
1072
+ try {
1073
+ ensureDirs();
1074
+ const date = new Date().toISOString().slice(0, 10);
1075
+ const path = join(MULTI_DIR, 'logs', `events-${date}.ndjson`);
1076
+ appendFileSync(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + '\n');
1077
+ } catch {}
959
1078
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
960
1079
  }
961
1080
 
@@ -1259,7 +1378,7 @@ function openTasksDb(): Database {
1259
1378
  db.exec(`
1260
1379
  CREATE TABLE IF NOT EXISTS tasks (
1261
1380
  id TEXT PRIMARY KEY,
1262
- status TEXT NOT NULL DEFAULT 'pending',
1381
+ status TEXT NOT NULL DEFAULT 'queued',
1263
1382
  payload TEXT NOT NULL,
1264
1383
  attempts INTEGER NOT NULL DEFAULT 0,
1265
1384
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
@@ -1269,6 +1388,17 @@ function openTasksDb(): Database {
1269
1388
  );
1270
1389
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
1271
1390
  `);
1391
+ // Idempotent migrations: add agent_id/issue_id columns if missing.
1392
+ const cols = db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
1393
+ const have = new Set(cols.map((c) => c.name));
1394
+ if (!have.has('agent_id')) db.exec('ALTER TABLE tasks ADD COLUMN agent_id TEXT');
1395
+ if (!have.has('issue_id')) db.exec('ALTER TABLE tasks ADD COLUMN issue_id TEXT');
1396
+ db.exec(`
1397
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id, status);
1398
+ CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
1399
+ `);
1400
+ // Old rows used 'pending'; normalize to 'queued'.
1401
+ db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
1272
1402
  return db;
1273
1403
  }
1274
1404
 
@@ -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
+ }