@ra3orblade/swarm 0.4.0 → 0.5.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/README.md +5 -5
- package/dist/swarm-mcp.js +347 -240
- package/dist/swarm.js +279 -8
- package/dist/swarmd.js +871 -113
- package/package.json +1 -1
- package/web/app.js +151 -13
- package/web/icons.js +2 -2
- package/web/index.html +21 -0
- package/web/menus.js +11 -11
package/dist/swarmd.js
CHANGED
|
@@ -328,6 +328,7 @@ import { join as join2 } from "path";
|
|
|
328
328
|
var DEFAULT_CONFIG = {
|
|
329
329
|
daemon: { port: 7777 },
|
|
330
330
|
tasks: { source: null },
|
|
331
|
+
gates: { required: [] },
|
|
331
332
|
rules: {
|
|
332
333
|
shared_tree: "ask",
|
|
333
334
|
destructive_git: "ask",
|
|
@@ -459,6 +460,49 @@ function normalizeGitlab(raw, repo) {
|
|
|
459
460
|
};
|
|
460
461
|
});
|
|
461
462
|
}
|
|
463
|
+
// packages/core/src/gates.ts
|
|
464
|
+
var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
|
|
465
|
+
function validateGateRun(input) {
|
|
466
|
+
if (!input.task?.trim())
|
|
467
|
+
return { ok: false, reason: "task is required" };
|
|
468
|
+
if (!NAME_RE.test(input.gate ?? ""))
|
|
469
|
+
return { ok: false, reason: "gate must be a short name (letters, digits, _ . -)" };
|
|
470
|
+
if (input.verdict !== "pass" && input.verdict !== "fail")
|
|
471
|
+
return { ok: false, reason: 'verdict must be "pass" or "fail"' };
|
|
472
|
+
const rubric = input.rubric?.trim() ?? "";
|
|
473
|
+
if (rubric.length < 8)
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
reason: 'rubric is required: say what was checked (e.g. "tests green, no TODOs, reviewed error paths"). A verdict without a rubric is rejected.'
|
|
477
|
+
};
|
|
478
|
+
return { ok: true };
|
|
479
|
+
}
|
|
480
|
+
function gateStatus(runs, declared = []) {
|
|
481
|
+
const byGate = new Map;
|
|
482
|
+
for (const r of runs) {
|
|
483
|
+
const list = byGate.get(r.gate) ?? [];
|
|
484
|
+
list.push(r);
|
|
485
|
+
byGate.set(r.gate, list);
|
|
486
|
+
}
|
|
487
|
+
const names = [...new Set([...declared, ...byGate.keys()])];
|
|
488
|
+
return names.map((gate) => {
|
|
489
|
+
const list = (byGate.get(gate) ?? []).sort((a, b) => a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1);
|
|
490
|
+
const latest = list[0] ?? null;
|
|
491
|
+
return {
|
|
492
|
+
gate,
|
|
493
|
+
verdict: latest?.verdict ?? null,
|
|
494
|
+
latest,
|
|
495
|
+
runs: list.length,
|
|
496
|
+
fails: list.filter((r) => r.verdict === "fail").length
|
|
497
|
+
};
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
function gatesSatisfied(runs, declared) {
|
|
501
|
+
if (!declared.length)
|
|
502
|
+
return true;
|
|
503
|
+
const st = gateStatus(runs, declared);
|
|
504
|
+
return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
|
|
505
|
+
}
|
|
462
506
|
// packages/core/src/ledger.ts
|
|
463
507
|
var DEFAULT_LEASE_MINUTES = 45;
|
|
464
508
|
function isExpired(claim, now) {
|
|
@@ -495,12 +539,43 @@ function reapAction(claim, now, worktreeExists, work) {
|
|
|
495
539
|
return "keep-orphaned";
|
|
496
540
|
return "reap";
|
|
497
541
|
}
|
|
542
|
+
function shouldAutoRenew(claim, now, leaseMinutes = DEFAULT_LEASE_MINUTES) {
|
|
543
|
+
if (claim.state !== "held")
|
|
544
|
+
return false;
|
|
545
|
+
const left = new Date(claim.expiresAt).getTime() - now;
|
|
546
|
+
if (left <= 0)
|
|
547
|
+
return false;
|
|
548
|
+
return left < leaseMinutes * 60000 / 2;
|
|
549
|
+
}
|
|
498
550
|
function claimRefusalMessage(d, task) {
|
|
499
551
|
return `${task} is held by ${d.heldBy} until ${d.until}. ` + "Pick another task or coordinate with the holder \u2014 claims fail closed on purpose.";
|
|
500
552
|
}
|
|
501
553
|
function releaseRefusalMessage(d, worktree) {
|
|
502
554
|
return d.reason === "dirty" ? `${worktree} has uncommitted changes. Commit and push them, or re-run with --force to discard.` : `${worktree} has unpushed commits. Push them, or re-run with --force to discard the worktree.`;
|
|
503
555
|
}
|
|
556
|
+
function validateHandoff(h) {
|
|
557
|
+
if (!h.task?.trim())
|
|
558
|
+
return { ok: false, reason: "task is required" };
|
|
559
|
+
if (!h.done?.trim() || !h.remaining?.trim())
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
reason: "a handoff needs both `done` (what was finished) and `remaining` (what is left, in order)"
|
|
563
|
+
};
|
|
564
|
+
return { ok: true };
|
|
565
|
+
}
|
|
566
|
+
function formatHandoff(h) {
|
|
567
|
+
const lines = [
|
|
568
|
+
`[swarm] handoff on ${h.task}${h.by ? ` from ${h.by}` : ""} (${h.createdAt.slice(0, 16).replace("T", " ")}):`,
|
|
569
|
+
` done: ${h.done.trim()}`,
|
|
570
|
+
` remaining: ${h.remaining.trim()}`
|
|
571
|
+
];
|
|
572
|
+
if (h.files.length)
|
|
573
|
+
lines.push(` files: ${h.files.join(", ")}`);
|
|
574
|
+
if (h.verify)
|
|
575
|
+
lines.push(` verify: ${h.verify.trim()}`);
|
|
576
|
+
return lines.join(`
|
|
577
|
+
`);
|
|
578
|
+
}
|
|
504
579
|
// packages/core/src/pricing.ts
|
|
505
580
|
var PRICES = {
|
|
506
581
|
"claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
@@ -873,7 +948,7 @@ function taskBoard(tasks, activeClaims) {
|
|
|
873
948
|
// packages/daemon/src/app.ts
|
|
874
949
|
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
|
|
875
950
|
import { homedir as homedir4 } from "os";
|
|
876
|
-
import { dirname as dirname2, join as
|
|
951
|
+
import { dirname as dirname2, join as join7 } from "path";
|
|
877
952
|
import { fileURLToPath } from "url";
|
|
878
953
|
|
|
879
954
|
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
|
|
@@ -2118,78 +2193,6 @@ var RegExpRouter = class {
|
|
|
2118
2193
|
}
|
|
2119
2194
|
};
|
|
2120
2195
|
|
|
2121
|
-
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/router/reg-exp-router/prepared-router.js
|
|
2122
|
-
var PreparedRegExpRouter = class {
|
|
2123
|
-
name = "PreparedRegExpRouter";
|
|
2124
|
-
#matchers;
|
|
2125
|
-
#relocateMap;
|
|
2126
|
-
constructor(matchers, relocateMap) {
|
|
2127
|
-
this.#matchers = matchers;
|
|
2128
|
-
this.#relocateMap = relocateMap;
|
|
2129
|
-
}
|
|
2130
|
-
#addWildcard(method, handlerData) {
|
|
2131
|
-
const matcher = this.#matchers[method];
|
|
2132
|
-
matcher[1].forEach((list) => list && list.push(handlerData));
|
|
2133
|
-
Object.values(matcher[2]).forEach((list) => list[0].push(handlerData));
|
|
2134
|
-
}
|
|
2135
|
-
#addPath(method, path, handler, indexes, map) {
|
|
2136
|
-
const matcher = this.#matchers[method];
|
|
2137
|
-
if (!map) {
|
|
2138
|
-
matcher[2][path][0].push([handler, {}]);
|
|
2139
|
-
} else {
|
|
2140
|
-
indexes.forEach((index) => {
|
|
2141
|
-
if (typeof index === "number") {
|
|
2142
|
-
matcher[1][index].push([handler, map]);
|
|
2143
|
-
} else {
|
|
2144
|
-
matcher[2][index || path][0].push([handler, map]);
|
|
2145
|
-
}
|
|
2146
|
-
});
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
add(method, path, handler) {
|
|
2150
|
-
if (!this.#matchers[method]) {
|
|
2151
|
-
const all = this.#matchers[METHOD_NAME_ALL];
|
|
2152
|
-
const staticMap = {};
|
|
2153
|
-
for (const key in all[2]) {
|
|
2154
|
-
staticMap[key] = [all[2][key][0].slice(), emptyParam];
|
|
2155
|
-
}
|
|
2156
|
-
this.#matchers[method] = [
|
|
2157
|
-
all[0],
|
|
2158
|
-
all[1].map((list) => Array.isArray(list) ? list.slice() : 0),
|
|
2159
|
-
staticMap
|
|
2160
|
-
];
|
|
2161
|
-
}
|
|
2162
|
-
if (path === "/*" || path === "*") {
|
|
2163
|
-
const handlerData = [handler, {}];
|
|
2164
|
-
if (method === METHOD_NAME_ALL) {
|
|
2165
|
-
for (const m in this.#matchers) {
|
|
2166
|
-
this.#addWildcard(m, handlerData);
|
|
2167
|
-
}
|
|
2168
|
-
} else {
|
|
2169
|
-
this.#addWildcard(method, handlerData);
|
|
2170
|
-
}
|
|
2171
|
-
return;
|
|
2172
|
-
}
|
|
2173
|
-
const data = this.#relocateMap[path];
|
|
2174
|
-
if (!data) {
|
|
2175
|
-
throw new Error(`Path ${path} is not registered`);
|
|
2176
|
-
}
|
|
2177
|
-
for (const [indexes, map] of data) {
|
|
2178
|
-
if (method === METHOD_NAME_ALL) {
|
|
2179
|
-
for (const m in this.#matchers) {
|
|
2180
|
-
this.#addPath(m, path, handler, indexes, map);
|
|
2181
|
-
}
|
|
2182
|
-
} else {
|
|
2183
|
-
this.#addPath(method, path, handler, indexes, map);
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
buildAllMatchers() {
|
|
2188
|
-
return this.#matchers;
|
|
2189
|
-
}
|
|
2190
|
-
match = match;
|
|
2191
|
-
};
|
|
2192
|
-
|
|
2193
2196
|
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/router/smart-router/router.js
|
|
2194
2197
|
var SmartRouter = class {
|
|
2195
2198
|
name = "SmartRouter";
|
|
@@ -2579,7 +2582,7 @@ var EXTRA_BIN_DIRS = [
|
|
|
2579
2582
|
function findBin(name) {
|
|
2580
2583
|
if (!name)
|
|
2581
2584
|
return null;
|
|
2582
|
-
const onPath = Bun.which(name);
|
|
2585
|
+
const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
|
|
2583
2586
|
if (onPath)
|
|
2584
2587
|
return onPath;
|
|
2585
2588
|
for (const d of EXTRA_BIN_DIRS) {
|
|
@@ -2672,13 +2675,326 @@ class ForgeService {
|
|
|
2672
2675
|
}
|
|
2673
2676
|
}
|
|
2674
2677
|
|
|
2678
|
+
// packages/daemon/src/runner.ts
|
|
2679
|
+
import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
|
|
2680
|
+
import { join as join4 } from "path";
|
|
2681
|
+
var PERMISSION_MODES = [
|
|
2682
|
+
"acceptEdits",
|
|
2683
|
+
"auto",
|
|
2684
|
+
"bypassPermissions",
|
|
2685
|
+
"manual",
|
|
2686
|
+
"dontAsk",
|
|
2687
|
+
"plan"
|
|
2688
|
+
];
|
|
2689
|
+
|
|
2690
|
+
class Runner {
|
|
2691
|
+
store;
|
|
2692
|
+
home;
|
|
2693
|
+
live = new Map;
|
|
2694
|
+
constructor(store, home) {
|
|
2695
|
+
this.store = store;
|
|
2696
|
+
this.home = home;
|
|
2697
|
+
}
|
|
2698
|
+
list(projectId) {
|
|
2699
|
+
return [...this.live.values()].map((x) => x.run).filter((r) => !projectId || r.projectId === projectId);
|
|
2700
|
+
}
|
|
2701
|
+
get(idOrTask) {
|
|
2702
|
+
for (const { run: run2 } of this.live.values())
|
|
2703
|
+
if (run2.id === idOrTask || run2.sessionId === idOrTask || run2.task === idOrTask)
|
|
2704
|
+
return run2;
|
|
2705
|
+
return null;
|
|
2706
|
+
}
|
|
2707
|
+
async start(input) {
|
|
2708
|
+
const bin = findBin("claude");
|
|
2709
|
+
if (!bin)
|
|
2710
|
+
return { ok: false, reason: "claude CLI not found \u2014 install Claude Code first" };
|
|
2711
|
+
const project = this.store.project(input.projectId);
|
|
2712
|
+
if (!project)
|
|
2713
|
+
return { ok: false, reason: "unknown project" };
|
|
2714
|
+
if (!input.prompt.trim())
|
|
2715
|
+
return { ok: false, reason: "prompt is required" };
|
|
2716
|
+
if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
|
|
2717
|
+
return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
|
|
2718
|
+
if (this.get(input.task)?.projectId === input.projectId)
|
|
2719
|
+
return {
|
|
2720
|
+
ok: false,
|
|
2721
|
+
reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
|
|
2722
|
+
};
|
|
2723
|
+
const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
|
|
2724
|
+
let worktree = held?.worktree ?? "";
|
|
2725
|
+
if (!worktree) {
|
|
2726
|
+
const c = this.store.claim(input.projectId, input.task, input.owner);
|
|
2727
|
+
if (!c.ok)
|
|
2728
|
+
return { ok: false, reason: c.error };
|
|
2729
|
+
worktree = c.worktree;
|
|
2730
|
+
}
|
|
2731
|
+
const sessionId = crypto.randomUUID();
|
|
2732
|
+
const id = sessionId.slice(0, 8);
|
|
2733
|
+
const logDir = join4(this.home, "logs", project.id);
|
|
2734
|
+
mkdirSync2(logDir, { recursive: true });
|
|
2735
|
+
const log = join4(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
|
|
2736
|
+
const logFd = openSync(log, "a");
|
|
2737
|
+
const args = [
|
|
2738
|
+
bin,
|
|
2739
|
+
"-p",
|
|
2740
|
+
"--output-format",
|
|
2741
|
+
"stream-json",
|
|
2742
|
+
"--input-format",
|
|
2743
|
+
"stream-json",
|
|
2744
|
+
"--verbose",
|
|
2745
|
+
"--session-id",
|
|
2746
|
+
sessionId,
|
|
2747
|
+
"--permission-prompt-tool",
|
|
2748
|
+
"stdio"
|
|
2749
|
+
];
|
|
2750
|
+
if (input.model)
|
|
2751
|
+
args.push("--model", input.model);
|
|
2752
|
+
if (input.permissionMode)
|
|
2753
|
+
args.push("--permission-mode", input.permissionMode);
|
|
2754
|
+
if (input.allowedTools?.length)
|
|
2755
|
+
args.push("--allowedTools", ...input.allowedTools);
|
|
2756
|
+
if (input.maxTurns)
|
|
2757
|
+
args.push("--max-turns", String(input.maxTurns));
|
|
2758
|
+
this.store.preregisterSpawnedSession(sessionId, project.id, worktree, input.task);
|
|
2759
|
+
const proc = Bun.spawn(args, {
|
|
2760
|
+
cwd: worktree,
|
|
2761
|
+
env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
|
|
2762
|
+
stdin: "pipe",
|
|
2763
|
+
stdout: "pipe",
|
|
2764
|
+
stderr: logFd
|
|
2765
|
+
});
|
|
2766
|
+
const run2 = {
|
|
2767
|
+
id,
|
|
2768
|
+
sessionId,
|
|
2769
|
+
projectId: project.id,
|
|
2770
|
+
task: input.task,
|
|
2771
|
+
worktree,
|
|
2772
|
+
pid: proc.pid,
|
|
2773
|
+
owner: input.owner,
|
|
2774
|
+
model: input.model ?? null,
|
|
2775
|
+
permissionMode: input.permissionMode ?? null,
|
|
2776
|
+
prompt: input.prompt,
|
|
2777
|
+
log,
|
|
2778
|
+
startedAt: new Date().toISOString(),
|
|
2779
|
+
endedAt: null,
|
|
2780
|
+
exitCode: null,
|
|
2781
|
+
result: null,
|
|
2782
|
+
pending: []
|
|
2783
|
+
};
|
|
2784
|
+
this.live.set(id, { run: run2, proc });
|
|
2785
|
+
this.store.registerProcess({
|
|
2786
|
+
pid: proc.pid,
|
|
2787
|
+
projectId: project.id,
|
|
2788
|
+
sessionId,
|
|
2789
|
+
kind: "proc",
|
|
2790
|
+
name: `run:${input.task}`,
|
|
2791
|
+
cwd: worktree,
|
|
2792
|
+
cmd: `claude -p (run ${id})`,
|
|
2793
|
+
owner: input.owner,
|
|
2794
|
+
log
|
|
2795
|
+
});
|
|
2796
|
+
this.store.append({
|
|
2797
|
+
ts: run2.startedAt,
|
|
2798
|
+
type: "session.started",
|
|
2799
|
+
projectId: project.id,
|
|
2800
|
+
sessionId,
|
|
2801
|
+
payload: { kind: "spawned", task: input.task, runId: id, summary: `swarm run ${input.task}` }
|
|
2802
|
+
});
|
|
2803
|
+
this.pump(id, proc);
|
|
2804
|
+
this.send(id, input.prompt);
|
|
2805
|
+
return { ok: true, run: run2 };
|
|
2806
|
+
}
|
|
2807
|
+
async pump(id, proc) {
|
|
2808
|
+
const entry = this.live.get(id);
|
|
2809
|
+
if (!entry || !proc.stdout || typeof proc.stdout === "number")
|
|
2810
|
+
return;
|
|
2811
|
+
const reader = proc.stdout.getReader();
|
|
2812
|
+
const dec = new TextDecoder;
|
|
2813
|
+
let buf = "";
|
|
2814
|
+
try {
|
|
2815
|
+
while (true) {
|
|
2816
|
+
const { value, done } = await reader.read();
|
|
2817
|
+
if (done)
|
|
2818
|
+
break;
|
|
2819
|
+
buf += dec.decode(value, { stream: true });
|
|
2820
|
+
let nl = buf.indexOf(`
|
|
2821
|
+
`);
|
|
2822
|
+
while (nl >= 0) {
|
|
2823
|
+
const line = buf.slice(0, nl);
|
|
2824
|
+
buf = buf.slice(nl + 1);
|
|
2825
|
+
try {
|
|
2826
|
+
appendFileSync(entry.run.log, `${line}
|
|
2827
|
+
`);
|
|
2828
|
+
} catch {}
|
|
2829
|
+
this.onLine(entry.run, line);
|
|
2830
|
+
nl = buf.indexOf(`
|
|
2831
|
+
`);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
} catch (e) {
|
|
2835
|
+
console.error("swarm run: stdout pump failed:", e.message);
|
|
2836
|
+
}
|
|
2837
|
+
const code = await proc.exited;
|
|
2838
|
+
entry.run.endedAt = new Date().toISOString();
|
|
2839
|
+
entry.run.exitCode = code;
|
|
2840
|
+
this.store.append({
|
|
2841
|
+
ts: entry.run.endedAt,
|
|
2842
|
+
type: "run.result",
|
|
2843
|
+
projectId: entry.run.projectId,
|
|
2844
|
+
sessionId: entry.run.sessionId,
|
|
2845
|
+
payload: {
|
|
2846
|
+
runId: id,
|
|
2847
|
+
task: entry.run.task,
|
|
2848
|
+
exitCode: code,
|
|
2849
|
+
final: true,
|
|
2850
|
+
...entry.run.result ?? {},
|
|
2851
|
+
summary: `run ${entry.run.task} exited ${code}`
|
|
2852
|
+
}
|
|
2853
|
+
});
|
|
2854
|
+
this.store.endSpawnedSession(entry.run.sessionId);
|
|
2855
|
+
this.live.delete(id);
|
|
2856
|
+
}
|
|
2857
|
+
onLine(run2, line) {
|
|
2858
|
+
if (!line.startsWith("{"))
|
|
2859
|
+
return;
|
|
2860
|
+
let j;
|
|
2861
|
+
try {
|
|
2862
|
+
j = JSON.parse(line);
|
|
2863
|
+
} catch {
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
if (j.type === "control_request" && j.request?.subtype === "can_use_tool") {
|
|
2867
|
+
this.onPermissionRequest(run2, j.request_id, j.request.tool_name ?? "", j.request.input ?? {});
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
if (j.type !== "result")
|
|
2871
|
+
return;
|
|
2872
|
+
run2.result = {
|
|
2873
|
+
costUsd: Number(j.total_cost_usd ?? 0),
|
|
2874
|
+
turns: Number(j.num_turns ?? 0),
|
|
2875
|
+
isError: Boolean(j.is_error),
|
|
2876
|
+
at: new Date().toISOString()
|
|
2877
|
+
};
|
|
2878
|
+
this.store.append({
|
|
2879
|
+
ts: run2.result.at,
|
|
2880
|
+
type: "run.result",
|
|
2881
|
+
projectId: run2.projectId,
|
|
2882
|
+
sessionId: run2.sessionId,
|
|
2883
|
+
payload: {
|
|
2884
|
+
runId: run2.id,
|
|
2885
|
+
task: run2.task,
|
|
2886
|
+
...run2.result,
|
|
2887
|
+
summary: `turn done \xB7 $${run2.result.costUsd.toFixed(2)} \xB7 ${run2.result.turns} turns${run2.result.isError ? " \xB7 error" : ""}`
|
|
2888
|
+
}
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
onPermissionRequest(run2, requestId, tool, input) {
|
|
2892
|
+
const { decision, display } = this.store.evaluateTool(tool, input, run2.sessionId, run2.worktree, true);
|
|
2893
|
+
if (decision.action === "deny") {
|
|
2894
|
+
this.answerPermission(run2.id, requestId, false, `[swarm] ${decision.reason}`);
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
if (decision.action === "allow") {
|
|
2898
|
+
this.answerPermission(run2.id, requestId, true);
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
run2.pending.push({
|
|
2902
|
+
requestId,
|
|
2903
|
+
tool,
|
|
2904
|
+
input,
|
|
2905
|
+
display,
|
|
2906
|
+
reason: decision.reason,
|
|
2907
|
+
askedAt: new Date().toISOString()
|
|
2908
|
+
});
|
|
2909
|
+
this.store.append({
|
|
2910
|
+
ts: new Date().toISOString(),
|
|
2911
|
+
type: "permission.requested",
|
|
2912
|
+
projectId: run2.projectId,
|
|
2913
|
+
sessionId: run2.sessionId,
|
|
2914
|
+
payload: {
|
|
2915
|
+
runId: run2.id,
|
|
2916
|
+
requestId,
|
|
2917
|
+
tool,
|
|
2918
|
+
display: display.slice(0, 300),
|
|
2919
|
+
reason: decision.reason,
|
|
2920
|
+
summary: `permission: ${tool} \u2014 waiting`
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
this.store.touch();
|
|
2924
|
+
}
|
|
2925
|
+
answerPermission(runId, requestId, allow, message) {
|
|
2926
|
+
const entry = this.live.get(runId);
|
|
2927
|
+
if (!entry)
|
|
2928
|
+
return { ok: false, reason: "no live run" };
|
|
2929
|
+
const stdin = entry.proc.stdin;
|
|
2930
|
+
if (!stdin || typeof stdin === "number")
|
|
2931
|
+
return { ok: false, reason: "stdin not available" };
|
|
2932
|
+
const pend = entry.run.pending.find((p) => p.requestId === requestId);
|
|
2933
|
+
const response = allow ? { behavior: "allow", updatedInput: pend?.input ?? {} } : { behavior: "deny", message: message ?? "Denied from the Swarm dashboard" };
|
|
2934
|
+
stdin.write(`${JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: requestId, response } })}
|
|
2935
|
+
`);
|
|
2936
|
+
stdin.flush();
|
|
2937
|
+
entry.run.pending = entry.run.pending.filter((p) => p.requestId !== requestId);
|
|
2938
|
+
if (pend)
|
|
2939
|
+
this.store.append({
|
|
2940
|
+
ts: new Date().toISOString(),
|
|
2941
|
+
type: "permission.resolved",
|
|
2942
|
+
projectId: entry.run.projectId,
|
|
2943
|
+
sessionId: entry.run.sessionId,
|
|
2944
|
+
payload: {
|
|
2945
|
+
runId,
|
|
2946
|
+
requestId,
|
|
2947
|
+
tool: pend.tool,
|
|
2948
|
+
allow,
|
|
2949
|
+
summary: `permission: ${pend.tool} \u2014 ${allow ? "allowed" : "denied"}`
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
this.store.touch();
|
|
2953
|
+
return { ok: true };
|
|
2954
|
+
}
|
|
2955
|
+
send(id, text) {
|
|
2956
|
+
const entry = this.live.get(id) ?? [...this.live.values()].find((x) => x.run.task === id || x.run.sessionId === id);
|
|
2957
|
+
if (!entry)
|
|
2958
|
+
return { ok: false, reason: "no live run" };
|
|
2959
|
+
const stdin = entry.proc.stdin;
|
|
2960
|
+
if (!stdin || typeof stdin === "number")
|
|
2961
|
+
return { ok: false, reason: "stdin not available" };
|
|
2962
|
+
stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: text } })}
|
|
2963
|
+
`);
|
|
2964
|
+
stdin.flush();
|
|
2965
|
+
this.store.append({
|
|
2966
|
+
ts: new Date().toISOString(),
|
|
2967
|
+
type: "prompt.submitted",
|
|
2968
|
+
projectId: entry.run.projectId,
|
|
2969
|
+
sessionId: entry.run.sessionId,
|
|
2970
|
+
payload: { prompt: text.slice(0, 400), via: "swarm run send", summary: text.slice(0, 120) }
|
|
2971
|
+
});
|
|
2972
|
+
return { ok: true };
|
|
2973
|
+
}
|
|
2974
|
+
async stop(id) {
|
|
2975
|
+
const run2 = this.get(id);
|
|
2976
|
+
if (!run2)
|
|
2977
|
+
return { ok: false, reason: "no live run" };
|
|
2978
|
+
const entry = this.live.get(run2.id);
|
|
2979
|
+
try {
|
|
2980
|
+
const stdin = entry?.proc.stdin;
|
|
2981
|
+
if (stdin && typeof stdin !== "number")
|
|
2982
|
+
stdin.end();
|
|
2983
|
+
} catch {}
|
|
2984
|
+
return this.store.stopProcess(run2.pid, run2.projectId, 5000);
|
|
2985
|
+
}
|
|
2986
|
+
async stopAll() {
|
|
2987
|
+
await Promise.all([...this.live.keys()].map((id) => this.stop(id)));
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2675
2991
|
// packages/daemon/src/store.ts
|
|
2676
2992
|
import { Database } from "bun:sqlite";
|
|
2677
2993
|
import {
|
|
2678
2994
|
closeSync,
|
|
2679
2995
|
existsSync as existsSync4,
|
|
2680
|
-
mkdirSync as
|
|
2681
|
-
openSync,
|
|
2996
|
+
mkdirSync as mkdirSync3,
|
|
2997
|
+
openSync as openSync2,
|
|
2682
2998
|
readdirSync,
|
|
2683
2999
|
readFileSync as readFileSync3,
|
|
2684
3000
|
readSync,
|
|
@@ -2688,11 +3004,11 @@ import {
|
|
|
2688
3004
|
writeFileSync as writeFileSync2
|
|
2689
3005
|
} from "fs";
|
|
2690
3006
|
import { homedir as homedir3 } from "os";
|
|
2691
|
-
import { basename, dirname, join as
|
|
3007
|
+
import { basename, dirname, join as join6 } from "path";
|
|
2692
3008
|
|
|
2693
3009
|
// packages/daemon/src/git.ts
|
|
2694
3010
|
import { realpathSync } from "fs";
|
|
2695
|
-
import { join as
|
|
3011
|
+
import { join as join5 } from "path";
|
|
2696
3012
|
function git(cwd, args) {
|
|
2697
3013
|
try {
|
|
2698
3014
|
const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
@@ -2706,7 +3022,7 @@ function gitCommonDir(cwd) {
|
|
|
2706
3022
|
if (!out)
|
|
2707
3023
|
return null;
|
|
2708
3024
|
try {
|
|
2709
|
-
return realpathSync(out.startsWith("/") ? out :
|
|
3025
|
+
return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
|
|
2710
3026
|
} catch {
|
|
2711
3027
|
return null;
|
|
2712
3028
|
}
|
|
@@ -2861,6 +3177,16 @@ CREATE TABLE IF NOT EXISTS processes (
|
|
|
2861
3177
|
cwd TEXT, cmd TEXT, owner TEXT, log TEXT, started_at TEXT, ended_at TEXT
|
|
2862
3178
|
);
|
|
2863
3179
|
CREATE INDEX IF NOT EXISTS processes_live ON processes(ended_at, project_id);
|
|
3180
|
+
CREATE TABLE IF NOT EXISTS gates (
|
|
3181
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, gate TEXT, verdict TEXT,
|
|
3182
|
+
rubric TEXT, evidence TEXT, session_id TEXT, created_at TEXT
|
|
3183
|
+
);
|
|
3184
|
+
CREATE INDEX IF NOT EXISTS gates_task ON gates(project_id, task, created_at);
|
|
3185
|
+
CREATE TABLE IF NOT EXISTS handoffs (
|
|
3186
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, done TEXT, remaining TEXT,
|
|
3187
|
+
files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
|
|
3188
|
+
);
|
|
3189
|
+
CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
|
|
2864
3190
|
CREATE TABLE IF NOT EXISTS claims (
|
|
2865
3191
|
project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
|
|
2866
3192
|
acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
|
|
@@ -2881,15 +3207,15 @@ class Store {
|
|
|
2881
3207
|
gen = 0;
|
|
2882
3208
|
memo = new Map;
|
|
2883
3209
|
constructor(home = swarmHome()) {
|
|
2884
|
-
|
|
3210
|
+
mkdirSync3(home, { recursive: true });
|
|
2885
3211
|
this.home = home;
|
|
2886
|
-
this.db = new Database(
|
|
3212
|
+
this.db = new Database(join6(home, "swarm.db"));
|
|
2887
3213
|
this.loadPricing();
|
|
2888
3214
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
|
|
2889
3215
|
this.db.exec(SCHEMA);
|
|
2890
3216
|
this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
|
|
2891
3217
|
this.ensureColumn("projects", "sort_order", "INTEGER");
|
|
2892
|
-
this.migrateProjectsJson(
|
|
3218
|
+
this.migrateProjectsJson(join6(home, "projects.json"));
|
|
2893
3219
|
this.reconcileMovedProjects();
|
|
2894
3220
|
this.slimExistingEvents();
|
|
2895
3221
|
this.retypeNotificationIncidents();
|
|
@@ -2991,17 +3317,186 @@ class Store {
|
|
|
2991
3317
|
return v;
|
|
2992
3318
|
}
|
|
2993
3319
|
rulesCache = new Map;
|
|
3320
|
+
preregisterSpawnedSession(id, projectId, cwd, task) {
|
|
3321
|
+
const now = new Date().toISOString();
|
|
3322
|
+
this.db.query(`INSERT INTO sessions (id, project_id, kind, cwd, started_at, last_seen_at, last, last_type, state, title)
|
|
3323
|
+
VALUES (?, ?, 'spawned', ?, ?, ?, ?, 'session.started', 'active', ?)
|
|
3324
|
+
ON CONFLICT(id) DO UPDATE SET kind = 'spawned', project_id = excluded.project_id, cwd = excluded.cwd`).run(id, projectId, cwd, now, now, `swarm run ${task}`, `run: ${task}`);
|
|
3325
|
+
this.touch();
|
|
3326
|
+
}
|
|
3327
|
+
endSpawnedSession(id) {
|
|
3328
|
+
const now = new Date().toISOString();
|
|
3329
|
+
this.db.query("UPDATE sessions SET state = 'ended', ended_at = COALESCE(ended_at, ?), last_seen_at = ? WHERE id = ?").run(now, now, id);
|
|
3330
|
+
this.touch();
|
|
3331
|
+
}
|
|
3332
|
+
recordHandoff(projectId, h) {
|
|
3333
|
+
if (!this.project(projectId))
|
|
3334
|
+
return { ok: false, reason: "unknown project" };
|
|
3335
|
+
const v = validateHandoff(h);
|
|
3336
|
+
if (!v.ok)
|
|
3337
|
+
return v;
|
|
3338
|
+
const handoff = {
|
|
3339
|
+
task: h.task.trim(),
|
|
3340
|
+
done: h.done.trim(),
|
|
3341
|
+
remaining: h.remaining.trim(),
|
|
3342
|
+
files: (h.files ?? []).map((f) => f.trim()).filter(Boolean).slice(0, 50),
|
|
3343
|
+
verify: h.verify?.trim() || null,
|
|
3344
|
+
by: h.by?.trim() || null,
|
|
3345
|
+
createdAt: new Date().toISOString()
|
|
3346
|
+
};
|
|
3347
|
+
const sessionId = this.knownSession(h.sessionId);
|
|
3348
|
+
this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
|
|
3349
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
|
|
3350
|
+
this.append({
|
|
3351
|
+
ts: handoff.createdAt,
|
|
3352
|
+
type: "handoff.recorded",
|
|
3353
|
+
projectId,
|
|
3354
|
+
sessionId,
|
|
3355
|
+
payload: { task: handoff.task, by: handoff.by, summary: `handoff on ${handoff.task}` }
|
|
3356
|
+
});
|
|
3357
|
+
this.touch();
|
|
3358
|
+
return { ok: true, handoff };
|
|
3359
|
+
}
|
|
3360
|
+
latestHandoff(projectId, task) {
|
|
3361
|
+
const r = this.db.query("SELECT * FROM handoffs WHERE project_id = ? AND task = ? ORDER BY id DESC LIMIT 1").get(projectId, task);
|
|
3362
|
+
if (!r)
|
|
3363
|
+
return null;
|
|
3364
|
+
return {
|
|
3365
|
+
task: r.task,
|
|
3366
|
+
done: r.done,
|
|
3367
|
+
remaining: r.remaining,
|
|
3368
|
+
files: JSON.parse(r.files || "[]"),
|
|
3369
|
+
verify: r.verify ?? null,
|
|
3370
|
+
by: r.by ?? null,
|
|
3371
|
+
createdAt: r.created_at
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
handoffs(projectId, limit = 50) {
|
|
3375
|
+
return this.db.query("SELECT * FROM handoffs WHERE project_id = ? ORDER BY id DESC LIMIT ?").all(projectId, limit).map((r) => ({
|
|
3376
|
+
task: r.task,
|
|
3377
|
+
done: r.done,
|
|
3378
|
+
remaining: r.remaining,
|
|
3379
|
+
files: JSON.parse(r.files || "[]"),
|
|
3380
|
+
verify: r.verify ?? null,
|
|
3381
|
+
by: r.by ?? null,
|
|
3382
|
+
createdAt: r.created_at,
|
|
3383
|
+
sessionId: r.session_id ?? null
|
|
3384
|
+
}));
|
|
3385
|
+
}
|
|
3386
|
+
sessionContext(cwd) {
|
|
3387
|
+
if (!cwd || !existsSync4(cwd))
|
|
3388
|
+
return null;
|
|
3389
|
+
const toplevel = this.toplevel(cwd);
|
|
3390
|
+
const project = this.resolveProject(cwd);
|
|
3391
|
+
const lines = [];
|
|
3392
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
3393
|
+
if (held) {
|
|
3394
|
+
const left = Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000));
|
|
3395
|
+
lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
|
|
3396
|
+
const h = this.latestHandoff(held.projectId, held.task);
|
|
3397
|
+
if (h)
|
|
3398
|
+
lines.push(formatHandoff(h));
|
|
3399
|
+
const required = this.requiredGates(held.projectId);
|
|
3400
|
+
if (required.length) {
|
|
3401
|
+
const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
|
|
3402
|
+
lines.push(`[swarm] gates on ${held.task}: ${st.map((g) => `${g.gate} ${g.verdict ?? "not run"}`).join(", ")} \u2014 record with swarm_gate_record (rubric required)`);
|
|
3403
|
+
}
|
|
3404
|
+
} else if (project) {
|
|
3405
|
+
const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
|
|
3406
|
+
if (active.length)
|
|
3407
|
+
lines.push(`[swarm] ${project.name}: held by others \u2014 ${active.map((c) => `${c.task} (${c.owner})`).join(", ")}. Claim a task (swarm_claim) to get your own worktree.`);
|
|
3408
|
+
}
|
|
3409
|
+
const res = this.resources(project?.id).filter((r) => !r.released);
|
|
3410
|
+
if (res.length)
|
|
3411
|
+
lines.push(`[swarm] resources held: ${res.map((r) => `${r.name}${r.port ? `:${r.port}` : ""} (${r.owner})`).join(", ")} \u2014 their ports are protected; don't kill them`);
|
|
3412
|
+
const modes = this.rulesFor(toplevel);
|
|
3413
|
+
const on = [
|
|
3414
|
+
"shared_tree",
|
|
3415
|
+
"destructive_git",
|
|
3416
|
+
"pattern_kill",
|
|
3417
|
+
"protected_ports",
|
|
3418
|
+
"no_foreign_worktree",
|
|
3419
|
+
"claim_required_to_write"
|
|
3420
|
+
].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
|
|
3421
|
+
if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
|
|
3422
|
+
lines.push(`[swarm] rules: ${on.join(" ")}`);
|
|
3423
|
+
return lines.length ? lines.join(`
|
|
3424
|
+
`) : null;
|
|
3425
|
+
}
|
|
3426
|
+
rowToGate(r) {
|
|
3427
|
+
return {
|
|
3428
|
+
id: r.id,
|
|
3429
|
+
projectId: r.project_id,
|
|
3430
|
+
task: r.task,
|
|
3431
|
+
gate: r.gate,
|
|
3432
|
+
verdict: r.verdict,
|
|
3433
|
+
rubric: r.rubric,
|
|
3434
|
+
evidence: r.evidence ?? null,
|
|
3435
|
+
sessionId: r.session_id ?? null,
|
|
3436
|
+
createdAt: r.created_at
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
gateRuns(projectId, task, limit = 200) {
|
|
3440
|
+
const rows = task ? this.db.query("SELECT * FROM gates WHERE project_id = ? AND task = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, task, limit) : this.db.query("SELECT * FROM gates WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, limit);
|
|
3441
|
+
return rows.map((r) => this.rowToGate(r));
|
|
3442
|
+
}
|
|
3443
|
+
gateStatusFor(runs, required) {
|
|
3444
|
+
return gateStatus(runs, required);
|
|
3445
|
+
}
|
|
3446
|
+
requiredGates(projectId) {
|
|
3447
|
+
const p = this.project(projectId);
|
|
3448
|
+
return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates.required : [];
|
|
3449
|
+
}
|
|
3450
|
+
recordGate(projectId, input) {
|
|
3451
|
+
if (!this.project(projectId))
|
|
3452
|
+
return { ok: false, reason: "unknown project" };
|
|
3453
|
+
const v = validateGateRun(input);
|
|
3454
|
+
if (!v.ok)
|
|
3455
|
+
return v;
|
|
3456
|
+
const createdAt = new Date().toISOString();
|
|
3457
|
+
const sessionId = this.knownSession(input.sessionId);
|
|
3458
|
+
const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
|
|
3459
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
|
|
3460
|
+
const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
|
|
3461
|
+
this.append({
|
|
3462
|
+
ts: createdAt,
|
|
3463
|
+
type: "gate.recorded",
|
|
3464
|
+
projectId,
|
|
3465
|
+
sessionId,
|
|
3466
|
+
payload: {
|
|
3467
|
+
task: run2.task,
|
|
3468
|
+
gate: run2.gate,
|
|
3469
|
+
verdict: run2.verdict,
|
|
3470
|
+
summary: `gate ${run2.gate} ${run2.verdict} on ${run2.task}`
|
|
3471
|
+
}
|
|
3472
|
+
});
|
|
3473
|
+
if (run2.verdict === "fail")
|
|
3474
|
+
this.append({
|
|
3475
|
+
ts: createdAt,
|
|
3476
|
+
type: "incident.opened",
|
|
3477
|
+
projectId,
|
|
3478
|
+
sessionId,
|
|
3479
|
+
payload: {
|
|
3480
|
+
rule: "gate_failed",
|
|
3481
|
+
action: "failed",
|
|
3482
|
+
command: `${run2.task} \xB7 ${run2.gate}`,
|
|
3483
|
+
reason: `${run2.rubric}${run2.evidence ? ` \u2014 ${run2.evidence.slice(0, 200)}` : ""}`
|
|
3484
|
+
}
|
|
3485
|
+
});
|
|
3486
|
+
this.touch();
|
|
3487
|
+
return { ok: true, run: run2 };
|
|
3488
|
+
}
|
|
2994
3489
|
taskCache = new Map;
|
|
2995
3490
|
tasks(projectId) {
|
|
2996
3491
|
const p = this.project(projectId);
|
|
2997
3492
|
if (!p)
|
|
2998
3493
|
return null;
|
|
2999
|
-
const source = loadConfig({ repoRoot: p.root }).tasks.source;
|
|
3494
|
+
const source = loadConfig({ repoRoot: p.root, home: this.home }).tasks.source;
|
|
3000
3495
|
if (!source)
|
|
3001
3496
|
return null;
|
|
3002
|
-
const path =
|
|
3497
|
+
const path = join6(p.root, source);
|
|
3003
3498
|
if (!existsSync4(path))
|
|
3004
|
-
return { source, tasks: [] };
|
|
3499
|
+
return { source, required: this.requiredGates(projectId), tasks: [] };
|
|
3005
3500
|
const mtime = statSync(path).mtimeMs;
|
|
3006
3501
|
let hit = this.taskCache.get(projectId);
|
|
3007
3502
|
if (!hit || hit.path !== path || hit.mtime !== mtime) {
|
|
@@ -3010,17 +3505,80 @@ class Store {
|
|
|
3010
3505
|
}
|
|
3011
3506
|
const now = Date.now();
|
|
3012
3507
|
const active = this.claimRows(projectId).filter((c) => isActive(c, now));
|
|
3013
|
-
|
|
3508
|
+
const required = this.requiredGates(projectId);
|
|
3509
|
+
const runs = this.gateRuns(projectId, undefined, 2000);
|
|
3510
|
+
const byTask = new Map;
|
|
3511
|
+
for (const r of runs)
|
|
3512
|
+
byTask.set(r.task, [...byTask.get(r.task) ?? [], r]);
|
|
3513
|
+
const board = taskBoard(hit.tasks, active).map((t) => {
|
|
3514
|
+
const tr = byTask.get(t.id) ?? [];
|
|
3515
|
+
return {
|
|
3516
|
+
...t,
|
|
3517
|
+
gates: gateStatus(tr, required).map((g) => ({
|
|
3518
|
+
gate: g.gate,
|
|
3519
|
+
verdict: g.verdict,
|
|
3520
|
+
fails: g.fails,
|
|
3521
|
+
runs: g.runs
|
|
3522
|
+
})),
|
|
3523
|
+
gated: gatesSatisfied(tr, required)
|
|
3524
|
+
};
|
|
3525
|
+
});
|
|
3526
|
+
return { source, required, tasks: board };
|
|
3014
3527
|
}
|
|
3015
3528
|
rulesFor(repoRoot) {
|
|
3016
3529
|
const key = repoRoot ?? "";
|
|
3017
3530
|
const hit = this.rulesCache.get(key);
|
|
3018
3531
|
if (hit && Date.now() - hit.at < 30000)
|
|
3019
3532
|
return hit.rules;
|
|
3020
|
-
const rules2 = loadConfig({ repoRoot }).rules;
|
|
3533
|
+
const rules2 = loadConfig({ repoRoot, home: this.home }).rules;
|
|
3021
3534
|
this.rulesCache.set(key, { at: Date.now(), rules: rules2 });
|
|
3022
3535
|
return rules2;
|
|
3023
3536
|
}
|
|
3537
|
+
evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
|
|
3538
|
+
const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
|
|
3539
|
+
const cmd = tool === "Bash" ? input.command : undefined;
|
|
3540
|
+
const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
|
|
3541
|
+
const modes = this.rulesFor(current.toplevel);
|
|
3542
|
+
if (isWrite && (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off")) {
|
|
3543
|
+
const target = absolutePath(input.file_path, cwd);
|
|
3544
|
+
const w = guardWrite(target, current, this.heldWorktrees(), modes, "file");
|
|
3545
|
+
if (w.action !== "allow") {
|
|
3546
|
+
if (recordIncident)
|
|
3547
|
+
this.openIncident(w, cwd, sessionId, `${tool} ${target}`);
|
|
3548
|
+
return { decision: w, display: `${tool} ${target}` };
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
if (cmd) {
|
|
3552
|
+
if (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off") {
|
|
3553
|
+
const w = guardWrite(cwd, current, this.heldWorktrees(), modes, "bash");
|
|
3554
|
+
if (w.action !== "allow") {
|
|
3555
|
+
if (recordIncident)
|
|
3556
|
+
this.openIncident(w, cwd, sessionId, cmd);
|
|
3557
|
+
return { decision: w, display: cmd };
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
const d = guardBash(cmd, current, this.liveSessions(), Date.now(), {
|
|
3561
|
+
...modes,
|
|
3562
|
+
protected: { ports: [...new Set([...modes.protected.ports, ...this.heldPorts()])] }
|
|
3563
|
+
});
|
|
3564
|
+
if (d.action !== "allow" && recordIncident)
|
|
3565
|
+
this.openIncident(d, cwd, sessionId, cmd);
|
|
3566
|
+
return { decision: d, display: cmd };
|
|
3567
|
+
}
|
|
3568
|
+
return {
|
|
3569
|
+
decision: { action: "allow" },
|
|
3570
|
+
display: isWrite ? `${tool} ${input.file_path}` : tool
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
liveSessions() {
|
|
3574
|
+
const rows = this.db.query("SELECT id, cwd, last_seen_at, state FROM sessions WHERE state != 'ended' AND last_seen_at > ?").all(new Date(Date.now() - LIVE_WINDOW_MS - 1e4).toISOString());
|
|
3575
|
+
return rows.map((r) => ({
|
|
3576
|
+
id: r.id,
|
|
3577
|
+
toplevel: this.toplevel(r.cwd),
|
|
3578
|
+
lastSeenAt: r.last_seen_at,
|
|
3579
|
+
state: r.state
|
|
3580
|
+
}));
|
|
3581
|
+
}
|
|
3024
3582
|
guardHook(raw2) {
|
|
3025
3583
|
const tool = typeof raw2.tool_name === "string" ? raw2.tool_name : "";
|
|
3026
3584
|
const input = raw2.tool_input ?? {};
|
|
@@ -3077,7 +3635,7 @@ class Store {
|
|
|
3077
3635
|
loadPricing() {
|
|
3078
3636
|
this.prices = { ...PRICES };
|
|
3079
3637
|
for (const f of ["pricing.litellm.json", "pricing.json"]) {
|
|
3080
|
-
const p =
|
|
3638
|
+
const p = join6(this.home, f);
|
|
3081
3639
|
if (!existsSync4(p))
|
|
3082
3640
|
continue;
|
|
3083
3641
|
try {
|
|
@@ -3093,7 +3651,7 @@ class Store {
|
|
|
3093
3651
|
throw new Error(`pricing fetch ${r.status}`);
|
|
3094
3652
|
const j = await r.json();
|
|
3095
3653
|
const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
|
|
3096
|
-
writeFileSync2(
|
|
3654
|
+
writeFileSync2(join6(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
|
|
3097
3655
|
this.loadPricing();
|
|
3098
3656
|
this.reprice();
|
|
3099
3657
|
}
|
|
@@ -3232,6 +3790,8 @@ class Store {
|
|
|
3232
3790
|
return n;
|
|
3233
3791
|
}
|
|
3234
3792
|
ingestHook(event, raw2) {
|
|
3793
|
+
if (typeof raw2.cwd === "string")
|
|
3794
|
+
this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
|
|
3235
3795
|
const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
|
|
3236
3796
|
const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
|
|
3237
3797
|
const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
|
|
@@ -3245,8 +3805,24 @@ class Store {
|
|
|
3245
3805
|
}
|
|
3246
3806
|
return e;
|
|
3247
3807
|
}
|
|
3808
|
+
static LEDGER_EVENTS = new Set([
|
|
3809
|
+
"process.started",
|
|
3810
|
+
"process.exited",
|
|
3811
|
+
"resource.acquired",
|
|
3812
|
+
"resource.released",
|
|
3813
|
+
"resource.reaped",
|
|
3814
|
+
"claim.acquired",
|
|
3815
|
+
"claim.renewed",
|
|
3816
|
+
"claim.released",
|
|
3817
|
+
"claim.orphaned",
|
|
3818
|
+
"gate.recorded",
|
|
3819
|
+
"handoff.recorded",
|
|
3820
|
+
"incident.opened",
|
|
3821
|
+
"incident.acked",
|
|
3822
|
+
"run.result"
|
|
3823
|
+
]);
|
|
3248
3824
|
projectSession(e) {
|
|
3249
|
-
if (!e.sessionId)
|
|
3825
|
+
if (!e.sessionId || Store.LEDGER_EVENTS.has(e.type))
|
|
3250
3826
|
return;
|
|
3251
3827
|
const p = e.payload;
|
|
3252
3828
|
const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
|
|
@@ -3270,7 +3846,7 @@ class Store {
|
|
|
3270
3846
|
const size = statSync(path).size;
|
|
3271
3847
|
if (size <= offset)
|
|
3272
3848
|
return null;
|
|
3273
|
-
const fd =
|
|
3849
|
+
const fd = openSync2(path, "r");
|
|
3274
3850
|
const buf = Buffer.alloc(size - offset);
|
|
3275
3851
|
readSync(fd, buf, 0, buf.length, offset);
|
|
3276
3852
|
closeSync(fd);
|
|
@@ -3311,6 +3887,10 @@ class Store {
|
|
|
3311
3887
|
const lastText = [...d.turns].reverse().find((t) => t.text && !t.sidechain)?.text ?? null;
|
|
3312
3888
|
const lastModel = [...d.turns].reverse().find((t) => !t.sidechain)?.model ?? null;
|
|
3313
3889
|
this.db.query("UPDATE sessions SET title = COALESCE(?, title), model = COALESCE(?, model), version = COALESCE(?, version), last_text = COALESCE(?, last_text), branch = COALESCE(branch, ?), last_seen_at = MAX(COALESCE(last_seen_at, ''), ?) WHERE id = ?").run(d.title, agentId ? null : lastModel, d.version, agentId ? null : lastText, d.branch, new Date().toISOString(), sessionId);
|
|
3890
|
+
if (d.turns.length) {
|
|
3891
|
+
const cwdRow = this.db.query("SELECT cwd FROM sessions WHERE id = ?").get(sessionId);
|
|
3892
|
+
this.autoRenewFor(sessionId, cwdRow?.cwd);
|
|
3893
|
+
}
|
|
3314
3894
|
this.db.query("INSERT INTO tails (path, session_id, agent_id, offset) VALUES (?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET offset = excluded.offset").run(path, sessionId, agentId, r.next);
|
|
3315
3895
|
return d.turns.length;
|
|
3316
3896
|
}
|
|
@@ -3319,9 +3899,9 @@ class Store {
|
|
|
3319
3899
|
if (!s?.transcript_path || !existsSync4(s.transcript_path))
|
|
3320
3900
|
return 0;
|
|
3321
3901
|
let n = this.tailFile(s.transcript_path, sessionId, null);
|
|
3322
|
-
const subDir =
|
|
3902
|
+
const subDir = join6(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
|
|
3323
3903
|
for (const f of this.subagentFiles(subDir)) {
|
|
3324
|
-
n += this.tailFile(
|
|
3904
|
+
n += this.tailFile(join6(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
|
|
3325
3905
|
}
|
|
3326
3906
|
return n;
|
|
3327
3907
|
}
|
|
@@ -3353,7 +3933,7 @@ class Store {
|
|
|
3353
3933
|
return n;
|
|
3354
3934
|
}
|
|
3355
3935
|
codexRoot() {
|
|
3356
|
-
return process.env.SWARM_CODEX_DIR ??
|
|
3936
|
+
return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
|
|
3357
3937
|
}
|
|
3358
3938
|
codexRolloutFiles(sinceMs) {
|
|
3359
3939
|
const root = this.codexRoot();
|
|
@@ -3368,18 +3948,18 @@ class Store {
|
|
|
3368
3948
|
for (const y of ls(root)) {
|
|
3369
3949
|
if (!/^\d{4}$/.test(y))
|
|
3370
3950
|
continue;
|
|
3371
|
-
for (const m of ls(
|
|
3951
|
+
for (const m of ls(join6(root, y))) {
|
|
3372
3952
|
if (!/^\d\d$/.test(m))
|
|
3373
3953
|
continue;
|
|
3374
|
-
for (const day of ls(
|
|
3954
|
+
for (const day of ls(join6(root, y, m))) {
|
|
3375
3955
|
if (!/^\d\d$/.test(day))
|
|
3376
3956
|
continue;
|
|
3377
3957
|
if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
|
|
3378
3958
|
continue;
|
|
3379
|
-
const dir =
|
|
3959
|
+
const dir = join6(root, y, m, day);
|
|
3380
3960
|
for (const f of ls(dir)) {
|
|
3381
3961
|
if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
|
|
3382
|
-
out.push(
|
|
3962
|
+
out.push(join6(dir, f));
|
|
3383
3963
|
}
|
|
3384
3964
|
}
|
|
3385
3965
|
}
|
|
@@ -3396,7 +3976,7 @@ class Store {
|
|
|
3396
3976
|
return n;
|
|
3397
3977
|
}
|
|
3398
3978
|
grokRoot() {
|
|
3399
|
-
return process.env.SWARM_GROK_DIR ??
|
|
3979
|
+
return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
|
|
3400
3980
|
}
|
|
3401
3981
|
grokSummary = new Map;
|
|
3402
3982
|
tailGrok(windowMs = 3 * 24 * 60 * 60000) {
|
|
@@ -3421,9 +4001,9 @@ class Store {
|
|
|
3421
4001
|
} catch {
|
|
3422
4002
|
cwd = enc;
|
|
3423
4003
|
}
|
|
3424
|
-
const cwdDir =
|
|
4004
|
+
const cwdDir = join6(root, enc);
|
|
3425
4005
|
for (const sid of ls(cwdDir)) {
|
|
3426
|
-
const path =
|
|
4006
|
+
const path = join6(cwdDir, sid, "updates.jsonl");
|
|
3427
4007
|
if (!existsSync4(path))
|
|
3428
4008
|
continue;
|
|
3429
4009
|
try {
|
|
@@ -3432,7 +4012,7 @@ class Store {
|
|
|
3432
4012
|
} catch {
|
|
3433
4013
|
continue;
|
|
3434
4014
|
}
|
|
3435
|
-
const sumPath =
|
|
4015
|
+
const sumPath = join6(cwdDir, sid, "summary.json");
|
|
3436
4016
|
let title;
|
|
3437
4017
|
let fresh = false;
|
|
3438
4018
|
try {
|
|
@@ -3519,7 +4099,7 @@ class Store {
|
|
|
3519
4099
|
worktreePath(projectId, task) {
|
|
3520
4100
|
const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
3521
4101
|
const p = this.project(projectId);
|
|
3522
|
-
return
|
|
4102
|
+
return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
|
|
3523
4103
|
}
|
|
3524
4104
|
claim(projectId, task, owner, baseRef = "HEAD") {
|
|
3525
4105
|
const p = this.project(projectId);
|
|
@@ -3533,7 +4113,7 @@ class Store {
|
|
|
3533
4113
|
const worktree = this.worktreePath(projectId, task);
|
|
3534
4114
|
if (existsSync4(worktree))
|
|
3535
4115
|
return { ok: false, error: `${worktree} already exists; release ${task} first` };
|
|
3536
|
-
|
|
4116
|
+
mkdirSync3(dirname(worktree), { recursive: true });
|
|
3537
4117
|
const created = worktreeAdd(p.root, worktree, branch, baseRef);
|
|
3538
4118
|
if (!created)
|
|
3539
4119
|
return { ok: false, error: `git worktree add failed for ${task}` };
|
|
@@ -3553,6 +4133,82 @@ class Store {
|
|
|
3553
4133
|
});
|
|
3554
4134
|
return { ok: true, task, owner, worktree: created, branch, expiresAt };
|
|
3555
4135
|
}
|
|
4136
|
+
autoRenewAt = new Map;
|
|
4137
|
+
autoRenewFor(sessionId, cwd) {
|
|
4138
|
+
if (!cwd)
|
|
4139
|
+
return;
|
|
4140
|
+
const key = sessionId ?? cwd;
|
|
4141
|
+
const last = this.autoRenewAt.get(key) ?? 0;
|
|
4142
|
+
const now = Date.now();
|
|
4143
|
+
if (now - last < 60000)
|
|
4144
|
+
return;
|
|
4145
|
+
this.autoRenewAt.set(key, now);
|
|
4146
|
+
for (const c of this.heldClaimsWithWorktree()) {
|
|
4147
|
+
if (!isInside(cwd, c.worktree))
|
|
4148
|
+
continue;
|
|
4149
|
+
if (!shouldAutoRenew({ state: "held", expiresAt: c.expiresAt }, now))
|
|
4150
|
+
continue;
|
|
4151
|
+
const expiresAt = nextExpiry(now);
|
|
4152
|
+
this.db.query("UPDATE claims SET expires_at = ? WHERE project_id = ? AND task = ? AND state = 'held'").run(expiresAt, c.projectId, c.task);
|
|
4153
|
+
this.append({
|
|
4154
|
+
ts: new Date(now).toISOString(),
|
|
4155
|
+
type: "claim.renewed",
|
|
4156
|
+
projectId: c.projectId,
|
|
4157
|
+
sessionId: this.knownSession(sessionId),
|
|
4158
|
+
payload: { task: c.task, expiresAt, auto: true, summary: `auto-renew ${c.task}` }
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
heldClaimsWithWorktree() {
|
|
4163
|
+
return this.db.query("SELECT project_id, task, worktree, expires_at FROM claims WHERE state = 'held' AND worktree != ''").all().map((r) => ({
|
|
4164
|
+
projectId: r.project_id,
|
|
4165
|
+
task: r.task,
|
|
4166
|
+
worktree: r.worktree,
|
|
4167
|
+
expiresAt: r.expires_at
|
|
4168
|
+
}));
|
|
4169
|
+
}
|
|
4170
|
+
sweepOrphans() {
|
|
4171
|
+
const now = Date.now();
|
|
4172
|
+
let n = 0;
|
|
4173
|
+
for (const p of this.projects()) {
|
|
4174
|
+
for (const c of this.claimRows(p.id)) {
|
|
4175
|
+
if (c.state !== "held" || isActive(c, now))
|
|
4176
|
+
continue;
|
|
4177
|
+
const exists = c.worktree ? existsSync4(c.worktree) : false;
|
|
4178
|
+
const work = exists ? heldWork(c.worktree) : null;
|
|
4179
|
+
if (reapAction(c, now, exists, work) !== "keep-orphaned")
|
|
4180
|
+
continue;
|
|
4181
|
+
this.db.query("UPDATE claims SET state = 'orphaned' WHERE project_id = ? AND task = ?").run(p.id, c.task);
|
|
4182
|
+
const ts = new Date(now).toISOString();
|
|
4183
|
+
this.append({
|
|
4184
|
+
ts,
|
|
4185
|
+
type: "claim.orphaned",
|
|
4186
|
+
projectId: p.id,
|
|
4187
|
+
sessionId: null,
|
|
4188
|
+
payload: {
|
|
4189
|
+
task: c.task,
|
|
4190
|
+
worktree: c.worktree,
|
|
4191
|
+
summary: `orphaned ${c.task} (holds work)`
|
|
4192
|
+
}
|
|
4193
|
+
});
|
|
4194
|
+
this.append({
|
|
4195
|
+
ts,
|
|
4196
|
+
type: "incident.opened",
|
|
4197
|
+
projectId: p.id,
|
|
4198
|
+
sessionId: null,
|
|
4199
|
+
payload: {
|
|
4200
|
+
rule: "orphaned_claim",
|
|
4201
|
+
action: "orphaned",
|
|
4202
|
+
command: `${c.task} \u2192 ${c.worktree}`,
|
|
4203
|
+
reason: `The lease on "${c.task}" (held by ${c.owner}) expired while its worktree still holds ${work?.dirty ? "uncommitted" : "unpushed"} work. Nothing was removed: renew it, finish and push, or force-release to discard.`
|
|
4204
|
+
}
|
|
4205
|
+
});
|
|
4206
|
+
this.touch();
|
|
4207
|
+
n++;
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
return n;
|
|
4211
|
+
}
|
|
3556
4212
|
renew(projectId, task) {
|
|
3557
4213
|
const row = this.db.query("SELECT state FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
|
|
3558
4214
|
if (!row)
|
|
@@ -4262,13 +4918,13 @@ function rowToEvent(r) {
|
|
|
4262
4918
|
}
|
|
4263
4919
|
|
|
4264
4920
|
// packages/daemon/src/app.ts
|
|
4265
|
-
var VERSION = "0.
|
|
4921
|
+
var VERSION = "0.5.0";
|
|
4266
4922
|
var WEB_DIR = (() => {
|
|
4267
4923
|
if (process.env.SWARM_WEB_DIR)
|
|
4268
4924
|
return process.env.SWARM_WEB_DIR;
|
|
4269
4925
|
const here = dirname2(fileURLToPath(import.meta.url));
|
|
4270
|
-
const dev =
|
|
4271
|
-
return existsSync5(
|
|
4926
|
+
const dev = join7(here, "../../web/public");
|
|
4927
|
+
return existsSync5(join7(dev, "index.html")) ? dev : join7(here, "../web");
|
|
4272
4928
|
})();
|
|
4273
4929
|
var REPLAY_TAIL = 200;
|
|
4274
4930
|
var wireCache = new WeakMap;
|
|
@@ -4283,6 +4939,7 @@ function wireJson(e) {
|
|
|
4283
4939
|
function createApp(store = new Store) {
|
|
4284
4940
|
const app = new Hono2;
|
|
4285
4941
|
const forge2 = new ForgeService(store);
|
|
4942
|
+
const runner = new Runner(store, store.home);
|
|
4286
4943
|
app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
|
|
4287
4944
|
app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
|
|
4288
4945
|
app.post("/v1/projects", async (c) => {
|
|
@@ -4316,7 +4973,7 @@ function createApp(store = new Store) {
|
|
|
4316
4973
|
dir = homedir4();
|
|
4317
4974
|
}
|
|
4318
4975
|
try {
|
|
4319
|
-
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync5(
|
|
4976
|
+
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync5(join7(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
|
|
4320
4977
|
const parent = dirname2(dir);
|
|
4321
4978
|
return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
|
|
4322
4979
|
} catch (e) {
|
|
@@ -4390,6 +5047,92 @@ function createApp(store = new Store) {
|
|
|
4390
5047
|
const r = await store.stopProcess(pid, c.req.query("project") || null);
|
|
4391
5048
|
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
4392
5049
|
});
|
|
5050
|
+
app.get("/v1/runs", (c) => c.json(runner.list(c.req.query("project") || undefined)));
|
|
5051
|
+
app.post("/v1/runs", async (c) => {
|
|
5052
|
+
const b = await c.req.json().catch(() => ({}));
|
|
5053
|
+
if (!b.projectId || !b.task || !b.prompt)
|
|
5054
|
+
return c.json({ ok: false, error: "projectId, task and prompt required" }, 400);
|
|
5055
|
+
const r = await runner.start({
|
|
5056
|
+
projectId: b.projectId,
|
|
5057
|
+
task: b.task,
|
|
5058
|
+
prompt: b.prompt,
|
|
5059
|
+
owner: b.owner ?? "dashboard",
|
|
5060
|
+
model: b.model,
|
|
5061
|
+
permissionMode: b.permissionMode,
|
|
5062
|
+
allowedTools: b.allowedTools,
|
|
5063
|
+
maxTurns: b.maxTurns
|
|
5064
|
+
});
|
|
5065
|
+
return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
|
|
5066
|
+
});
|
|
5067
|
+
app.post("/v1/runs/:id/send", async (c) => {
|
|
5068
|
+
const b = await c.req.json().catch(() => ({}));
|
|
5069
|
+
if (!b.text?.trim())
|
|
5070
|
+
return c.json({ ok: false, error: "text required" }, 400);
|
|
5071
|
+
const r = runner.send(c.req.param("id"), b.text);
|
|
5072
|
+
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
5073
|
+
});
|
|
5074
|
+
app.post("/v1/runs/:id/permissions/:reqId", async (c) => {
|
|
5075
|
+
const b = await c.req.json().catch(() => ({}));
|
|
5076
|
+
const r = runner.answerPermission(c.req.param("id"), c.req.param("reqId"), b.allow === true, b.message);
|
|
5077
|
+
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
5078
|
+
});
|
|
5079
|
+
app.delete("/v1/runs/:id", async (c) => {
|
|
5080
|
+
const r = await runner.stop(c.req.param("id"));
|
|
5081
|
+
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
5082
|
+
});
|
|
5083
|
+
app.get("/v1/handoffs", (c) => {
|
|
5084
|
+
const project = c.req.query("project");
|
|
5085
|
+
if (!project)
|
|
5086
|
+
return c.json({ error: "project required" }, 400);
|
|
5087
|
+
const task = c.req.query("task");
|
|
5088
|
+
if (task) {
|
|
5089
|
+
const h = store.latestHandoff(project, task);
|
|
5090
|
+
return h ? c.json({ handoff: h, text: formatHandoff(h) }) : c.json({ handoff: null, text: null }, 404);
|
|
5091
|
+
}
|
|
5092
|
+
return c.json(store.handoffs(project));
|
|
5093
|
+
});
|
|
5094
|
+
app.post("/v1/handoffs", async (c) => {
|
|
5095
|
+
const b = await c.req.json().catch(() => ({}));
|
|
5096
|
+
if (!b.projectId || !b.task)
|
|
5097
|
+
return c.json({ ok: false, error: "projectId and task required" }, 400);
|
|
5098
|
+
const r = store.recordHandoff(b.projectId, {
|
|
5099
|
+
task: b.task,
|
|
5100
|
+
done: b.done ?? "",
|
|
5101
|
+
remaining: b.remaining ?? "",
|
|
5102
|
+
files: Array.isArray(b.files) ? b.files : [],
|
|
5103
|
+
verify: b.verify ?? null,
|
|
5104
|
+
by: b.by ?? null,
|
|
5105
|
+
sessionId: b.sessionId ?? null
|
|
5106
|
+
});
|
|
5107
|
+
return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 400);
|
|
5108
|
+
});
|
|
5109
|
+
app.get("/v1/gates", (c) => {
|
|
5110
|
+
const project = c.req.query("project");
|
|
5111
|
+
if (!project)
|
|
5112
|
+
return c.json({ error: "project required" }, 400);
|
|
5113
|
+
const task = c.req.query("task") || undefined;
|
|
5114
|
+
const runs = store.gateRuns(project, task);
|
|
5115
|
+
const required = store.requiredGates(project);
|
|
5116
|
+
return c.json({
|
|
5117
|
+
required,
|
|
5118
|
+
runs,
|
|
5119
|
+
status: task ? store.gateStatusFor(runs, required) : undefined
|
|
5120
|
+
});
|
|
5121
|
+
});
|
|
5122
|
+
app.post("/v1/gates", async (c) => {
|
|
5123
|
+
const b = await c.req.json().catch(() => ({}));
|
|
5124
|
+
if (!b.projectId)
|
|
5125
|
+
return c.json({ ok: false, error: "projectId required" }, 400);
|
|
5126
|
+
const r = store.recordGate(b.projectId, {
|
|
5127
|
+
task: b.task ?? "",
|
|
5128
|
+
gate: b.gate ?? "",
|
|
5129
|
+
verdict: b.verdict,
|
|
5130
|
+
rubric: b.rubric,
|
|
5131
|
+
evidence: b.evidence,
|
|
5132
|
+
sessionId: b.sessionId ?? null
|
|
5133
|
+
});
|
|
5134
|
+
return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 400);
|
|
5135
|
+
});
|
|
4393
5136
|
app.get("/v1/tasks", (c) => {
|
|
4394
5137
|
const project = c.req.query("project");
|
|
4395
5138
|
if (!project)
|
|
@@ -4448,6 +5191,14 @@ function createApp(store = new Store) {
|
|
|
4448
5191
|
const event = c.req.param("event");
|
|
4449
5192
|
const raw2 = await c.req.json().catch(() => ({}));
|
|
4450
5193
|
store.ingestHook(event, raw2);
|
|
5194
|
+
if (event === "SessionStart" && typeof raw2.cwd === "string") {
|
|
5195
|
+
const ctx = store.sessionContext(raw2.cwd);
|
|
5196
|
+
if (ctx)
|
|
5197
|
+
return c.json({
|
|
5198
|
+
additionalContext: ctx,
|
|
5199
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
|
|
5200
|
+
});
|
|
5201
|
+
}
|
|
4451
5202
|
if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
|
|
4452
5203
|
const guard = store.guardHook(raw2);
|
|
4453
5204
|
if (guard) {
|
|
@@ -4488,23 +5239,23 @@ function createApp(store = new Store) {
|
|
|
4488
5239
|
});
|
|
4489
5240
|
});
|
|
4490
5241
|
});
|
|
4491
|
-
app.get("/", (c) => c.html(readFileSync4(
|
|
5242
|
+
app.get("/", (c) => c.html(readFileSync4(join7(WEB_DIR, "index.html"), "utf8")));
|
|
4492
5243
|
const MIME = { js: "text/javascript", css: "text/css" };
|
|
4493
5244
|
app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
|
|
4494
5245
|
const f = c.req.param("file");
|
|
4495
|
-
const p =
|
|
5246
|
+
const p = join7(WEB_DIR, f);
|
|
4496
5247
|
if (!existsSync5(p))
|
|
4497
5248
|
return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
|
|
4498
5249
|
return c.body(readFileSync4(p, "utf8"), 200, {
|
|
4499
5250
|
"content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
|
|
4500
5251
|
});
|
|
4501
5252
|
});
|
|
4502
|
-
return { app, store, forge: forge2 };
|
|
5253
|
+
return { app, store, forge: forge2, runner };
|
|
4503
5254
|
}
|
|
4504
5255
|
|
|
4505
5256
|
// packages/daemon/src/bin.ts
|
|
4506
5257
|
var DEFAULT_PORT2 = process.env.SWARM_PORT ? DEFAULT_PORT : loadConfig().daemon.port;
|
|
4507
|
-
var { app, store } = createApp();
|
|
5258
|
+
var { app, store, runner } = createApp();
|
|
4508
5259
|
function serve() {
|
|
4509
5260
|
const bind = (p) => Bun.serve({ port: p, hostname: "127.0.0.1", idleTimeout: 0, fetch: app.fetch });
|
|
4510
5261
|
try {
|
|
@@ -4533,6 +5284,8 @@ var tailer = setInterval(() => {
|
|
|
4533
5284
|
}
|
|
4534
5285
|
store.reapResources();
|
|
4535
5286
|
store.reapProcesses();
|
|
5287
|
+
if (tick % 12 === 0)
|
|
5288
|
+
store.sweepOrphans();
|
|
4536
5289
|
}, 5000);
|
|
4537
5290
|
store.refreshAllWorktrees();
|
|
4538
5291
|
var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);
|
|
@@ -4541,11 +5294,16 @@ var pruner = setInterval(() => store.prune(), 24 * 60 * 60000);
|
|
|
4541
5294
|
if (process.env.SWARM_OFFLINE !== "1")
|
|
4542
5295
|
store.refreshPricing().catch(() => {});
|
|
4543
5296
|
console.log(`swarmd ${VERSION} listening on http://127.0.0.1:${port}`);
|
|
4544
|
-
|
|
5297
|
+
var stopping = false;
|
|
5298
|
+
async function shutdown() {
|
|
5299
|
+
if (stopping)
|
|
5300
|
+
return;
|
|
5301
|
+
stopping = true;
|
|
4545
5302
|
clearInterval(tailer);
|
|
4546
5303
|
clearInterval(wtRefresh);
|
|
4547
5304
|
clearInterval(pruner);
|
|
4548
5305
|
clearDaemonInfo();
|
|
5306
|
+
await Promise.race([runner.stopAll(), Bun.sleep(6000)]);
|
|
4549
5307
|
server.stop(true);
|
|
4550
5308
|
process.exit(0);
|
|
4551
5309
|
}
|