@ra3orblade/swarm 0.4.1 → 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
@@ -2507,7 +2582,7 @@ var EXTRA_BIN_DIRS = [
2507
2582
  function findBin(name) {
2508
2583
  if (!name)
2509
2584
  return null;
2510
- const onPath = Bun.which(name);
2585
+ const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
2511
2586
  if (onPath)
2512
2587
  return onPath;
2513
2588
  for (const d of EXTRA_BIN_DIRS) {
@@ -2600,13 +2675,326 @@ class ForgeService {
2600
2675
  }
2601
2676
  }
2602
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
+
2603
2991
  // packages/daemon/src/store.ts
2604
2992
  import { Database } from "bun:sqlite";
2605
2993
  import {
2606
2994
  closeSync,
2607
2995
  existsSync as existsSync4,
2608
- mkdirSync as mkdirSync2,
2609
- openSync,
2996
+ mkdirSync as mkdirSync3,
2997
+ openSync as openSync2,
2610
2998
  readdirSync,
2611
2999
  readFileSync as readFileSync3,
2612
3000
  readSync,
@@ -2616,11 +3004,11 @@ import {
2616
3004
  writeFileSync as writeFileSync2
2617
3005
  } from "fs";
2618
3006
  import { homedir as homedir3 } from "os";
2619
- import { basename, dirname, join as join5 } from "path";
3007
+ import { basename, dirname, join as join6 } from "path";
2620
3008
 
2621
3009
  // packages/daemon/src/git.ts
2622
3010
  import { realpathSync } from "fs";
2623
- import { join as join4 } from "path";
3011
+ import { join as join5 } from "path";
2624
3012
  function git(cwd, args) {
2625
3013
  try {
2626
3014
  const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
@@ -2634,7 +3022,7 @@ function gitCommonDir(cwd) {
2634
3022
  if (!out)
2635
3023
  return null;
2636
3024
  try {
2637
- return realpathSync(out.startsWith("/") ? out : join4(cwd, out));
3025
+ return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
2638
3026
  } catch {
2639
3027
  return null;
2640
3028
  }
@@ -2789,6 +3177,16 @@ CREATE TABLE IF NOT EXISTS processes (
2789
3177
  cwd TEXT, cmd TEXT, owner TEXT, log TEXT, started_at TEXT, ended_at TEXT
2790
3178
  );
2791
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);
2792
3190
  CREATE TABLE IF NOT EXISTS claims (
2793
3191
  project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
2794
3192
  acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
@@ -2809,15 +3207,15 @@ class Store {
2809
3207
  gen = 0;
2810
3208
  memo = new Map;
2811
3209
  constructor(home = swarmHome()) {
2812
- mkdirSync2(home, { recursive: true });
3210
+ mkdirSync3(home, { recursive: true });
2813
3211
  this.home = home;
2814
- this.db = new Database(join5(home, "swarm.db"));
3212
+ this.db = new Database(join6(home, "swarm.db"));
2815
3213
  this.loadPricing();
2816
3214
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
2817
3215
  this.db.exec(SCHEMA);
2818
3216
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
2819
3217
  this.ensureColumn("projects", "sort_order", "INTEGER");
2820
- this.migrateProjectsJson(join5(home, "projects.json"));
3218
+ this.migrateProjectsJson(join6(home, "projects.json"));
2821
3219
  this.reconcileMovedProjects();
2822
3220
  this.slimExistingEvents();
2823
3221
  this.retypeNotificationIncidents();
@@ -2919,17 +3317,186 @@ class Store {
2919
3317
  return v;
2920
3318
  }
2921
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
+ }
2922
3489
  taskCache = new Map;
2923
3490
  tasks(projectId) {
2924
3491
  const p = this.project(projectId);
2925
3492
  if (!p)
2926
3493
  return null;
2927
- const source = loadConfig({ repoRoot: p.root }).tasks.source;
3494
+ const source = loadConfig({ repoRoot: p.root, home: this.home }).tasks.source;
2928
3495
  if (!source)
2929
3496
  return null;
2930
- const path = join5(p.root, source);
3497
+ const path = join6(p.root, source);
2931
3498
  if (!existsSync4(path))
2932
- return { source, tasks: [] };
3499
+ return { source, required: this.requiredGates(projectId), tasks: [] };
2933
3500
  const mtime = statSync(path).mtimeMs;
2934
3501
  let hit = this.taskCache.get(projectId);
2935
3502
  if (!hit || hit.path !== path || hit.mtime !== mtime) {
@@ -2938,17 +3505,80 @@ class Store {
2938
3505
  }
2939
3506
  const now = Date.now();
2940
3507
  const active = this.claimRows(projectId).filter((c) => isActive(c, now));
2941
- 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 };
2942
3527
  }
2943
3528
  rulesFor(repoRoot) {
2944
3529
  const key = repoRoot ?? "";
2945
3530
  const hit = this.rulesCache.get(key);
2946
3531
  if (hit && Date.now() - hit.at < 30000)
2947
3532
  return hit.rules;
2948
- const rules2 = loadConfig({ repoRoot }).rules;
3533
+ const rules2 = loadConfig({ repoRoot, home: this.home }).rules;
2949
3534
  this.rulesCache.set(key, { at: Date.now(), rules: rules2 });
2950
3535
  return rules2;
2951
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
+ }
2952
3582
  guardHook(raw2) {
2953
3583
  const tool = typeof raw2.tool_name === "string" ? raw2.tool_name : "";
2954
3584
  const input = raw2.tool_input ?? {};
@@ -3005,7 +3635,7 @@ class Store {
3005
3635
  loadPricing() {
3006
3636
  this.prices = { ...PRICES };
3007
3637
  for (const f of ["pricing.litellm.json", "pricing.json"]) {
3008
- const p = join5(this.home, f);
3638
+ const p = join6(this.home, f);
3009
3639
  if (!existsSync4(p))
3010
3640
  continue;
3011
3641
  try {
@@ -3021,7 +3651,7 @@ class Store {
3021
3651
  throw new Error(`pricing fetch ${r.status}`);
3022
3652
  const j = await r.json();
3023
3653
  const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
3024
- 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));
3025
3655
  this.loadPricing();
3026
3656
  this.reprice();
3027
3657
  }
@@ -3160,6 +3790,8 @@ class Store {
3160
3790
  return n;
3161
3791
  }
3162
3792
  ingestHook(event, raw2) {
3793
+ if (typeof raw2.cwd === "string")
3794
+ this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
3163
3795
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
3164
3796
  const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
3165
3797
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
@@ -3173,8 +3805,24 @@ class Store {
3173
3805
  }
3174
3806
  return e;
3175
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
+ ]);
3176
3824
  projectSession(e) {
3177
- if (!e.sessionId)
3825
+ if (!e.sessionId || Store.LEDGER_EVENTS.has(e.type))
3178
3826
  return;
3179
3827
  const p = e.payload;
3180
3828
  const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
@@ -3198,7 +3846,7 @@ class Store {
3198
3846
  const size = statSync(path).size;
3199
3847
  if (size <= offset)
3200
3848
  return null;
3201
- const fd = openSync(path, "r");
3849
+ const fd = openSync2(path, "r");
3202
3850
  const buf = Buffer.alloc(size - offset);
3203
3851
  readSync(fd, buf, 0, buf.length, offset);
3204
3852
  closeSync(fd);
@@ -3239,6 +3887,10 @@ class Store {
3239
3887
  const lastText = [...d.turns].reverse().find((t) => t.text && !t.sidechain)?.text ?? null;
3240
3888
  const lastModel = [...d.turns].reverse().find((t) => !t.sidechain)?.model ?? null;
3241
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
+ }
3242
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);
3243
3895
  return d.turns.length;
3244
3896
  }
@@ -3247,9 +3899,9 @@ class Store {
3247
3899
  if (!s?.transcript_path || !existsSync4(s.transcript_path))
3248
3900
  return 0;
3249
3901
  let n = this.tailFile(s.transcript_path, sessionId, null);
3250
- 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");
3251
3903
  for (const f of this.subagentFiles(subDir)) {
3252
- 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, ""));
3253
3905
  }
3254
3906
  return n;
3255
3907
  }
@@ -3281,7 +3933,7 @@ class Store {
3281
3933
  return n;
3282
3934
  }
3283
3935
  codexRoot() {
3284
- return process.env.SWARM_CODEX_DIR ?? join5(homedir3(), ".codex", "sessions");
3936
+ return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
3285
3937
  }
3286
3938
  codexRolloutFiles(sinceMs) {
3287
3939
  const root = this.codexRoot();
@@ -3296,18 +3948,18 @@ class Store {
3296
3948
  for (const y of ls(root)) {
3297
3949
  if (!/^\d{4}$/.test(y))
3298
3950
  continue;
3299
- for (const m of ls(join5(root, y))) {
3951
+ for (const m of ls(join6(root, y))) {
3300
3952
  if (!/^\d\d$/.test(m))
3301
3953
  continue;
3302
- for (const day of ls(join5(root, y, m))) {
3954
+ for (const day of ls(join6(root, y, m))) {
3303
3955
  if (!/^\d\d$/.test(day))
3304
3956
  continue;
3305
3957
  if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
3306
3958
  continue;
3307
- const dir = join5(root, y, m, day);
3959
+ const dir = join6(root, y, m, day);
3308
3960
  for (const f of ls(dir)) {
3309
3961
  if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
3310
- out.push(join5(dir, f));
3962
+ out.push(join6(dir, f));
3311
3963
  }
3312
3964
  }
3313
3965
  }
@@ -3324,7 +3976,7 @@ class Store {
3324
3976
  return n;
3325
3977
  }
3326
3978
  grokRoot() {
3327
- return process.env.SWARM_GROK_DIR ?? join5(homedir3(), ".grok", "sessions");
3979
+ return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
3328
3980
  }
3329
3981
  grokSummary = new Map;
3330
3982
  tailGrok(windowMs = 3 * 24 * 60 * 60000) {
@@ -3349,9 +4001,9 @@ class Store {
3349
4001
  } catch {
3350
4002
  cwd = enc;
3351
4003
  }
3352
- const cwdDir = join5(root, enc);
4004
+ const cwdDir = join6(root, enc);
3353
4005
  for (const sid of ls(cwdDir)) {
3354
- const path = join5(cwdDir, sid, "updates.jsonl");
4006
+ const path = join6(cwdDir, sid, "updates.jsonl");
3355
4007
  if (!existsSync4(path))
3356
4008
  continue;
3357
4009
  try {
@@ -3360,7 +4012,7 @@ class Store {
3360
4012
  } catch {
3361
4013
  continue;
3362
4014
  }
3363
- const sumPath = join5(cwdDir, sid, "summary.json");
4015
+ const sumPath = join6(cwdDir, sid, "summary.json");
3364
4016
  let title;
3365
4017
  let fresh = false;
3366
4018
  try {
@@ -3447,7 +4099,7 @@ class Store {
3447
4099
  worktreePath(projectId, task) {
3448
4100
  const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
3449
4101
  const p = this.project(projectId);
3450
- return join5(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
4102
+ return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
3451
4103
  }
3452
4104
  claim(projectId, task, owner, baseRef = "HEAD") {
3453
4105
  const p = this.project(projectId);
@@ -3461,7 +4113,7 @@ class Store {
3461
4113
  const worktree = this.worktreePath(projectId, task);
3462
4114
  if (existsSync4(worktree))
3463
4115
  return { ok: false, error: `${worktree} already exists; release ${task} first` };
3464
- mkdirSync2(dirname(worktree), { recursive: true });
4116
+ mkdirSync3(dirname(worktree), { recursive: true });
3465
4117
  const created = worktreeAdd(p.root, worktree, branch, baseRef);
3466
4118
  if (!created)
3467
4119
  return { ok: false, error: `git worktree add failed for ${task}` };
@@ -3481,6 +4133,82 @@ class Store {
3481
4133
  });
3482
4134
  return { ok: true, task, owner, worktree: created, branch, expiresAt };
3483
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
+ }
3484
4212
  renew(projectId, task) {
3485
4213
  const row = this.db.query("SELECT state FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
3486
4214
  if (!row)
@@ -4190,13 +4918,13 @@ function rowToEvent(r) {
4190
4918
  }
4191
4919
 
4192
4920
  // packages/daemon/src/app.ts
4193
- var VERSION = "0.4.1";
4921
+ var VERSION = "0.5.0";
4194
4922
  var WEB_DIR = (() => {
4195
4923
  if (process.env.SWARM_WEB_DIR)
4196
4924
  return process.env.SWARM_WEB_DIR;
4197
4925
  const here = dirname2(fileURLToPath(import.meta.url));
4198
- const dev = join6(here, "../../web/public");
4199
- 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");
4200
4928
  })();
4201
4929
  var REPLAY_TAIL = 200;
4202
4930
  var wireCache = new WeakMap;
@@ -4211,6 +4939,7 @@ function wireJson(e) {
4211
4939
  function createApp(store = new Store) {
4212
4940
  const app = new Hono2;
4213
4941
  const forge2 = new ForgeService(store);
4942
+ const runner = new Runner(store, store.home);
4214
4943
  app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
4215
4944
  app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
4216
4945
  app.post("/v1/projects", async (c) => {
@@ -4244,7 +4973,7 @@ function createApp(store = new Store) {
4244
4973
  dir = homedir4();
4245
4974
  }
4246
4975
  try {
4247
- 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));
4248
4977
  const parent = dirname2(dir);
4249
4978
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
4250
4979
  } catch (e) {
@@ -4318,6 +5047,92 @@ function createApp(store = new Store) {
4318
5047
  const r = await store.stopProcess(pid, c.req.query("project") || null);
4319
5048
  return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
4320
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
+ });
4321
5136
  app.get("/v1/tasks", (c) => {
4322
5137
  const project = c.req.query("project");
4323
5138
  if (!project)
@@ -4376,6 +5191,14 @@ function createApp(store = new Store) {
4376
5191
  const event = c.req.param("event");
4377
5192
  const raw2 = await c.req.json().catch(() => ({}));
4378
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
+ }
4379
5202
  if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
4380
5203
  const guard = store.guardHook(raw2);
4381
5204
  if (guard) {
@@ -4416,23 +5239,23 @@ function createApp(store = new Store) {
4416
5239
  });
4417
5240
  });
4418
5241
  });
4419
- 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")));
4420
5243
  const MIME = { js: "text/javascript", css: "text/css" };
4421
5244
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
4422
5245
  const f = c.req.param("file");
4423
- const p = join6(WEB_DIR, f);
5246
+ const p = join7(WEB_DIR, f);
4424
5247
  if (!existsSync5(p))
4425
5248
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
4426
5249
  return c.body(readFileSync4(p, "utf8"), 200, {
4427
5250
  "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
4428
5251
  });
4429
5252
  });
4430
- return { app, store, forge: forge2 };
5253
+ return { app, store, forge: forge2, runner };
4431
5254
  }
4432
5255
 
4433
5256
  // packages/daemon/src/bin.ts
4434
5257
  var DEFAULT_PORT2 = process.env.SWARM_PORT ? DEFAULT_PORT : loadConfig().daemon.port;
4435
- var { app, store } = createApp();
5258
+ var { app, store, runner } = createApp();
4436
5259
  function serve() {
4437
5260
  const bind = (p) => Bun.serve({ port: p, hostname: "127.0.0.1", idleTimeout: 0, fetch: app.fetch });
4438
5261
  try {
@@ -4461,6 +5284,8 @@ var tailer = setInterval(() => {
4461
5284
  }
4462
5285
  store.reapResources();
4463
5286
  store.reapProcesses();
5287
+ if (tick % 12 === 0)
5288
+ store.sweepOrphans();
4464
5289
  }, 5000);
4465
5290
  store.refreshAllWorktrees();
4466
5291
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);
@@ -4469,11 +5294,16 @@ var pruner = setInterval(() => store.prune(), 24 * 60 * 60000);
4469
5294
  if (process.env.SWARM_OFFLINE !== "1")
4470
5295
  store.refreshPricing().catch(() => {});
4471
5296
  console.log(`swarmd ${VERSION} listening on http://127.0.0.1:${port}`);
4472
- function shutdown() {
5297
+ var stopping = false;
5298
+ async function shutdown() {
5299
+ if (stopping)
5300
+ return;
5301
+ stopping = true;
4473
5302
  clearInterval(tailer);
4474
5303
  clearInterval(wtRefresh);
4475
5304
  clearInterval(pruner);
4476
5305
  clearDaemonInfo();
5306
+ await Promise.race([runner.stopAll(), Bun.sleep(6000)]);
4477
5307
  server.stop(true);
4478
5308
  process.exit(0);
4479
5309
  }