@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 +292 -91
- package/package.json +1 -1
- package/src/acp-runner.ts +12 -0
- package/src/acpx-runner.ts +2 -0
- package/src/index.ts +169 -39
- package/src/worktree.ts +89 -0
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
|
|
5593
|
-
import { join as
|
|
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 =
|
|
5596
|
-
var CONFIG_PATH =
|
|
5597
|
-
var PID_PATH =
|
|
5598
|
-
var LOG_PATH2 =
|
|
5599
|
-
var SKILLS_DIR =
|
|
5600
|
-
var STOP_PATH =
|
|
5601
|
-
var TASKS_DB_PATH =
|
|
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,
|
|
5613
|
-
if (!
|
|
5614
|
-
|
|
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
|
-
|
|
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 (
|
|
5818
|
-
const pid = Number(
|
|
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
|
-
|
|
5827
|
-
if (
|
|
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 = '
|
|
5834
|
-
|
|
5835
|
-
const
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
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
|
|
5857
|
-
|
|
5858
|
-
|
|
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
|
|
6037
|
+
let alive = true;
|
|
5888
6038
|
const shutdown = async (reason) => {
|
|
5889
|
-
if (!
|
|
6039
|
+
if (!alive)
|
|
5890
6040
|
return;
|
|
5891
|
-
|
|
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 (
|
|
6052
|
+
if (existsSync3(PID_PATH))
|
|
5903
6053
|
unlinkSync(PID_PATH);
|
|
5904
|
-
if (
|
|
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
|
-
(
|
|
5913
|
-
|
|
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 (
|
|
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 (
|
|
5948
|
-
const pid = Number(
|
|
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
|
|
6009
|
-
|
|
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 ||
|
|
6015
|
-
const inDir =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
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 || "
|
|
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 &&
|
|
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
|
-
|
|
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 =
|
|
6641
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
6669
|
-
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
7137
|
+
if (!existsSync3(PID_PATH)) {
|
|
6948
7138
|
console.log("No daemon running.");
|
|
6949
7139
|
return;
|
|
6950
7140
|
}
|
|
6951
|
-
const pid = Number(
|
|
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
|
-
|
|
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 (!
|
|
7155
|
+
if (!existsSync3(LOG_PATH2)) {
|
|
6966
7156
|
console.log("No logs yet.");
|
|
6967
7157
|
return;
|
|
6968
7158
|
}
|
|
6969
|
-
const content =
|
|
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 '
|
|
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 (!
|
|
7203
|
+
if (!existsSync3(CONFIG_PATH))
|
|
7003
7204
|
return {};
|
|
7004
|
-
return JSON.parse(
|
|
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
|
-
|
|
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
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 || ''));
|
package/src/acpx-runner.ts
CHANGED
|
@@ -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 = '
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
368
|
+
let alive = true;
|
|
304
369
|
|
|
305
370
|
const shutdown = async (reason: string) => {
|
|
306
|
-
if (!
|
|
307
|
-
|
|
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
|
-
//
|
|
322
|
-
(
|
|
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 (
|
|
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
|
-
|
|
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
|
|
418
|
-
|
|
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 (
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 || '
|
|
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 '
|
|
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
|
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|