@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/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 join6 } from "path";
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 mkdirSync2,
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 join5 } from "path";
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 join4 } from "path";
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 : join4(cwd, 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
- mkdirSync2(home, { recursive: true });
3210
+ mkdirSync3(home, { recursive: true });
2885
3211
  this.home = home;
2886
- this.db = new Database(join5(home, "swarm.db"));
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(join5(home, "projects.json"));
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 = join5(p.root, source);
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
- return { source, tasks: taskBoard(hit.tasks, active) };
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 = join5(this.home, f);
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(join5(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
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 = openSync(path, "r");
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 = join5(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
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(join5(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
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 ?? join5(homedir3(), ".codex", "sessions");
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(join5(root, y))) {
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(join5(root, y, m))) {
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 = join5(root, y, m, day);
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(join5(dir, f));
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 ?? join5(homedir3(), ".grok", "sessions");
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 = join5(root, enc);
4004
+ const cwdDir = join6(root, enc);
3425
4005
  for (const sid of ls(cwdDir)) {
3426
- const path = join5(cwdDir, sid, "updates.jsonl");
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 = join5(cwdDir, sid, "summary.json");
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 join5(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
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
- mkdirSync2(dirname(worktree), { recursive: true });
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.4.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 = join6(here, "../../web/public");
4271
- return existsSync5(join6(dev, "index.html")) ? dev : join6(here, "../web");
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(join6(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
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(join6(WEB_DIR, "index.html"), "utf8")));
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 = join6(WEB_DIR, f);
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
- function shutdown() {
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
  }