@ra3orblade/swarm 0.4.1 → 0.6.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
@@ -327,7 +327,8 @@ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
327
327
  import { join as join2 } from "path";
328
328
  var DEFAULT_CONFIG = {
329
329
  daemon: { port: 7777 },
330
- tasks: { source: null },
330
+ tasks: { source: null, labels: [], team: null },
331
+ gates: { required: [] },
331
332
  rules: {
332
333
  shared_tree: "ask",
333
334
  destructive_git: "ask",
@@ -366,7 +367,9 @@ function validate(c) {
366
367
  ...c,
367
368
  daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
368
369
  tasks: {
369
- source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null
370
+ source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null,
371
+ labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
372
+ team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
370
373
  },
371
374
  rules: {
372
375
  ...c.rules,
@@ -395,6 +398,250 @@ function loadConfig(opts = {}) {
395
398
  }
396
399
  return validate(cfg);
397
400
  }
401
+ // packages/core/src/rules.ts
402
+ var LIVE_WINDOW_MS = 10 * 60000;
403
+ function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
404
+ if (!current.toplevel)
405
+ return null;
406
+ for (const s of sessions) {
407
+ if (s.id === current.id)
408
+ continue;
409
+ if (s.state === "ended")
410
+ continue;
411
+ if (s.toplevel !== current.toplevel)
412
+ continue;
413
+ if (now - new Date(s.lastSeenAt).getTime() > withinMs)
414
+ continue;
415
+ return s;
416
+ }
417
+ return null;
418
+ }
419
+ function isBroadStage(cmd) {
420
+ const c = cmd.trim();
421
+ if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
422
+ return true;
423
+ if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
424
+ return true;
425
+ if (/\bgit\s+add\s*$/.test(c))
426
+ return true;
427
+ return false;
428
+ }
429
+ function isDestructiveGit(cmd) {
430
+ const c = cmd.trim();
431
+ return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
432
+ }
433
+ function isPatternKill(cmd) {
434
+ return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
435
+ }
436
+ function killedPorts(cmd) {
437
+ const ports = new Set;
438
+ const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
439
+ if (!killy)
440
+ return [];
441
+ for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
442
+ ports.add(Number(m[1]));
443
+ for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
444
+ ports.add(Number(m[1]));
445
+ for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
446
+ ports.add(Number(m[1]));
447
+ return [...ports];
448
+ }
449
+ var DEFAULT_MODES = {
450
+ shared_tree: "ask",
451
+ destructive_git: "ask",
452
+ pattern_kill: "ask",
453
+ protected_ports: "ask",
454
+ no_foreign_worktree: "ask",
455
+ claim_required_to_write: "off",
456
+ protected: { ports: [] }
457
+ };
458
+ function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
459
+ const other = () => otherLiveInSameTree(current, sessions, now);
460
+ const hit = (rule, reason) => {
461
+ const mode = modes[rule];
462
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
463
+ };
464
+ if (modes.protected_ports !== "off" && modes.protected.ports.length) {
465
+ const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
466
+ if (target.length) {
467
+ const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
468
+ if (d.action !== "allow")
469
+ return d;
470
+ }
471
+ }
472
+ if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
473
+ const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
474
+ if (d.action !== "allow")
475
+ return d;
476
+ }
477
+ if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
478
+ const o = other();
479
+ if (o) {
480
+ const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
481
+ if (d.action !== "allow")
482
+ return d;
483
+ }
484
+ }
485
+ if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
486
+ const o = other();
487
+ if (o) {
488
+ const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
489
+ if (d.action !== "allow")
490
+ return d;
491
+ }
492
+ }
493
+ return { action: "allow" };
494
+ }
495
+ function norm(p) {
496
+ const parts = [];
497
+ for (const seg of p.split("/")) {
498
+ if (seg === "" || seg === ".")
499
+ continue;
500
+ if (seg === "..")
501
+ parts.pop();
502
+ else
503
+ parts.push(seg);
504
+ }
505
+ return `/${parts.join("/")}`;
506
+ }
507
+ function isInside(path, dir) {
508
+ if (!path || !dir)
509
+ return false;
510
+ const a = norm(path);
511
+ const d = norm(dir);
512
+ return a === d || a.startsWith(`${d}/`);
513
+ }
514
+ function absolutePath(path, cwd) {
515
+ if (path.startsWith("/"))
516
+ return path;
517
+ if (path.startsWith("~/"))
518
+ return path;
519
+ return `${cwd.replace(/\/+$/, "")}/${path}`;
520
+ }
521
+ var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
522
+ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
523
+ const hit = (rule, reason) => {
524
+ const mode = modes[rule];
525
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
526
+ };
527
+ const held = claims.filter((c) => c.worktree);
528
+ const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
529
+ if (modes.no_foreign_worktree !== "off") {
530
+ const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
531
+ if (foreign) {
532
+ const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
533
+ if (d.action !== "allow")
534
+ return d;
535
+ }
536
+ }
537
+ if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
538
+ const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
539
+ if (inShared) {
540
+ const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
541
+ if (d.action !== "allow")
542
+ return d;
543
+ }
544
+ }
545
+ return { action: "allow" };
546
+ }
547
+
548
+ // packages/core/src/dryrun.ts
549
+ var RULE_IDS = [
550
+ "pattern_kill",
551
+ "shared_tree",
552
+ "destructive_git",
553
+ "protected_ports",
554
+ "no_foreign_worktree",
555
+ "claim_required_to_write"
556
+ ];
557
+ function normalizeDisplay(s) {
558
+ return s.replace(/\s+/g, " ").trim().slice(0, 160);
559
+ }
560
+ function dryRunRules(calls, modes, ctx) {
561
+ const claims = ctx.claims ?? [];
562
+ const minRepeat = ctx.minRepeat ?? 3;
563
+ const maxHits = ctx.maxHits ?? 200;
564
+ const live = new Map;
565
+ const byRule = Object.fromEntries(RULE_IDS.map((r) => [r, { ask: 0, deny: 0 }]));
566
+ const hits = [];
567
+ const groups = new Map;
568
+ let evaluated = 0;
569
+ const sorted = [...calls].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0);
570
+ const writeRules = modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off";
571
+ for (const c of sorted) {
572
+ const toplevel = ctx.toplevel(c.cwd);
573
+ live.set(c.sessionId, { id: c.sessionId, toplevel, lastSeenAt: c.ts, state: "active" });
574
+ const now = new Date(c.ts).getTime();
575
+ const current = { id: c.sessionId, cwd: c.cwd, toplevel };
576
+ let d = { action: "allow" };
577
+ let display = c.tool;
578
+ const isWrite = WRITE_TOOLS.has(c.tool) && typeof c.filePath === "string";
579
+ if (isWrite) {
580
+ const target = absolutePath(c.filePath, c.cwd);
581
+ display = `${c.tool} ${target}`;
582
+ evaluated++;
583
+ if (writeRules)
584
+ d = guardWrite(target, current, claims, modes, "file");
585
+ } else if (c.tool === "Bash" && c.command) {
586
+ display = c.command;
587
+ evaluated++;
588
+ if (writeRules)
589
+ d = guardWrite(c.cwd, current, claims, modes, "bash");
590
+ if (d.action === "allow") {
591
+ const sessions = [...live.values()].filter((s) => now - new Date(s.lastSeenAt).getTime() <= LIVE_WINDOW_MS);
592
+ d = guardBash(c.command, current, sessions, now, modes);
593
+ }
594
+ } else
595
+ continue;
596
+ if (d.action === "allow")
597
+ continue;
598
+ byRule[d.rule][d.action]++;
599
+ const norm2 = normalizeDisplay(display);
600
+ if (hits.length < maxHits)
601
+ hits.push({
602
+ ts: c.ts,
603
+ sessionId: c.sessionId,
604
+ rule: d.rule,
605
+ action: d.action,
606
+ display: norm2,
607
+ completed: c.completed
608
+ });
609
+ const key = `${d.rule} ${norm2}`;
610
+ const g = groups.get(key) ?? {
611
+ rule: d.rule,
612
+ display: norm2,
613
+ fires: 0,
614
+ completedRatio: 0,
615
+ sessions: 0,
616
+ suggestion: "",
617
+ done: 0,
618
+ sids: new Set
619
+ };
620
+ g.fires++;
621
+ if (c.completed)
622
+ g.done++;
623
+ g.sids.add(c.sessionId);
624
+ groups.set(key, g);
625
+ }
626
+ const flaky = [];
627
+ for (const g of groups.values()) {
628
+ if (g.fires < minRepeat)
629
+ continue;
630
+ const ratio = g.done / g.fires;
631
+ if (ratio < 0.8)
632
+ continue;
633
+ flaky.push({
634
+ rule: g.rule,
635
+ display: g.display,
636
+ fires: g.fires,
637
+ completedRatio: Math.round(ratio * 100) / 100,
638
+ sessions: g.sids.size,
639
+ suggestion: modes[g.rule] === "deny" ? `${g.rule} denies this but it ran ${g.done}/${g.fires} times anyway \u2014 the rule is being bypassed; check the hook is installed, or turn it off here.` : `${g.rule} asked ${g.fires} times on this and it was allowed ${g.done} times \u2014 pure friction here. Turn it off for this repo, or make it deny so it stops asking.`
640
+ });
641
+ }
642
+ flaky.sort((a, b) => b.fires - a.fires);
643
+ return { calls: calls.length, evaluated, hits, byRule, flaky };
644
+ }
398
645
  // packages/core/src/forge.ts
399
646
  function parseRemote(url) {
400
647
  const m = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/) ?? url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
@@ -459,6 +706,49 @@ function normalizeGitlab(raw, repo) {
459
706
  };
460
707
  });
461
708
  }
709
+ // packages/core/src/gates.ts
710
+ var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
711
+ function validateGateRun(input) {
712
+ if (!input.task?.trim())
713
+ return { ok: false, reason: "task is required" };
714
+ if (!NAME_RE.test(input.gate ?? ""))
715
+ return { ok: false, reason: "gate must be a short name (letters, digits, _ . -)" };
716
+ if (input.verdict !== "pass" && input.verdict !== "fail")
717
+ return { ok: false, reason: 'verdict must be "pass" or "fail"' };
718
+ const rubric = input.rubric?.trim() ?? "";
719
+ if (rubric.length < 8)
720
+ return {
721
+ ok: false,
722
+ reason: 'rubric is required: say what was checked (e.g. "tests green, no TODOs, reviewed error paths"). A verdict without a rubric is rejected.'
723
+ };
724
+ return { ok: true };
725
+ }
726
+ function gateStatus(runs, declared = []) {
727
+ const byGate = new Map;
728
+ for (const r of runs) {
729
+ const list = byGate.get(r.gate) ?? [];
730
+ list.push(r);
731
+ byGate.set(r.gate, list);
732
+ }
733
+ const names = [...new Set([...declared, ...byGate.keys()])];
734
+ return names.map((gate) => {
735
+ const list = (byGate.get(gate) ?? []).sort((a, b) => a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1);
736
+ const latest = list[0] ?? null;
737
+ return {
738
+ gate,
739
+ verdict: latest?.verdict ?? null,
740
+ latest,
741
+ runs: list.length,
742
+ fails: list.filter((r) => r.verdict === "fail").length
743
+ };
744
+ });
745
+ }
746
+ function gatesSatisfied(runs, declared) {
747
+ if (!declared.length)
748
+ return true;
749
+ const st = gateStatus(runs, declared);
750
+ return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
751
+ }
462
752
  // packages/core/src/ledger.ts
463
753
  var DEFAULT_LEASE_MINUTES = 45;
464
754
  function isExpired(claim, now) {
@@ -495,12 +785,274 @@ function reapAction(claim, now, worktreeExists, work) {
495
785
  return "keep-orphaned";
496
786
  return "reap";
497
787
  }
788
+ function shouldAutoRenew(claim, now, leaseMinutes = DEFAULT_LEASE_MINUTES) {
789
+ if (claim.state !== "held")
790
+ return false;
791
+ const left = new Date(claim.expiresAt).getTime() - now;
792
+ if (left <= 0)
793
+ return false;
794
+ return left < leaseMinutes * 60000 / 2;
795
+ }
498
796
  function claimRefusalMessage(d, task) {
499
797
  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
798
  }
501
799
  function releaseRefusalMessage(d, worktree) {
502
800
  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
801
  }
802
+ function validateHandoff(h) {
803
+ if (!h.task?.trim())
804
+ return { ok: false, reason: "task is required" };
805
+ if (!h.done?.trim() || !h.remaining?.trim())
806
+ return {
807
+ ok: false,
808
+ reason: "a handoff needs both `done` (what was finished) and `remaining` (what is left, in order)"
809
+ };
810
+ return { ok: true };
811
+ }
812
+ function formatHandoff(h) {
813
+ const lines = [
814
+ `[swarm] handoff on ${h.task}${h.by ? ` from ${h.by}` : ""} (${h.createdAt.slice(0, 16).replace("T", " ")}):`,
815
+ ` done: ${h.done.trim()}`,
816
+ ` remaining: ${h.remaining.trim()}`
817
+ ];
818
+ if (h.files.length)
819
+ lines.push(` files: ${h.files.join(", ")}`);
820
+ if (h.verify)
821
+ lines.push(` verify: ${h.verify.trim()}`);
822
+ return lines.join(`
823
+ `);
824
+ }
825
+ var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
826
+ var VERIFY_RE = /\b(test|tests|typecheck|tsc|lint|biome|eslint|check|build|smoke|pytest|cargo (test|check|build)|go (test|vet|build)|make)\b/;
827
+ function deriveHandoff(task, ev, opts = {}) {
828
+ const files = [];
829
+ let verify = null;
830
+ let lastPrompt = null;
831
+ for (const e of ev) {
832
+ const p = e.payload ?? {};
833
+ if (e.type === "tool.requested" && p.tool) {
834
+ const arg = (p.summary ?? "").slice(p.tool.length).trim();
835
+ if (EDIT_TOOLS.has(p.tool) && arg && !files.includes(arg))
836
+ files.push(arg);
837
+ if (p.tool === "Bash") {
838
+ if (arg && VERIFY_RE.test(arg))
839
+ verify = arg;
840
+ }
841
+ } else if (p.hook === "UserPromptSubmit" && (p.prompt ?? p.summary)) {
842
+ lastPrompt = (p.prompt ?? p.summary ?? "").trim().split(`
843
+ `)[0]?.slice(0, 200) ?? null;
844
+ }
845
+ }
846
+ const said = (opts.lastText ?? "").trim().replace(/\s+/g, " ").slice(0, 600);
847
+ if (!files.length && !said)
848
+ return null;
849
+ const done = said || `edited ${files.length} file${files.length === 1 ? "" : "s"} (no summary)`;
850
+ const remaining = lastPrompt ? `unverified \u2014 session stopped without a manual handoff; last request: "${lastPrompt}". Re-read the files below, run verify, then continue.` : "unverified \u2014 session stopped without a manual handoff. Re-read the files below, run verify, then continue.";
851
+ return {
852
+ task,
853
+ done,
854
+ remaining,
855
+ files: files.slice(-30),
856
+ verify,
857
+ by: `auto${opts.sessionId ? `:${opts.sessionId.slice(0, 8)}` : ""}`,
858
+ createdAt: opts.now ?? new Date().toISOString()
859
+ };
860
+ }
861
+ function isAutoHandoff(h) {
862
+ return h.by === "auto" || (h.by?.startsWith("auto:") ?? false);
863
+ }
864
+ function formatResumePrompt(h, tail) {
865
+ const out = [
866
+ `You are resuming ${h.task}; the previous session on it stopped without finishing.`,
867
+ "",
868
+ formatHandoff(h)
869
+ ];
870
+ if (tail.length)
871
+ out.push("", "Its last actions, oldest first:", ...tail.map((t) => ` - ${t}`));
872
+ out.push("", "Start by reading the files listed, run the verify step if there is one, then continue with `remaining`. Work only inside this worktree; when done commit, push, and call swarm_handoff.");
873
+ return out.join(`
874
+ `);
875
+ }
876
+ // packages/core/src/lessons.ts
877
+ function portsIn(cmd) {
878
+ const ports = new Set;
879
+ for (const m of cmd.matchAll(/(?::|-i\s*:?|kill-port\s+|fuser\s+-[a-z]*k\s+)(\d{2,5})\b/g))
880
+ ports.add(Number(m[1]));
881
+ return [...ports];
882
+ }
883
+ var RECURRING = 3;
884
+ function suggestFromIncident(inc) {
885
+ const n = inc.count ?? 1;
886
+ switch (inc.rule) {
887
+ case "protected_ports": {
888
+ const ports = portsIn(inc.command);
889
+ return {
890
+ title: ports.length ? `Protect port${ports.length > 1 ? "s" : ""} ${ports.join(", ")} for good` : "Protect this port",
891
+ toml: ports.length ? `[rules]
892
+ protected_ports = "deny"
893
+
894
+ [rules.protected]
895
+ ports = [${ports.join(", ")}]` : null,
896
+ lesson: `Never kill the process on port ${ports.join("/") || "the dev server's port"} \u2014 it's someone's running service. Ask them, or use \`swarm serve\` so the port is tracked.`
897
+ };
898
+ }
899
+ case "pattern_kill":
900
+ return {
901
+ title: n >= RECURRING ? "Deny pattern kills (recurring)" : "Discourage pattern kills",
902
+ toml: `[rules]
903
+ pattern_kill = "${n >= RECURRING ? "deny" : "ask"}"`,
904
+ lesson: "Kill processes by pid, never by command pattern (`pkill -f`) \u2014 pattern kills hit every matching process, including other agents' and the owner's."
905
+ };
906
+ case "shared_tree":
907
+ return {
908
+ title: "Deny broad staging in a shared checkout",
909
+ toml: `[rules]
910
+ shared_tree = "deny"`,
911
+ lesson: "Don't `git add -A` / `git commit -a` while another session shares the checkout \u2014 stage explicit paths, or work in your own worktree via `swarm claim`."
912
+ };
913
+ case "destructive_git":
914
+ return {
915
+ title: "Deny destructive git in a shared checkout",
916
+ toml: `[rules]
917
+ destructive_git = "deny"`,
918
+ lesson: "Never run `git reset --hard` / `checkout .` / `clean -f` in a checkout another session shares \u2014 coordinate, or use a separate worktree."
919
+ };
920
+ case "no_foreign_worktree":
921
+ return {
922
+ title: "Deny writes into others' worktrees",
923
+ toml: `[rules]
924
+ no_foreign_worktree = "deny"`,
925
+ lesson: "Never edit inside a worktree you don't hold \u2014 work in your own checkout, or claim the task first."
926
+ };
927
+ case "claim_required_to_write":
928
+ return {
929
+ title: "Require a claim before writing",
930
+ toml: `[rules]
931
+ claim_required_to_write = "deny"`,
932
+ lesson: "Claim a task (`swarm claim`) and work in the worktree it creates before editing this repo."
933
+ };
934
+ case "orphaned_claim":
935
+ return {
936
+ title: "A claim expired with unfinished work",
937
+ toml: null,
938
+ lesson: "Finish and push, or `swarm handoff`, before a lease expires \u2014 an orphaned worktree still holds work nobody owns."
939
+ };
940
+ case "gate_failed":
941
+ return {
942
+ title: "A verification gate failed",
943
+ toml: null,
944
+ lesson: `A gate failed here \u2014 ${inc.reason.slice(0, 120)}. Fix it and re-record the gate before marking the task done.`
945
+ };
946
+ default:
947
+ return {
948
+ title: `Codify the ${inc.rule} intent`,
949
+ toml: null,
950
+ lesson: inc.reason.slice(0, 160)
951
+ };
952
+ }
953
+ }
954
+ function incidentKey(inc) {
955
+ if (inc.rule === "protected_ports")
956
+ return `protected_ports:${portsIn(inc.command).join(",")}`;
957
+ return inc.rule;
958
+ }
959
+ // packages/core/src/memory.ts
960
+ var MEMORY_KINDS = ["handoff", "incident", "gate", "session"];
961
+ function handoffDoc(projectId, id, h, sessionId) {
962
+ return {
963
+ kind: "handoff",
964
+ ref: String(id),
965
+ projectId,
966
+ task: h.task,
967
+ sessionId,
968
+ ts: h.createdAt,
969
+ title: `handoff on ${h.task}${h.by ? ` by ${h.by}` : ""}`,
970
+ text: [
971
+ `done: ${h.done}`,
972
+ `remaining: ${h.remaining}`,
973
+ h.files.length ? `files: ${h.files.join(" ")}` : "",
974
+ h.verify ? `verify: ${h.verify}` : ""
975
+ ].filter(Boolean).join(`
976
+ `)
977
+ };
978
+ }
979
+ function incidentDoc(projectId, seq, p, ts, sessionId) {
980
+ return {
981
+ kind: "incident",
982
+ ref: String(seq),
983
+ projectId,
984
+ task: null,
985
+ sessionId,
986
+ ts,
987
+ title: `${p.action ?? "ask"} \xB7 ${p.rule ?? "rule"}`,
988
+ text: [p.command ? `command: ${p.command}` : "", p.reason ? `reason: ${p.reason}` : ""].filter(Boolean).join(`
989
+ `)
990
+ };
991
+ }
992
+ function gateDoc(projectId, id, g, sessionId) {
993
+ return {
994
+ kind: "gate",
995
+ ref: String(id),
996
+ projectId,
997
+ task: g.task,
998
+ sessionId,
999
+ ts: g.createdAt,
1000
+ title: `${g.gate} ${g.verdict} on ${g.task}`,
1001
+ text: [`rubric: ${g.rubric}`, g.evidence ? `evidence: ${g.evidence}` : ""].filter(Boolean).join(`
1002
+ `)
1003
+ };
1004
+ }
1005
+ function sessionDoc(projectId, s) {
1006
+ const text = (s.lastText ?? "").trim();
1007
+ if (!text)
1008
+ return null;
1009
+ return {
1010
+ kind: "session",
1011
+ ref: s.id,
1012
+ projectId,
1013
+ task: s.task ?? null,
1014
+ sessionId: s.id,
1015
+ ts: s.ts,
1016
+ title: s.title?.trim() || `session ${s.id.slice(0, 8)}`,
1017
+ text: text.slice(0, 4000)
1018
+ };
1019
+ }
1020
+ function parseMemoryQuery(q) {
1021
+ let kind = null;
1022
+ let task = null;
1023
+ const terms = [];
1024
+ const re = /"([^"]+)"|(\S+)/g;
1025
+ for (const m of q.matchAll(re)) {
1026
+ if (m[1] !== undefined) {
1027
+ const phrase = m[1].replace(/"/g, "").trim();
1028
+ if (phrase)
1029
+ terms.push(`"${phrase}"`);
1030
+ continue;
1031
+ }
1032
+ const w = m[2] ?? "";
1033
+ const k = /^kind:(\w+)$/i.exec(w);
1034
+ if (k) {
1035
+ const v = (k[1] ?? "").toLowerCase();
1036
+ if (MEMORY_KINDS.includes(v))
1037
+ kind = v;
1038
+ continue;
1039
+ }
1040
+ const t = /^task:(\S+)$/i.exec(w);
1041
+ if (t) {
1042
+ task = t[1] ?? null;
1043
+ continue;
1044
+ }
1045
+ const clean = w.replace(/"/g, "").replace(/^\*+|\*+$/g, "");
1046
+ if (clean)
1047
+ terms.push(`"${clean}"`);
1048
+ }
1049
+ if (terms.length) {
1050
+ const last = terms[terms.length - 1];
1051
+ if (!/\s/.test(last) && !q.trim().endsWith('"'))
1052
+ terms[terms.length - 1] = `${last}*`;
1053
+ }
1054
+ return { match: terms.join(" "), kind, task };
1055
+ }
504
1056
  // packages/core/src/pricing.ts
505
1057
  var PRICES = {
506
1058
  "claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
@@ -624,152 +1176,6 @@ function acquireRefusalMessage(holder) {
624
1176
  const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
625
1177
  return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
626
1178
  }
627
- // packages/core/src/rules.ts
628
- var LIVE_WINDOW_MS = 10 * 60000;
629
- function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
630
- if (!current.toplevel)
631
- return null;
632
- for (const s of sessions) {
633
- if (s.id === current.id)
634
- continue;
635
- if (s.state === "ended")
636
- continue;
637
- if (s.toplevel !== current.toplevel)
638
- continue;
639
- if (now - new Date(s.lastSeenAt).getTime() > withinMs)
640
- continue;
641
- return s;
642
- }
643
- return null;
644
- }
645
- function isBroadStage(cmd) {
646
- const c = cmd.trim();
647
- if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
648
- return true;
649
- if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
650
- return true;
651
- if (/\bgit\s+add\s*$/.test(c))
652
- return true;
653
- return false;
654
- }
655
- function isDestructiveGit(cmd) {
656
- const c = cmd.trim();
657
- return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
658
- }
659
- function isPatternKill(cmd) {
660
- return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
661
- }
662
- function killedPorts(cmd) {
663
- const ports = new Set;
664
- const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
665
- if (!killy)
666
- return [];
667
- for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
668
- ports.add(Number(m[1]));
669
- for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
670
- ports.add(Number(m[1]));
671
- for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
672
- ports.add(Number(m[1]));
673
- return [...ports];
674
- }
675
- var DEFAULT_MODES = {
676
- shared_tree: "ask",
677
- destructive_git: "ask",
678
- pattern_kill: "ask",
679
- protected_ports: "ask",
680
- no_foreign_worktree: "ask",
681
- claim_required_to_write: "off",
682
- protected: { ports: [] }
683
- };
684
- function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
685
- const other = () => otherLiveInSameTree(current, sessions, now);
686
- const hit = (rule, reason) => {
687
- const mode = modes[rule];
688
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
689
- };
690
- if (modes.protected_ports !== "off" && modes.protected.ports.length) {
691
- const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
692
- if (target.length) {
693
- const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
694
- if (d.action !== "allow")
695
- return d;
696
- }
697
- }
698
- if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
699
- const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
700
- if (d.action !== "allow")
701
- return d;
702
- }
703
- if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
704
- const o = other();
705
- if (o) {
706
- const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
707
- if (d.action !== "allow")
708
- return d;
709
- }
710
- }
711
- if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
712
- const o = other();
713
- if (o) {
714
- const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
715
- if (d.action !== "allow")
716
- return d;
717
- }
718
- }
719
- return { action: "allow" };
720
- }
721
- function norm(p) {
722
- const parts = [];
723
- for (const seg of p.split("/")) {
724
- if (seg === "" || seg === ".")
725
- continue;
726
- if (seg === "..")
727
- parts.pop();
728
- else
729
- parts.push(seg);
730
- }
731
- return `/${parts.join("/")}`;
732
- }
733
- function isInside(path, dir) {
734
- if (!path || !dir)
735
- return false;
736
- const a = norm(path);
737
- const d = norm(dir);
738
- return a === d || a.startsWith(`${d}/`);
739
- }
740
- function absolutePath(path, cwd) {
741
- if (path.startsWith("/"))
742
- return path;
743
- if (path.startsWith("~/"))
744
- return path;
745
- return `${cwd.replace(/\/+$/, "")}/${path}`;
746
- }
747
- var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
748
- function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
749
- const hit = (rule, reason) => {
750
- const mode = modes[rule];
751
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
752
- };
753
- const held = claims.filter((c) => c.worktree);
754
- const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
755
- if (modes.no_foreign_worktree !== "off") {
756
- const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
757
- if (foreign) {
758
- const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
759
- if (d.action !== "allow")
760
- return d;
761
- }
762
- }
763
- if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
764
- const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
765
- if (inShared) {
766
- const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
767
- if (d.action !== "allow")
768
- return d;
769
- }
770
- }
771
- return { action: "allow" };
772
- }
773
1179
  // packages/core/src/tasks.ts
774
1180
  var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
775
1181
  var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
@@ -870,10 +1276,69 @@ function taskBoard(tasks, activeClaims) {
870
1276
  };
871
1277
  });
872
1278
  }
1279
+ var TASK_SOURCE_KINDS = ["github", "linear"];
1280
+ function taskSourceKind(source) {
1281
+ if (!source)
1282
+ return null;
1283
+ return TASK_SOURCE_KINDS.includes(source) ? source : "markdown";
1284
+ }
1285
+ var ACTIVE_LABEL_RE = /^(in[- ]progress|wip|doing|active|started)$/i;
1286
+ var GH_DEP_RE = /\b(?:depends on|blocked by|after|requires)\b[^\n.]*?((?:#\d+[,\s]*(?:and)?\s*)+)/gi;
1287
+ function normalizeGithubIssues(issues) {
1288
+ return issues.filter((i) => Number.isInteger(i.number) && typeof i.title === "string").sort((a, b) => a.number - b.number).map((i) => {
1289
+ const labels = (i.labels ?? []).map((l) => l.name);
1290
+ const closed = (i.state ?? "").toUpperCase() === "CLOSED";
1291
+ const active = !closed && labels.some((l) => ACTIVE_LABEL_RE.test(l));
1292
+ const depends = [];
1293
+ for (const m of (i.body ?? "").matchAll(GH_DEP_RE))
1294
+ for (const n of (m[1] ?? "").matchAll(/#(\d+)/g)) {
1295
+ const id = `GH-${n[1]}`;
1296
+ if (!depends.includes(id))
1297
+ depends.push(id);
1298
+ }
1299
+ const statusText = closed ? "closed" : active ? `in progress${i.assignees?.length ? ` (${i.assignees.map((a) => a.login).join(", ")})` : ""}` : labels.length ? labels.join(", ") : "open";
1300
+ return {
1301
+ id: `GH-${i.number}`,
1302
+ title: i.title,
1303
+ depends,
1304
+ status: closed ? "done" : active ? "active" : "todo",
1305
+ statusText,
1306
+ milestone: i.milestone?.title ?? null
1307
+ };
1308
+ });
1309
+ }
1310
+ function normalizeLinearIssues(issues) {
1311
+ return issues.filter((i) => typeof i.identifier === "string" && typeof i.title === "string").map((i) => {
1312
+ const type = i.state?.type ?? "unstarted";
1313
+ const done = type === "completed" || type === "canceled";
1314
+ const active = type === "started";
1315
+ const depends = (i.inverseRelations?.nodes ?? []).filter((r) => r.type === "blocks").map((r) => r.issue.identifier);
1316
+ const statusText = `${i.state?.name ?? type}${active && i.assignee ? ` (${i.assignee.name})` : ""}`;
1317
+ return {
1318
+ id: i.identifier,
1319
+ title: i.title,
1320
+ depends: [...new Set(depends)],
1321
+ status: done ? "done" : active ? "active" : "todo",
1322
+ statusText,
1323
+ milestone: i.cycle?.name ?? (i.cycle ? `Cycle ${i.cycle.number}` : i.project?.name ?? null)
1324
+ };
1325
+ });
1326
+ }
1327
+ function linearIssuesQuery(teamKey, first = 200) {
1328
+ const filter = teamKey ? `, filter: { team: { key: { eq: "${teamKey.replace(/"/g, "")}" } } }` : "";
1329
+ return `{ issues(first: ${first}, orderBy: createdAt${filter}) { nodes {
1330
+ identifier title sortOrder
1331
+ state { name type }
1332
+ assignee { name }
1333
+ project { name }
1334
+ cycle { name number }
1335
+ inverseRelations { nodes { type issue { identifier } } }
1336
+ } } }`;
1337
+ }
873
1338
  // packages/daemon/src/app.ts
874
1339
  import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
875
1340
  import { homedir as homedir4 } from "os";
876
- import { dirname as dirname2, join as join6 } from "path";
1341
+ import { dirname as dirname2, join as join7 } from "path";
877
1342
  import { fileURLToPath } from "url";
878
1343
 
879
1344
  // node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
@@ -2507,7 +2972,7 @@ var EXTRA_BIN_DIRS = [
2507
2972
  function findBin(name) {
2508
2973
  if (!name)
2509
2974
  return null;
2510
- const onPath = Bun.which(name);
2975
+ const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
2511
2976
  if (onPath)
2512
2977
  return onPath;
2513
2978
  for (const d of EXTRA_BIN_DIRS) {
@@ -2578,25 +3043,338 @@ class ForgeService {
2578
3043
  if (out)
2579
3044
  prs = normalizeGitlab(JSON.parse(out), remote.repo);
2580
3045
  }
2581
- return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3046
+ return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3047
+ }
3048
+ async merge(projectId, number) {
3049
+ const p = this.store.projects().find((x) => x.id === projectId);
3050
+ if (!p)
3051
+ return { ok: false, output: "unknown project" };
3052
+ const remote = this.remote(p.root);
3053
+ if (!remote)
3054
+ return { ok: false, output: "no forge remote" };
3055
+ const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
3056
+ const bin = findBin(cmd[0]);
3057
+ if (!bin)
3058
+ return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
3059
+ const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
3060
+ const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
3061
+ const ok = await proc.exited === 0;
3062
+ if (ok)
3063
+ this.cache.delete(projectId);
3064
+ return { ok, output: out.trim().slice(0, 800) };
3065
+ }
3066
+ }
3067
+
3068
+ // packages/daemon/src/runner.ts
3069
+ import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
3070
+ import { join as join4 } from "path";
3071
+ var PERMISSION_MODES = [
3072
+ "acceptEdits",
3073
+ "auto",
3074
+ "bypassPermissions",
3075
+ "manual",
3076
+ "dontAsk",
3077
+ "plan"
3078
+ ];
3079
+
3080
+ class Runner {
3081
+ store;
3082
+ home;
3083
+ live = new Map;
3084
+ constructor(store, home) {
3085
+ this.store = store;
3086
+ this.home = home;
3087
+ }
3088
+ list(projectId) {
3089
+ return [...this.live.values()].map((x) => x.run).filter((r) => !projectId || r.projectId === projectId);
3090
+ }
3091
+ get(idOrTask) {
3092
+ for (const { run: run2 } of this.live.values())
3093
+ if (run2.id === idOrTask || run2.sessionId === idOrTask || run2.task === idOrTask)
3094
+ return run2;
3095
+ return null;
3096
+ }
3097
+ async start(input) {
3098
+ const bin = findBin("claude");
3099
+ if (!bin)
3100
+ return { ok: false, reason: "claude CLI not found \u2014 install Claude Code first" };
3101
+ const project = this.store.project(input.projectId);
3102
+ if (!project)
3103
+ return { ok: false, reason: "unknown project" };
3104
+ if (!input.prompt.trim())
3105
+ return { ok: false, reason: "prompt is required" };
3106
+ if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
3107
+ return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
3108
+ if (this.get(input.task)?.projectId === input.projectId)
3109
+ return {
3110
+ ok: false,
3111
+ reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
3112
+ };
3113
+ const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
3114
+ let worktree = held?.worktree ?? "";
3115
+ if (!worktree) {
3116
+ const c = this.store.claim(input.projectId, input.task, input.owner);
3117
+ if (!c.ok)
3118
+ return { ok: false, reason: c.error };
3119
+ worktree = c.worktree;
3120
+ }
3121
+ const sessionId = crypto.randomUUID();
3122
+ const id = sessionId.slice(0, 8);
3123
+ const logDir = join4(this.home, "logs", project.id);
3124
+ mkdirSync2(logDir, { recursive: true });
3125
+ const log = join4(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
3126
+ const logFd = openSync(log, "a");
3127
+ const args = [
3128
+ bin,
3129
+ "-p",
3130
+ "--output-format",
3131
+ "stream-json",
3132
+ "--input-format",
3133
+ "stream-json",
3134
+ "--verbose",
3135
+ "--session-id",
3136
+ sessionId,
3137
+ "--permission-prompt-tool",
3138
+ "stdio"
3139
+ ];
3140
+ if (input.model)
3141
+ args.push("--model", input.model);
3142
+ if (input.permissionMode)
3143
+ args.push("--permission-mode", input.permissionMode);
3144
+ if (input.allowedTools?.length)
3145
+ args.push("--allowedTools", ...input.allowedTools);
3146
+ if (input.maxTurns)
3147
+ args.push("--max-turns", String(input.maxTurns));
3148
+ this.store.preregisterSpawnedSession(sessionId, project.id, worktree, input.task);
3149
+ const proc = Bun.spawn(args, {
3150
+ cwd: worktree,
3151
+ env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
3152
+ stdin: "pipe",
3153
+ stdout: "pipe",
3154
+ stderr: logFd
3155
+ });
3156
+ const run2 = {
3157
+ id,
3158
+ sessionId,
3159
+ projectId: project.id,
3160
+ task: input.task,
3161
+ worktree,
3162
+ pid: proc.pid,
3163
+ owner: input.owner,
3164
+ model: input.model ?? null,
3165
+ permissionMode: input.permissionMode ?? null,
3166
+ prompt: input.prompt,
3167
+ log,
3168
+ startedAt: new Date().toISOString(),
3169
+ endedAt: null,
3170
+ exitCode: null,
3171
+ result: null,
3172
+ pending: []
3173
+ };
3174
+ this.live.set(id, { run: run2, proc });
3175
+ this.store.registerProcess({
3176
+ pid: proc.pid,
3177
+ projectId: project.id,
3178
+ sessionId,
3179
+ kind: "proc",
3180
+ name: `run:${input.task}`,
3181
+ cwd: worktree,
3182
+ cmd: `claude -p (run ${id})`,
3183
+ owner: input.owner,
3184
+ log
3185
+ });
3186
+ this.store.append({
3187
+ ts: run2.startedAt,
3188
+ type: "session.started",
3189
+ projectId: project.id,
3190
+ sessionId,
3191
+ payload: { kind: "spawned", task: input.task, runId: id, summary: `swarm run ${input.task}` }
3192
+ });
3193
+ this.pump(id, proc);
3194
+ this.send(id, input.prompt);
3195
+ return { ok: true, run: run2 };
3196
+ }
3197
+ async pump(id, proc) {
3198
+ const entry = this.live.get(id);
3199
+ if (!entry || !proc.stdout || typeof proc.stdout === "number")
3200
+ return;
3201
+ const reader = proc.stdout.getReader();
3202
+ const dec = new TextDecoder;
3203
+ let buf = "";
3204
+ try {
3205
+ while (true) {
3206
+ const { value, done } = await reader.read();
3207
+ if (done)
3208
+ break;
3209
+ buf += dec.decode(value, { stream: true });
3210
+ let nl = buf.indexOf(`
3211
+ `);
3212
+ while (nl >= 0) {
3213
+ const line = buf.slice(0, nl);
3214
+ buf = buf.slice(nl + 1);
3215
+ try {
3216
+ appendFileSync(entry.run.log, `${line}
3217
+ `);
3218
+ } catch {}
3219
+ this.onLine(entry.run, line);
3220
+ nl = buf.indexOf(`
3221
+ `);
3222
+ }
3223
+ }
3224
+ } catch (e) {
3225
+ console.error("swarm run: stdout pump failed:", e.message);
3226
+ }
3227
+ const code = await proc.exited;
3228
+ entry.run.endedAt = new Date().toISOString();
3229
+ entry.run.exitCode = code;
3230
+ this.store.append({
3231
+ ts: entry.run.endedAt,
3232
+ type: "run.result",
3233
+ projectId: entry.run.projectId,
3234
+ sessionId: entry.run.sessionId,
3235
+ payload: {
3236
+ runId: id,
3237
+ task: entry.run.task,
3238
+ exitCode: code,
3239
+ final: true,
3240
+ ...entry.run.result ?? {},
3241
+ summary: `run ${entry.run.task} exited ${code}`
3242
+ }
3243
+ });
3244
+ this.store.endSpawnedSession(entry.run.sessionId);
3245
+ this.live.delete(id);
3246
+ }
3247
+ onLine(run2, line) {
3248
+ if (!line.startsWith("{"))
3249
+ return;
3250
+ let j;
3251
+ try {
3252
+ j = JSON.parse(line);
3253
+ } catch {
3254
+ return;
3255
+ }
3256
+ if (j.type === "control_request" && j.request?.subtype === "can_use_tool") {
3257
+ this.onPermissionRequest(run2, j.request_id, j.request.tool_name ?? "", j.request.input ?? {});
3258
+ return;
3259
+ }
3260
+ if (j.type !== "result")
3261
+ return;
3262
+ run2.result = {
3263
+ costUsd: Number(j.total_cost_usd ?? 0),
3264
+ turns: Number(j.num_turns ?? 0),
3265
+ isError: Boolean(j.is_error),
3266
+ at: new Date().toISOString()
3267
+ };
3268
+ this.store.append({
3269
+ ts: run2.result.at,
3270
+ type: "run.result",
3271
+ projectId: run2.projectId,
3272
+ sessionId: run2.sessionId,
3273
+ payload: {
3274
+ runId: run2.id,
3275
+ task: run2.task,
3276
+ ...run2.result,
3277
+ summary: `turn done \xB7 $${run2.result.costUsd.toFixed(2)} \xB7 ${run2.result.turns} turns${run2.result.isError ? " \xB7 error" : ""}`
3278
+ }
3279
+ });
3280
+ }
3281
+ onPermissionRequest(run2, requestId, tool, input) {
3282
+ const { decision, display } = this.store.evaluateTool(tool, input, run2.sessionId, run2.worktree, true);
3283
+ if (decision.action === "deny") {
3284
+ this.answerPermission(run2.id, requestId, false, `[swarm] ${decision.reason}`);
3285
+ return;
3286
+ }
3287
+ if (decision.action === "allow") {
3288
+ this.answerPermission(run2.id, requestId, true);
3289
+ return;
3290
+ }
3291
+ run2.pending.push({
3292
+ requestId,
3293
+ tool,
3294
+ input,
3295
+ display,
3296
+ reason: decision.reason,
3297
+ askedAt: new Date().toISOString()
3298
+ });
3299
+ this.store.append({
3300
+ ts: new Date().toISOString(),
3301
+ type: "permission.requested",
3302
+ projectId: run2.projectId,
3303
+ sessionId: run2.sessionId,
3304
+ payload: {
3305
+ runId: run2.id,
3306
+ requestId,
3307
+ tool,
3308
+ display: display.slice(0, 300),
3309
+ reason: decision.reason,
3310
+ summary: `permission: ${tool} \u2014 waiting`
3311
+ }
3312
+ });
3313
+ this.store.touch();
3314
+ }
3315
+ answerPermission(runId, requestId, allow, message) {
3316
+ const entry = this.live.get(runId);
3317
+ if (!entry)
3318
+ return { ok: false, reason: "no live run" };
3319
+ const stdin = entry.proc.stdin;
3320
+ if (!stdin || typeof stdin === "number")
3321
+ return { ok: false, reason: "stdin not available" };
3322
+ const pend = entry.run.pending.find((p) => p.requestId === requestId);
3323
+ const response = allow ? { behavior: "allow", updatedInput: pend?.input ?? {} } : { behavior: "deny", message: message ?? "Denied from the Swarm dashboard" };
3324
+ stdin.write(`${JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: requestId, response } })}
3325
+ `);
3326
+ stdin.flush();
3327
+ entry.run.pending = entry.run.pending.filter((p) => p.requestId !== requestId);
3328
+ if (pend)
3329
+ this.store.append({
3330
+ ts: new Date().toISOString(),
3331
+ type: "permission.resolved",
3332
+ projectId: entry.run.projectId,
3333
+ sessionId: entry.run.sessionId,
3334
+ payload: {
3335
+ runId,
3336
+ requestId,
3337
+ tool: pend.tool,
3338
+ allow,
3339
+ summary: `permission: ${pend.tool} \u2014 ${allow ? "allowed" : "denied"}`
3340
+ }
3341
+ });
3342
+ this.store.touch();
3343
+ return { ok: true };
2582
3344
  }
2583
- async merge(projectId, number) {
2584
- const p = this.store.projects().find((x) => x.id === projectId);
2585
- if (!p)
2586
- return { ok: false, output: "unknown project" };
2587
- const remote = this.remote(p.root);
2588
- if (!remote)
2589
- return { ok: false, output: "no forge remote" };
2590
- const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
2591
- const bin = findBin(cmd[0]);
2592
- if (!bin)
2593
- return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
2594
- const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
2595
- const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
2596
- const ok = await proc.exited === 0;
2597
- if (ok)
2598
- this.cache.delete(projectId);
2599
- return { ok, output: out.trim().slice(0, 800) };
3345
+ send(id, text) {
3346
+ const entry = this.live.get(id) ?? [...this.live.values()].find((x) => x.run.task === id || x.run.sessionId === id);
3347
+ if (!entry)
3348
+ return { ok: false, reason: "no live run" };
3349
+ const stdin = entry.proc.stdin;
3350
+ if (!stdin || typeof stdin === "number")
3351
+ return { ok: false, reason: "stdin not available" };
3352
+ stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: text } })}
3353
+ `);
3354
+ stdin.flush();
3355
+ this.store.append({
3356
+ ts: new Date().toISOString(),
3357
+ type: "prompt.submitted",
3358
+ projectId: entry.run.projectId,
3359
+ sessionId: entry.run.sessionId,
3360
+ payload: { prompt: text.slice(0, 400), via: "swarm run send", summary: text.slice(0, 120) }
3361
+ });
3362
+ return { ok: true };
3363
+ }
3364
+ async stop(id) {
3365
+ const run2 = this.get(id);
3366
+ if (!run2)
3367
+ return { ok: false, reason: "no live run" };
3368
+ const entry = this.live.get(run2.id);
3369
+ try {
3370
+ const stdin = entry?.proc.stdin;
3371
+ if (stdin && typeof stdin !== "number")
3372
+ stdin.end();
3373
+ } catch {}
3374
+ return this.store.stopProcess(run2.pid, run2.projectId, 5000);
3375
+ }
3376
+ async stopAll() {
3377
+ await Promise.all([...this.live.keys()].map((id) => this.stop(id)));
2600
3378
  }
2601
3379
  }
2602
3380
 
@@ -2605,8 +3383,8 @@ import { Database } from "bun:sqlite";
2605
3383
  import {
2606
3384
  closeSync,
2607
3385
  existsSync as existsSync4,
2608
- mkdirSync as mkdirSync2,
2609
- openSync,
3386
+ mkdirSync as mkdirSync3,
3387
+ openSync as openSync2,
2610
3388
  readdirSync,
2611
3389
  readFileSync as readFileSync3,
2612
3390
  readSync,
@@ -2616,11 +3394,11 @@ import {
2616
3394
  writeFileSync as writeFileSync2
2617
3395
  } from "fs";
2618
3396
  import { homedir as homedir3 } from "os";
2619
- import { basename, dirname, join as join5 } from "path";
3397
+ import { basename, dirname, join as join6 } from "path";
2620
3398
 
2621
3399
  // packages/daemon/src/git.ts
2622
3400
  import { realpathSync } from "fs";
2623
- import { join as join4 } from "path";
3401
+ import { join as join5 } from "path";
2624
3402
  function git(cwd, args) {
2625
3403
  try {
2626
3404
  const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
@@ -2634,7 +3412,7 @@ function gitCommonDir(cwd) {
2634
3412
  if (!out)
2635
3413
  return null;
2636
3414
  try {
2637
- return realpathSync(out.startsWith("/") ? out : join4(cwd, out));
3415
+ return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
2638
3416
  } catch {
2639
3417
  return null;
2640
3418
  }
@@ -2757,6 +3535,83 @@ function heldWork(path) {
2757
3535
  return { dirty, unpushed };
2758
3536
  }
2759
3537
 
3538
+ // packages/daemon/src/task-sources.ts
3539
+ class TaskSources {
3540
+ env;
3541
+ cache = new Map;
3542
+ inflight = new Set;
3543
+ constructor(env = process.env) {
3544
+ this.env = env;
3545
+ }
3546
+ get(projectId, kind, root, opts, ttlMs = 60000) {
3547
+ const hit = this.cache.get(projectId);
3548
+ if (!hit || Date.now() - hit.at >= ttlMs)
3549
+ this.refresh(projectId, kind, root, opts);
3550
+ return hit ?? { at: 0, tasks: [], error: null };
3551
+ }
3552
+ async refresh(projectId, kind, root, opts) {
3553
+ if (this.inflight.has(projectId))
3554
+ return this.cache.get(projectId) ?? { at: 0, tasks: [], error: null };
3555
+ this.inflight.add(projectId);
3556
+ const prev = this.cache.get(projectId);
3557
+ let entry;
3558
+ try {
3559
+ const tasks2 = kind === "github" ? await this.github(root, opts.labels) : await this.linear(opts.team);
3560
+ entry = { at: Date.now(), tasks: tasks2, error: null };
3561
+ } catch (e) {
3562
+ entry = { at: Date.now(), tasks: prev?.tasks ?? [], error: e.message };
3563
+ } finally {
3564
+ this.inflight.delete(projectId);
3565
+ }
3566
+ this.cache.set(projectId, entry);
3567
+ return entry;
3568
+ }
3569
+ async github(root, labels) {
3570
+ const bin = findBin("gh");
3571
+ if (!bin)
3572
+ throw new Error("gh not installed \u2014 GitHub Issues need the gh CLI (brew install gh)");
3573
+ const args = [
3574
+ bin,
3575
+ "issue",
3576
+ "list",
3577
+ "--state",
3578
+ "all",
3579
+ "--limit",
3580
+ "300",
3581
+ "--json",
3582
+ "number,title,state,labels,body,assignees,milestone"
3583
+ ];
3584
+ for (const l of labels)
3585
+ args.push("--label", l);
3586
+ const proc = Bun.spawn(args, { cwd: root, stdout: "pipe", stderr: "pipe" });
3587
+ const [out, err, code] = await Promise.all([
3588
+ new Response(proc.stdout).text(),
3589
+ new Response(proc.stderr).text(),
3590
+ proc.exited
3591
+ ]);
3592
+ if (code !== 0)
3593
+ throw new Error(`gh issue list failed: ${err.trim().split(`
3594
+ `)[0] ?? code}`);
3595
+ return normalizeGithubIssues(JSON.parse(out));
3596
+ }
3597
+ async linear(team) {
3598
+ const key = this.env.LINEAR_API_KEY;
3599
+ if (!key)
3600
+ throw new Error("LINEAR_API_KEY not set \u2014 export it in the environment swarmd starts from (never stored)");
3601
+ const r = await fetch("https://api.linear.app/graphql", {
3602
+ method: "POST",
3603
+ headers: { "content-type": "application/json", authorization: key },
3604
+ body: JSON.stringify({ query: linearIssuesQuery(team) })
3605
+ });
3606
+ if (!r.ok)
3607
+ throw new Error(`Linear API ${r.status}`);
3608
+ const j = await r.json();
3609
+ if (j.errors?.length)
3610
+ throw new Error(`Linear: ${j.errors[0]?.message}`);
3611
+ return normalizeLinearIssues(j.data?.issues?.nodes ?? []);
3612
+ }
3613
+ }
3614
+
2760
3615
  // packages/daemon/src/store.ts
2761
3616
  var SCHEMA = `
2762
3617
  CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, root TEXT, common_dir TEXT, name TEXT, discovered INTEGER, created_at TEXT);
@@ -2783,12 +3638,26 @@ CREATE TABLE IF NOT EXISTS resources (
2783
3638
  PRIMARY KEY (name, project_id)
2784
3639
  );
2785
3640
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
3641
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory USING fts5(
3642
+ kind UNINDEXED, ref UNINDEXED, project_id UNINDEXED, task, session_id UNINDEXED, ts UNINDEXED,
3643
+ title, text, tokenize = 'unicode61 remove_diacritics 2'
3644
+ );
2786
3645
  CREATE TABLE IF NOT EXISTS incident_acks (seq INTEGER PRIMARY KEY, acked_at TEXT);
2787
3646
  CREATE TABLE IF NOT EXISTS processes (
2788
3647
  pid INTEGER, start_time TEXT, project_id TEXT, session_id TEXT, kind TEXT, name TEXT, port INTEGER,
2789
3648
  cwd TEXT, cmd TEXT, owner TEXT, log TEXT, started_at TEXT, ended_at TEXT
2790
3649
  );
2791
3650
  CREATE INDEX IF NOT EXISTS processes_live ON processes(ended_at, project_id);
3651
+ CREATE TABLE IF NOT EXISTS gates (
3652
+ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, gate TEXT, verdict TEXT,
3653
+ rubric TEXT, evidence TEXT, session_id TEXT, created_at TEXT
3654
+ );
3655
+ CREATE INDEX IF NOT EXISTS gates_task ON gates(project_id, task, created_at);
3656
+ CREATE TABLE IF NOT EXISTS handoffs (
3657
+ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, done TEXT, remaining TEXT,
3658
+ files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
3659
+ );
3660
+ CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
2792
3661
  CREATE TABLE IF NOT EXISTS claims (
2793
3662
  project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
2794
3663
  acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
@@ -2809,18 +3678,19 @@ class Store {
2809
3678
  gen = 0;
2810
3679
  memo = new Map;
2811
3680
  constructor(home = swarmHome()) {
2812
- mkdirSync2(home, { recursive: true });
3681
+ mkdirSync3(home, { recursive: true });
2813
3682
  this.home = home;
2814
- this.db = new Database(join5(home, "swarm.db"));
3683
+ this.db = new Database(join6(home, "swarm.db"));
2815
3684
  this.loadPricing();
2816
3685
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
2817
3686
  this.db.exec(SCHEMA);
2818
3687
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
2819
3688
  this.ensureColumn("projects", "sort_order", "INTEGER");
2820
- this.migrateProjectsJson(join5(home, "projects.json"));
3689
+ this.migrateProjectsJson(join6(home, "projects.json"));
2821
3690
  this.reconcileMovedProjects();
2822
3691
  this.slimExistingEvents();
2823
3692
  this.retypeNotificationIncidents();
3693
+ this.backfillMemory();
2824
3694
  }
2825
3695
  retypeNotificationIncidents() {
2826
3696
  if (this.meta("notifications_retyped") === "1")
@@ -2919,36 +3789,417 @@ class Store {
2919
3789
  return v;
2920
3790
  }
2921
3791
  rulesCache = new Map;
3792
+ preregisterSpawnedSession(id, projectId, cwd, task) {
3793
+ const now = new Date().toISOString();
3794
+ this.db.query(`INSERT INTO sessions (id, project_id, kind, cwd, started_at, last_seen_at, last, last_type, state, title)
3795
+ VALUES (?, ?, 'spawned', ?, ?, ?, ?, 'session.started', 'active', ?)
3796
+ 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}`);
3797
+ this.touch();
3798
+ }
3799
+ endSpawnedSession(id) {
3800
+ const now = new Date().toISOString();
3801
+ this.db.query("UPDATE sessions SET state = 'ended', ended_at = COALESCE(ended_at, ?), last_seen_at = ? WHERE id = ?").run(now, now, id);
3802
+ this.touch();
3803
+ }
3804
+ recordHandoff(projectId, h) {
3805
+ if (!this.project(projectId))
3806
+ return { ok: false, reason: "unknown project" };
3807
+ const v = validateHandoff(h);
3808
+ if (!v.ok)
3809
+ return v;
3810
+ const handoff = {
3811
+ task: h.task.trim(),
3812
+ done: h.done.trim(),
3813
+ remaining: h.remaining.trim(),
3814
+ files: (h.files ?? []).map((f) => f.trim()).filter(Boolean).slice(0, 50),
3815
+ verify: h.verify?.trim() || null,
3816
+ by: h.by?.trim() || null,
3817
+ createdAt: new Date().toISOString()
3818
+ };
3819
+ const sessionId = this.knownSession(h.sessionId);
3820
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3821
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
3822
+ this.remember(handoffDoc(projectId, Number(ins.lastInsertRowid), handoff, sessionId));
3823
+ this.append({
3824
+ ts: handoff.createdAt,
3825
+ type: "handoff.recorded",
3826
+ projectId,
3827
+ sessionId,
3828
+ payload: { task: handoff.task, by: handoff.by, summary: `handoff on ${handoff.task}` }
3829
+ });
3830
+ this.touch();
3831
+ return { ok: true, handoff };
3832
+ }
3833
+ latestHandoff(projectId, task) {
3834
+ const r = this.db.query("SELECT * FROM handoffs WHERE project_id = ? AND task = ? ORDER BY id DESC LIMIT 1").get(projectId, task);
3835
+ if (!r)
3836
+ return null;
3837
+ return {
3838
+ task: r.task,
3839
+ done: r.done,
3840
+ remaining: r.remaining,
3841
+ files: JSON.parse(r.files || "[]"),
3842
+ verify: r.verify ?? null,
3843
+ by: r.by ?? null,
3844
+ createdAt: r.created_at
3845
+ };
3846
+ }
3847
+ handoffs(projectId, limit = 50) {
3848
+ return this.db.query("SELECT * FROM handoffs WHERE project_id = ? ORDER BY id DESC LIMIT ?").all(projectId, limit).map((r) => ({
3849
+ task: r.task,
3850
+ done: r.done,
3851
+ remaining: r.remaining,
3852
+ files: JSON.parse(r.files || "[]"),
3853
+ verify: r.verify ?? null,
3854
+ by: r.by ?? null,
3855
+ createdAt: r.created_at,
3856
+ sessionId: r.session_id ?? null
3857
+ }));
3858
+ }
3859
+ autoHandoff(sessionId, cwd) {
3860
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
3861
+ if (!held)
3862
+ return null;
3863
+ const manual = this.db.query("SELECT id, by FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ?").all(held.projectId, held.task, sessionId);
3864
+ if (manual.some((h2) => !isAutoHandoff(h2)))
3865
+ return null;
3866
+ const row = this.db.query("SELECT last_text FROM sessions WHERE id = ?").get(sessionId);
3867
+ const h = deriveHandoff(held.task, this.sessionEvents(sessionId, 2000), { lastText: row?.last_text ?? null, sessionId });
3868
+ if (!h)
3869
+ return null;
3870
+ this.db.query("DELETE FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(held.projectId, held.task, sessionId);
3871
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3872
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt);
3873
+ this.remember(handoffDoc(held.projectId, Number(ins.lastInsertRowid), h, sessionId));
3874
+ this.touch();
3875
+ return h;
3876
+ }
3877
+ resumePlan(sessionId) {
3878
+ const s = this.db.query("SELECT project_id, cwd, last_text FROM sessions WHERE id = ?").get(sessionId);
3879
+ if (!s)
3880
+ return { ok: false, reason: "unknown session" };
3881
+ const byHandoff = this.db.query("SELECT project_id, task FROM handoffs WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
3882
+ const claim = this.claimRows(s.project_id).find((c) => c.worktree && s.cwd && isInside(s.cwd, c.worktree));
3883
+ const task = byHandoff?.task ?? claim?.task;
3884
+ const projectId = byHandoff?.project_id ?? s.project_id;
3885
+ if (!task)
3886
+ return { ok: false, reason: "this session was not working on a claimed task" };
3887
+ const ev = this.sessionEvents(sessionId, 2000);
3888
+ let handoff = this.latestHandoff(projectId, task);
3889
+ if (!handoff)
3890
+ handoff = deriveHandoff(task, ev, {
3891
+ lastText: s.last_text,
3892
+ sessionId
3893
+ });
3894
+ if (!handoff)
3895
+ return { ok: false, reason: "nothing to resume \u2014 the session left no trail" };
3896
+ const tail = ev.filter((e) => e.type === "tool.requested" || e.type === "prompt.submitted").slice(-12).map((e) => (e.payload.summary ?? e.type).slice(0, 160));
3897
+ const owner = claim && claim.state === "held" ? claim.owner : null;
3898
+ return { ok: true, projectId, task, owner, prompt: formatResumePrompt(handoff, tail), handoff };
3899
+ }
3900
+ remember(doc) {
3901
+ if (!doc)
3902
+ return;
3903
+ this.db.query("DELETE FROM memory WHERE kind = ? AND ref = ?").run(doc.kind, doc.ref);
3904
+ this.db.query("INSERT INTO memory (kind, ref, project_id, task, session_id, ts, title, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(doc.kind, doc.ref, doc.projectId, doc.task, doc.sessionId, doc.ts, doc.title, doc.text);
3905
+ }
3906
+ rememberSession(sessionId) {
3907
+ const r = this.db.query("SELECT id, project_id, title, last_text, last_seen_at, cwd FROM sessions WHERE id = ?").get(sessionId);
3908
+ if (!r)
3909
+ return;
3910
+ const held = r.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(r.cwd, c.worktree)) : null;
3911
+ this.remember(sessionDoc(r.project_id, {
3912
+ id: r.id,
3913
+ title: r.title,
3914
+ lastText: r.last_text,
3915
+ ts: r.last_seen_at,
3916
+ task: held?.task ?? null
3917
+ }));
3918
+ }
3919
+ backfillMemory() {
3920
+ if (this.db.query("SELECT value FROM meta WHERE key = 'memory_backfilled'").get())
3921
+ return;
3922
+ const tx = this.db.transaction(() => {
3923
+ for (const r of this.db.query("SELECT * FROM handoffs").all())
3924
+ this.remember(handoffDoc(r.project_id, r.id, {
3925
+ task: r.task,
3926
+ done: r.done,
3927
+ remaining: r.remaining,
3928
+ files: JSON.parse(r.files || "[]"),
3929
+ verify: r.verify ?? null,
3930
+ by: r.by ?? null,
3931
+ createdAt: r.created_at
3932
+ }, r.session_id ?? null));
3933
+ for (const r of this.db.query("SELECT * FROM gates").all())
3934
+ this.remember(gateDoc(r.project_id, r.id, this.rowToGate(r), r.session_id ?? null));
3935
+ for (const r of this.db.query("SELECT seq, ts, project_id, session_id, payload FROM events WHERE type = 'incident.opened'").all()) {
3936
+ let p = {};
3937
+ try {
3938
+ p = JSON.parse(r.payload || "{}");
3939
+ } catch {}
3940
+ this.remember(incidentDoc(r.project_id, r.seq, p, r.ts, r.session_id ?? null));
3941
+ }
3942
+ for (const r of this.db.query("SELECT id, project_id, title, last_text, last_seen_at FROM sessions WHERE last_text IS NOT NULL AND last_text != ''").all())
3943
+ this.remember(sessionDoc(r.project_id, {
3944
+ id: r.id,
3945
+ title: r.title ?? null,
3946
+ lastText: r.last_text,
3947
+ ts: r.last_seen_at
3948
+ }));
3949
+ this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('memory_backfilled', ?)").run(new Date().toISOString());
3950
+ });
3951
+ tx();
3952
+ }
3953
+ memorySearch(q, opts = {}) {
3954
+ const parsed = parseMemoryQuery(q);
3955
+ if (!parsed.match)
3956
+ return [];
3957
+ const kind = opts.kind ?? parsed.kind;
3958
+ const task = opts.task ?? parsed.task;
3959
+ const where = ["memory MATCH ?"];
3960
+ const args = [parsed.match];
3961
+ if (opts.projectId) {
3962
+ where.push("project_id = ?");
3963
+ args.push(opts.projectId);
3964
+ }
3965
+ if (kind) {
3966
+ where.push("kind = ?");
3967
+ args.push(kind);
3968
+ }
3969
+ if (task) {
3970
+ where.push("task = ?");
3971
+ args.push(task);
3972
+ }
3973
+ args.push(Math.min(200, Math.max(1, opts.limit ?? 30)));
3974
+ const rows = this.db.query(`SELECT kind, ref, project_id, task, session_id, ts, title, text,
3975
+ bm25(memory, 0, 0, 0, 2.0, 0, 0, 4.0, 1.0) AS score,
3976
+ snippet(memory, 7, '\x01', '\x02', ' \u2026 ', 24) AS snippet
3977
+ FROM memory WHERE ${where.join(" AND ")} ORDER BY score LIMIT ?`).all(...args);
3978
+ return rows.map((r) => ({
3979
+ kind: r.kind,
3980
+ ref: r.ref,
3981
+ projectId: r.project_id,
3982
+ task: r.task ?? null,
3983
+ sessionId: r.session_id ?? null,
3984
+ ts: r.ts,
3985
+ title: r.title,
3986
+ text: r.text,
3987
+ score: -r.score,
3988
+ snippet: r.snippet
3989
+ }));
3990
+ }
3991
+ sessionContext(cwd) {
3992
+ if (!cwd || !existsSync4(cwd))
3993
+ return null;
3994
+ const toplevel = this.toplevel(cwd);
3995
+ const project = this.resolveProject(cwd);
3996
+ const lines = [];
3997
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
3998
+ if (held) {
3999
+ const left = Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000));
4000
+ lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
4001
+ const h = this.latestHandoff(held.projectId, held.task);
4002
+ if (h)
4003
+ lines.push(formatHandoff(h));
4004
+ const required = this.requiredGates(held.projectId);
4005
+ if (required.length) {
4006
+ const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
4007
+ 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)`);
4008
+ }
4009
+ } else if (project) {
4010
+ const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
4011
+ if (active.length)
4012
+ 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.`);
4013
+ }
4014
+ const res = this.resources(project?.id).filter((r) => !r.released);
4015
+ if (res.length)
4016
+ 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`);
4017
+ const modes = this.rulesFor(toplevel);
4018
+ const on = [
4019
+ "shared_tree",
4020
+ "destructive_git",
4021
+ "pattern_kill",
4022
+ "protected_ports",
4023
+ "no_foreign_worktree",
4024
+ "claim_required_to_write"
4025
+ ].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
4026
+ if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
4027
+ lines.push(`[swarm] rules: ${on.join(" ")}`);
4028
+ return lines.length ? lines.join(`
4029
+ `) : null;
4030
+ }
4031
+ rowToGate(r) {
4032
+ return {
4033
+ id: r.id,
4034
+ projectId: r.project_id,
4035
+ task: r.task,
4036
+ gate: r.gate,
4037
+ verdict: r.verdict,
4038
+ rubric: r.rubric,
4039
+ evidence: r.evidence ?? null,
4040
+ sessionId: r.session_id ?? null,
4041
+ createdAt: r.created_at
4042
+ };
4043
+ }
4044
+ gateRuns(projectId, task, limit = 200) {
4045
+ 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);
4046
+ return rows.map((r) => this.rowToGate(r));
4047
+ }
4048
+ gateStatusFor(runs, required) {
4049
+ return gateStatus(runs, required);
4050
+ }
4051
+ requiredGates(projectId) {
4052
+ const p = this.project(projectId);
4053
+ return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates.required : [];
4054
+ }
4055
+ recordGate(projectId, input) {
4056
+ if (!this.project(projectId))
4057
+ return { ok: false, reason: "unknown project" };
4058
+ const v = validateGateRun(input);
4059
+ if (!v.ok)
4060
+ return v;
4061
+ const createdAt = new Date().toISOString();
4062
+ const sessionId = this.knownSession(input.sessionId);
4063
+ const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
4064
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
4065
+ const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
4066
+ this.remember(gateDoc(projectId, run2.id, run2, sessionId));
4067
+ this.append({
4068
+ ts: createdAt,
4069
+ type: "gate.recorded",
4070
+ projectId,
4071
+ sessionId,
4072
+ payload: {
4073
+ task: run2.task,
4074
+ gate: run2.gate,
4075
+ verdict: run2.verdict,
4076
+ summary: `gate ${run2.gate} ${run2.verdict} on ${run2.task}`
4077
+ }
4078
+ });
4079
+ if (run2.verdict === "fail")
4080
+ this.append({
4081
+ ts: createdAt,
4082
+ type: "incident.opened",
4083
+ projectId,
4084
+ sessionId,
4085
+ payload: {
4086
+ rule: "gate_failed",
4087
+ action: "failed",
4088
+ command: `${run2.task} \xB7 ${run2.gate}`,
4089
+ reason: `${run2.rubric}${run2.evidence ? ` \u2014 ${run2.evidence.slice(0, 200)}` : ""}`
4090
+ }
4091
+ });
4092
+ this.touch();
4093
+ return { ok: true, run: run2 };
4094
+ }
2922
4095
  taskCache = new Map;
4096
+ taskSources = new TaskSources;
2923
4097
  tasks(projectId) {
2924
4098
  const p = this.project(projectId);
2925
4099
  if (!p)
2926
4100
  return null;
2927
- const source = loadConfig({ repoRoot: p.root }).tasks.source;
4101
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).tasks;
4102
+ const source = cfg.source;
2928
4103
  if (!source)
2929
4104
  return null;
2930
- const path = join5(p.root, source);
2931
- if (!existsSync4(path))
2932
- return { source, tasks: [] };
2933
- const mtime = statSync(path).mtimeMs;
2934
- let hit = this.taskCache.get(projectId);
2935
- if (!hit || hit.path !== path || hit.mtime !== mtime) {
2936
- hit = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
2937
- this.taskCache.set(projectId, hit);
4105
+ let hit;
4106
+ let error = null;
4107
+ const kind = taskSourceKind(source);
4108
+ if (kind === "github" || kind === "linear") {
4109
+ const e = this.taskSources.get(projectId, kind, p.root, {
4110
+ labels: cfg.labels,
4111
+ team: cfg.team
4112
+ });
4113
+ hit = { tasks: e.tasks };
4114
+ error = e.error;
4115
+ } else {
4116
+ const path = join6(p.root, source);
4117
+ if (!existsSync4(path))
4118
+ return { source, required: this.requiredGates(projectId), tasks: [] };
4119
+ const mtime = statSync(path).mtimeMs;
4120
+ let md = this.taskCache.get(projectId);
4121
+ if (!md || md.path !== path || md.mtime !== mtime) {
4122
+ md = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
4123
+ this.taskCache.set(projectId, md);
4124
+ }
4125
+ hit = md;
2938
4126
  }
2939
4127
  const now = Date.now();
2940
4128
  const active = this.claimRows(projectId).filter((c) => isActive(c, now));
2941
- return { source, tasks: taskBoard(hit.tasks, active) };
4129
+ const required = this.requiredGates(projectId);
4130
+ const runs = this.gateRuns(projectId, undefined, 2000);
4131
+ const byTask = new Map;
4132
+ for (const r of runs)
4133
+ byTask.set(r.task, [...byTask.get(r.task) ?? [], r]);
4134
+ const board = taskBoard(hit.tasks, active).map((t) => {
4135
+ const tr = byTask.get(t.id) ?? [];
4136
+ return {
4137
+ ...t,
4138
+ gates: gateStatus(tr, required).map((g) => ({
4139
+ gate: g.gate,
4140
+ verdict: g.verdict,
4141
+ fails: g.fails,
4142
+ runs: g.runs
4143
+ })),
4144
+ gated: gatesSatisfied(tr, required)
4145
+ };
4146
+ });
4147
+ return { source, required, tasks: board, error };
2942
4148
  }
2943
4149
  rulesFor(repoRoot) {
2944
4150
  const key = repoRoot ?? "";
2945
4151
  const hit = this.rulesCache.get(key);
2946
4152
  if (hit && Date.now() - hit.at < 30000)
2947
4153
  return hit.rules;
2948
- const rules2 = loadConfig({ repoRoot }).rules;
4154
+ const rules2 = loadConfig({ repoRoot, home: this.home }).rules;
2949
4155
  this.rulesCache.set(key, { at: Date.now(), rules: rules2 });
2950
4156
  return rules2;
2951
4157
  }
4158
+ evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
4159
+ const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
4160
+ const cmd = tool === "Bash" ? input.command : undefined;
4161
+ const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
4162
+ const modes = this.rulesFor(current.toplevel);
4163
+ if (isWrite && (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off")) {
4164
+ const target = absolutePath(input.file_path, cwd);
4165
+ const w = guardWrite(target, current, this.heldWorktrees(), modes, "file");
4166
+ if (w.action !== "allow") {
4167
+ if (recordIncident)
4168
+ this.openIncident(w, cwd, sessionId, `${tool} ${target}`);
4169
+ return { decision: w, display: `${tool} ${target}` };
4170
+ }
4171
+ }
4172
+ if (cmd) {
4173
+ if (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off") {
4174
+ const w = guardWrite(cwd, current, this.heldWorktrees(), modes, "bash");
4175
+ if (w.action !== "allow") {
4176
+ if (recordIncident)
4177
+ this.openIncident(w, cwd, sessionId, cmd);
4178
+ return { decision: w, display: cmd };
4179
+ }
4180
+ }
4181
+ const d = guardBash(cmd, current, this.liveSessions(), Date.now(), {
4182
+ ...modes,
4183
+ protected: { ports: [...new Set([...modes.protected.ports, ...this.heldPorts()])] }
4184
+ });
4185
+ if (d.action !== "allow" && recordIncident)
4186
+ this.openIncident(d, cwd, sessionId, cmd);
4187
+ return { decision: d, display: cmd };
4188
+ }
4189
+ return {
4190
+ decision: { action: "allow" },
4191
+ display: isWrite ? `${tool} ${input.file_path}` : tool
4192
+ };
4193
+ }
4194
+ liveSessions() {
4195
+ 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());
4196
+ return rows.map((r) => ({
4197
+ id: r.id,
4198
+ toplevel: this.toplevel(r.cwd),
4199
+ lastSeenAt: r.last_seen_at,
4200
+ state: r.state
4201
+ }));
4202
+ }
2952
4203
  guardHook(raw2) {
2953
4204
  const tool = typeof raw2.tool_name === "string" ? raw2.tool_name : "";
2954
4205
  const input = raw2.tool_input ?? {};
@@ -2995,6 +4246,51 @@ class Store {
2995
4246
  return d;
2996
4247
  }
2997
4248
  heldWorktreesCache = null;
4249
+ dryRun(projectId, overrides = {}, limit = 5000) {
4250
+ const project = this.project(projectId);
4251
+ const modes = { ...this.rulesFor(project?.root ?? null), ...overrides };
4252
+ const rows = this.db.query(`SELECT * FROM (SELECT seq, ts, type, session_id, payload FROM events
4253
+ WHERE project_id = ? AND type IN ('tool.requested', 'tool.completed')
4254
+ ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(projectId, limit);
4255
+ const calls = [];
4256
+ const pending = new Map;
4257
+ for (const r of rows) {
4258
+ let p;
4259
+ try {
4260
+ p = JSON.parse(r.payload);
4261
+ } catch {
4262
+ continue;
4263
+ }
4264
+ if (!p.tool || !r.session_id)
4265
+ continue;
4266
+ const key = `${r.session_id} ${p.summary ?? p.tool}`;
4267
+ if (r.type === "tool.requested") {
4268
+ const input = p.toolInput ?? {};
4269
+ const call = {
4270
+ ts: r.ts,
4271
+ sessionId: r.session_id,
4272
+ cwd: p.cwd ?? "",
4273
+ tool: p.tool,
4274
+ command: typeof input.command === "string" ? input.command : undefined,
4275
+ filePath: typeof input.file_path === "string" ? input.file_path : undefined,
4276
+ completed: false
4277
+ };
4278
+ calls.push(call);
4279
+ pending.set(key, call);
4280
+ } else {
4281
+ const c = pending.get(key);
4282
+ if (c) {
4283
+ c.completed = true;
4284
+ pending.delete(key);
4285
+ }
4286
+ }
4287
+ }
4288
+ const report = dryRunRules(calls, modes, {
4289
+ toplevel: (cwd) => cwd && existsSync4(cwd) ? this.toplevel(cwd) : null,
4290
+ claims: this.heldWorktrees()
4291
+ });
4292
+ return { ...report, modes };
4293
+ }
2998
4294
  heldWorktrees() {
2999
4295
  if (this.heldWorktreesCache && Date.now() - this.heldWorktreesCache.at < 2000)
3000
4296
  return this.heldWorktreesCache.v;
@@ -3005,7 +4301,7 @@ class Store {
3005
4301
  loadPricing() {
3006
4302
  this.prices = { ...PRICES };
3007
4303
  for (const f of ["pricing.litellm.json", "pricing.json"]) {
3008
- const p = join5(this.home, f);
4304
+ const p = join6(this.home, f);
3009
4305
  if (!existsSync4(p))
3010
4306
  continue;
3011
4307
  try {
@@ -3021,7 +4317,7 @@ class Store {
3021
4317
  throw new Error(`pricing fetch ${r.status}`);
3022
4318
  const j = await r.json();
3023
4319
  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));
4320
+ writeFileSync2(join6(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
3025
4321
  this.loadPricing();
3026
4322
  this.reprice();
3027
4323
  }
@@ -3143,6 +4439,8 @@ class Store {
3143
4439
  const slim = slimForStorage(e);
3144
4440
  const r = this.db.query("INSERT INTO events (ts, type, project_id, session_id, payload, raw) VALUES (?, ?, ?, ?, ?, ?)").run(e.ts, e.type, e.projectId, e.sessionId, JSON.stringify(slim.payload ?? null), slim.raw === undefined ? null : JSON.stringify(slim.raw));
3145
4441
  const stored = { ...e, seq: Number(r.lastInsertRowid) };
4442
+ if (stored.type === "incident.opened")
4443
+ this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
3146
4444
  this.projectSession(stored);
3147
4445
  this.touch();
3148
4446
  const wire = toWire(stored);
@@ -3160,9 +4458,16 @@ class Store {
3160
4458
  return n;
3161
4459
  }
3162
4460
  ingestHook(event, raw2) {
4461
+ if (typeof raw2.cwd === "string")
4462
+ this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
3163
4463
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
3164
4464
  const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
3165
4465
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
4466
+ if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
4467
+ if (existsSync4(cwd))
4468
+ this.autoHandoff(e.sessionId, cwd);
4469
+ this.rememberSession(e.sessionId);
4470
+ }
3166
4471
  if (e.sessionId && typeof raw2.transcript_path === "string") {
3167
4472
  this.db.query("UPDATE sessions SET transcript_path = ? WHERE id = ? AND transcript_path IS NULL").run(raw2.transcript_path, e.sessionId);
3168
4473
  const last = this.lastTail.get(e.sessionId) ?? 0;
@@ -3173,8 +4478,24 @@ class Store {
3173
4478
  }
3174
4479
  return e;
3175
4480
  }
4481
+ static LEDGER_EVENTS = new Set([
4482
+ "process.started",
4483
+ "process.exited",
4484
+ "resource.acquired",
4485
+ "resource.released",
4486
+ "resource.reaped",
4487
+ "claim.acquired",
4488
+ "claim.renewed",
4489
+ "claim.released",
4490
+ "claim.orphaned",
4491
+ "gate.recorded",
4492
+ "handoff.recorded",
4493
+ "incident.opened",
4494
+ "incident.acked",
4495
+ "run.result"
4496
+ ]);
3176
4497
  projectSession(e) {
3177
- if (!e.sessionId)
4498
+ if (!e.sessionId || Store.LEDGER_EVENTS.has(e.type))
3178
4499
  return;
3179
4500
  const p = e.payload;
3180
4501
  const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
@@ -3198,7 +4519,7 @@ class Store {
3198
4519
  const size = statSync(path).size;
3199
4520
  if (size <= offset)
3200
4521
  return null;
3201
- const fd = openSync(path, "r");
4522
+ const fd = openSync2(path, "r");
3202
4523
  const buf = Buffer.alloc(size - offset);
3203
4524
  readSync(fd, buf, 0, buf.length, offset);
3204
4525
  closeSync(fd);
@@ -3239,6 +4560,10 @@ class Store {
3239
4560
  const lastText = [...d.turns].reverse().find((t) => t.text && !t.sidechain)?.text ?? null;
3240
4561
  const lastModel = [...d.turns].reverse().find((t) => !t.sidechain)?.model ?? null;
3241
4562
  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);
4563
+ if (d.turns.length) {
4564
+ const cwdRow = this.db.query("SELECT cwd FROM sessions WHERE id = ?").get(sessionId);
4565
+ this.autoRenewFor(sessionId, cwdRow?.cwd);
4566
+ }
3242
4567
  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
4568
  return d.turns.length;
3244
4569
  }
@@ -3247,9 +4572,9 @@ class Store {
3247
4572
  if (!s?.transcript_path || !existsSync4(s.transcript_path))
3248
4573
  return 0;
3249
4574
  let n = this.tailFile(s.transcript_path, sessionId, null);
3250
- const subDir = join5(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
4575
+ const subDir = join6(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
3251
4576
  for (const f of this.subagentFiles(subDir)) {
3252
- n += this.tailFile(join5(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
4577
+ n += this.tailFile(join6(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
3253
4578
  }
3254
4579
  return n;
3255
4580
  }
@@ -3281,7 +4606,7 @@ class Store {
3281
4606
  return n;
3282
4607
  }
3283
4608
  codexRoot() {
3284
- return process.env.SWARM_CODEX_DIR ?? join5(homedir3(), ".codex", "sessions");
4609
+ return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
3285
4610
  }
3286
4611
  codexRolloutFiles(sinceMs) {
3287
4612
  const root = this.codexRoot();
@@ -3296,18 +4621,18 @@ class Store {
3296
4621
  for (const y of ls(root)) {
3297
4622
  if (!/^\d{4}$/.test(y))
3298
4623
  continue;
3299
- for (const m of ls(join5(root, y))) {
4624
+ for (const m of ls(join6(root, y))) {
3300
4625
  if (!/^\d\d$/.test(m))
3301
4626
  continue;
3302
- for (const day of ls(join5(root, y, m))) {
4627
+ for (const day of ls(join6(root, y, m))) {
3303
4628
  if (!/^\d\d$/.test(day))
3304
4629
  continue;
3305
4630
  if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
3306
4631
  continue;
3307
- const dir = join5(root, y, m, day);
4632
+ const dir = join6(root, y, m, day);
3308
4633
  for (const f of ls(dir)) {
3309
4634
  if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
3310
- out.push(join5(dir, f));
4635
+ out.push(join6(dir, f));
3311
4636
  }
3312
4637
  }
3313
4638
  }
@@ -3324,7 +4649,7 @@ class Store {
3324
4649
  return n;
3325
4650
  }
3326
4651
  grokRoot() {
3327
- return process.env.SWARM_GROK_DIR ?? join5(homedir3(), ".grok", "sessions");
4652
+ return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
3328
4653
  }
3329
4654
  grokSummary = new Map;
3330
4655
  tailGrok(windowMs = 3 * 24 * 60 * 60000) {
@@ -3349,9 +4674,9 @@ class Store {
3349
4674
  } catch {
3350
4675
  cwd = enc;
3351
4676
  }
3352
- const cwdDir = join5(root, enc);
4677
+ const cwdDir = join6(root, enc);
3353
4678
  for (const sid of ls(cwdDir)) {
3354
- const path = join5(cwdDir, sid, "updates.jsonl");
4679
+ const path = join6(cwdDir, sid, "updates.jsonl");
3355
4680
  if (!existsSync4(path))
3356
4681
  continue;
3357
4682
  try {
@@ -3360,7 +4685,7 @@ class Store {
3360
4685
  } catch {
3361
4686
  continue;
3362
4687
  }
3363
- const sumPath = join5(cwdDir, sid, "summary.json");
4688
+ const sumPath = join6(cwdDir, sid, "summary.json");
3364
4689
  let title;
3365
4690
  let fresh = false;
3366
4691
  try {
@@ -3447,7 +4772,7 @@ class Store {
3447
4772
  worktreePath(projectId, task) {
3448
4773
  const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
3449
4774
  const p = this.project(projectId);
3450
- return join5(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
4775
+ return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
3451
4776
  }
3452
4777
  claim(projectId, task, owner, baseRef = "HEAD") {
3453
4778
  const p = this.project(projectId);
@@ -3461,7 +4786,7 @@ class Store {
3461
4786
  const worktree = this.worktreePath(projectId, task);
3462
4787
  if (existsSync4(worktree))
3463
4788
  return { ok: false, error: `${worktree} already exists; release ${task} first` };
3464
- mkdirSync2(dirname(worktree), { recursive: true });
4789
+ mkdirSync3(dirname(worktree), { recursive: true });
3465
4790
  const created = worktreeAdd(p.root, worktree, branch, baseRef);
3466
4791
  if (!created)
3467
4792
  return { ok: false, error: `git worktree add failed for ${task}` };
@@ -3481,6 +4806,82 @@ class Store {
3481
4806
  });
3482
4807
  return { ok: true, task, owner, worktree: created, branch, expiresAt };
3483
4808
  }
4809
+ autoRenewAt = new Map;
4810
+ autoRenewFor(sessionId, cwd) {
4811
+ if (!cwd)
4812
+ return;
4813
+ const key = sessionId ?? cwd;
4814
+ const last = this.autoRenewAt.get(key) ?? 0;
4815
+ const now = Date.now();
4816
+ if (now - last < 60000)
4817
+ return;
4818
+ this.autoRenewAt.set(key, now);
4819
+ for (const c of this.heldClaimsWithWorktree()) {
4820
+ if (!isInside(cwd, c.worktree))
4821
+ continue;
4822
+ if (!shouldAutoRenew({ state: "held", expiresAt: c.expiresAt }, now))
4823
+ continue;
4824
+ const expiresAt = nextExpiry(now);
4825
+ this.db.query("UPDATE claims SET expires_at = ? WHERE project_id = ? AND task = ? AND state = 'held'").run(expiresAt, c.projectId, c.task);
4826
+ this.append({
4827
+ ts: new Date(now).toISOString(),
4828
+ type: "claim.renewed",
4829
+ projectId: c.projectId,
4830
+ sessionId: this.knownSession(sessionId),
4831
+ payload: { task: c.task, expiresAt, auto: true, summary: `auto-renew ${c.task}` }
4832
+ });
4833
+ }
4834
+ }
4835
+ heldClaimsWithWorktree() {
4836
+ return this.db.query("SELECT project_id, task, worktree, expires_at FROM claims WHERE state = 'held' AND worktree != ''").all().map((r) => ({
4837
+ projectId: r.project_id,
4838
+ task: r.task,
4839
+ worktree: r.worktree,
4840
+ expiresAt: r.expires_at
4841
+ }));
4842
+ }
4843
+ sweepOrphans() {
4844
+ const now = Date.now();
4845
+ let n = 0;
4846
+ for (const p of this.projects()) {
4847
+ for (const c of this.claimRows(p.id)) {
4848
+ if (c.state !== "held" || isActive(c, now))
4849
+ continue;
4850
+ const exists = c.worktree ? existsSync4(c.worktree) : false;
4851
+ const work = exists ? heldWork(c.worktree) : null;
4852
+ if (reapAction(c, now, exists, work) !== "keep-orphaned")
4853
+ continue;
4854
+ this.db.query("UPDATE claims SET state = 'orphaned' WHERE project_id = ? AND task = ?").run(p.id, c.task);
4855
+ const ts = new Date(now).toISOString();
4856
+ this.append({
4857
+ ts,
4858
+ type: "claim.orphaned",
4859
+ projectId: p.id,
4860
+ sessionId: null,
4861
+ payload: {
4862
+ task: c.task,
4863
+ worktree: c.worktree,
4864
+ summary: `orphaned ${c.task} (holds work)`
4865
+ }
4866
+ });
4867
+ this.append({
4868
+ ts,
4869
+ type: "incident.opened",
4870
+ projectId: p.id,
4871
+ sessionId: null,
4872
+ payload: {
4873
+ rule: "orphaned_claim",
4874
+ action: "orphaned",
4875
+ command: `${c.task} \u2192 ${c.worktree}`,
4876
+ 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.`
4877
+ }
4878
+ });
4879
+ this.touch();
4880
+ n++;
4881
+ }
4882
+ }
4883
+ return n;
4884
+ }
3484
4885
  renew(projectId, task) {
3485
4886
  const row = this.db.query("SELECT state FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
3486
4887
  if (!row)
@@ -3720,6 +5121,23 @@ class Store {
3720
5121
  daily
3721
5122
  };
3722
5123
  }
5124
+ attribution(projectId) {
5125
+ const claims = this.db.query("SELECT task, owner, worktree, state FROM claims WHERE project_id = ? AND worktree != ''").all(projectId);
5126
+ const byTask = claims.map((c) => {
5127
+ const r = this.db.query(`SELECT COALESCE(SUM(t.cost_usd),0) AS cost, COALESCE(SUM(t.output),0) AS output, COUNT(*) AS turns,
5128
+ COUNT(DISTINCT s.id) AS sessions
5129
+ FROM sessions s JOIN turns t ON t.session_id = s.id
5130
+ WHERE s.cwd = ? OR s.cwd LIKE ?`).get(c.worktree, `${c.worktree}/%`);
5131
+ return { task: c.task, owner: c.owner, state: c.state, worktree: c.worktree, ...r };
5132
+ }).filter((t) => t.turns > 0).sort((a, b) => b.cost - a.cost);
5133
+ const contextBudget = this.db.query(`SELECT s.id, s.title, s.project_id AS projectId,
5134
+ COALESCE(SUM(t.cache_read),0) AS cacheRead,
5135
+ COALESCE(SUM(t.input + t.cache_write + t.cache_read),0) AS input,
5136
+ COALESCE(SUM(t.cost_usd),0) AS cost, COUNT(*) AS turns
5137
+ FROM sessions s JOIN turns t ON t.session_id = s.id
5138
+ WHERE s.project_id = ? GROUP BY s.id HAVING turns > 3 ORDER BY cacheRead DESC LIMIT 12`).all(projectId).map((r) => ({ ...r, reuse: r.input ? r.cacheRead / r.input : 0 }));
5139
+ return { byTask, contextBudget };
5140
+ }
3723
5141
  stats(projectId) {
3724
5142
  const scope = projectId ? "s.project_id = ?" : "? IS NULL";
3725
5143
  const arg = projectId ?? null;
@@ -3794,7 +5212,7 @@ class Store {
3794
5212
  args.push(limit);
3795
5213
  const rows = this.db.query(`SELECT e.seq, e.ts, e.project_id, e.session_id, e.payload, a.acked_at FROM events e
3796
5214
  LEFT JOIN incident_acks a ON a.seq = e.seq WHERE ${where.join(" AND ")} ORDER BY e.seq DESC LIMIT ?`).all(...args);
3797
- return rows.map((r) => ({
5215
+ const list = rows.map((r) => ({
3798
5216
  seq: r.seq,
3799
5217
  ts: r.ts,
3800
5218
  projectId: r.project_id,
@@ -3802,6 +5220,25 @@ class Store {
3802
5220
  acked: r.acked_at,
3803
5221
  ...JSON.parse(r.payload || "{}")
3804
5222
  }));
5223
+ const counts = new Map;
5224
+ for (const i of list) {
5225
+ const key = incidentKey(i);
5226
+ counts.set(key, (counts.get(key) ?? 0) + 1);
5227
+ }
5228
+ return list.map((i) => {
5229
+ const incident = i;
5230
+ if (!incident.rule)
5231
+ return i;
5232
+ const key = incidentKey(incident);
5233
+ const suggestion = suggestFromIncident({
5234
+ rule: incident.rule,
5235
+ action: incident.action ?? "",
5236
+ command: incident.command ?? "",
5237
+ reason: incident.reason ?? "",
5238
+ count: counts.get(key) ?? 1
5239
+ });
5240
+ return { ...i, count: counts.get(key) ?? 1, suggestion };
5241
+ });
3805
5242
  }
3806
5243
  openIncidents(projectId) {
3807
5244
  const r = this.db.query(`SELECT COUNT(*) AS n FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
@@ -4190,13 +5627,13 @@ function rowToEvent(r) {
4190
5627
  }
4191
5628
 
4192
5629
  // packages/daemon/src/app.ts
4193
- var VERSION = "0.4.1";
5630
+ var VERSION = "0.6.0";
4194
5631
  var WEB_DIR = (() => {
4195
5632
  if (process.env.SWARM_WEB_DIR)
4196
5633
  return process.env.SWARM_WEB_DIR;
4197
5634
  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");
5635
+ const dev = join7(here, "../../web/public");
5636
+ return existsSync5(join7(dev, "index.html")) ? dev : join7(here, "../web");
4200
5637
  })();
4201
5638
  var REPLAY_TAIL = 200;
4202
5639
  var wireCache = new WeakMap;
@@ -4211,6 +5648,7 @@ function wireJson(e) {
4211
5648
  function createApp(store = new Store) {
4212
5649
  const app = new Hono2;
4213
5650
  const forge2 = new ForgeService(store);
5651
+ const runner = new Runner(store, store.home);
4214
5652
  app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
4215
5653
  app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
4216
5654
  app.post("/v1/projects", async (c) => {
@@ -4244,7 +5682,7 @@ function createApp(store = new Store) {
4244
5682
  dir = homedir4();
4245
5683
  }
4246
5684
  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));
5685
+ 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
5686
  const parent = dirname2(dir);
4249
5687
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
4250
5688
  } catch (e) {
@@ -4257,6 +5695,30 @@ function createApp(store = new Store) {
4257
5695
  open: c.req.query("open") === "1",
4258
5696
  projectId: c.req.query("project") || undefined
4259
5697
  })));
5698
+ app.get("/v1/memory", (c) => {
5699
+ const q = c.req.query("q") ?? "";
5700
+ const kind = c.req.query("kind");
5701
+ return c.json({
5702
+ q,
5703
+ hits: store.memorySearch(q, {
5704
+ projectId: c.req.query("project") || null,
5705
+ kind: MEMORY_KINDS.includes(kind) ? kind : null,
5706
+ task: c.req.query("task") || null,
5707
+ limit: Number(c.req.query("limit")) || 30
5708
+ })
5709
+ });
5710
+ });
5711
+ app.get("/v1/rules/dryrun", (c) => {
5712
+ const projectId = c.req.query("project");
5713
+ if (!projectId)
5714
+ return c.json({ ok: false, error: "project required" }, 400);
5715
+ const overrides = {};
5716
+ for (const [k, v] of Object.entries(c.req.query()))
5717
+ if (RULE_IDS.includes(k) && ["ask", "deny", "off"].includes(v))
5718
+ overrides[k] = v;
5719
+ const limit = Math.min(20000, Math.max(100, Number(c.req.query("limit")) || 5000));
5720
+ return c.json(store.dryRun(projectId, overrides, limit));
5721
+ });
4260
5722
  app.post("/v1/incidents/ack", async (c) => {
4261
5723
  const body = await c.req.json().catch(() => ({}));
4262
5724
  return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
@@ -4318,6 +5780,92 @@ function createApp(store = new Store) {
4318
5780
  const r = await store.stopProcess(pid, c.req.query("project") || null);
4319
5781
  return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
4320
5782
  });
5783
+ app.get("/v1/runs", (c) => c.json(runner.list(c.req.query("project") || undefined)));
5784
+ app.post("/v1/runs", async (c) => {
5785
+ const b = await c.req.json().catch(() => ({}));
5786
+ if (!b.projectId || !b.task || !b.prompt)
5787
+ return c.json({ ok: false, error: "projectId, task and prompt required" }, 400);
5788
+ const r = await runner.start({
5789
+ projectId: b.projectId,
5790
+ task: b.task,
5791
+ prompt: b.prompt,
5792
+ owner: b.owner ?? "dashboard",
5793
+ model: b.model,
5794
+ permissionMode: b.permissionMode,
5795
+ allowedTools: b.allowedTools,
5796
+ maxTurns: b.maxTurns
5797
+ });
5798
+ return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
5799
+ });
5800
+ app.post("/v1/runs/:id/send", async (c) => {
5801
+ const b = await c.req.json().catch(() => ({}));
5802
+ if (!b.text?.trim())
5803
+ return c.json({ ok: false, error: "text required" }, 400);
5804
+ const r = runner.send(c.req.param("id"), b.text);
5805
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
5806
+ });
5807
+ app.post("/v1/runs/:id/permissions/:reqId", async (c) => {
5808
+ const b = await c.req.json().catch(() => ({}));
5809
+ const r = runner.answerPermission(c.req.param("id"), c.req.param("reqId"), b.allow === true, b.message);
5810
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
5811
+ });
5812
+ app.delete("/v1/runs/:id", async (c) => {
5813
+ const r = await runner.stop(c.req.param("id"));
5814
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
5815
+ });
5816
+ app.get("/v1/handoffs", (c) => {
5817
+ const project = c.req.query("project");
5818
+ if (!project)
5819
+ return c.json({ error: "project required" }, 400);
5820
+ const task = c.req.query("task");
5821
+ if (task) {
5822
+ const h = store.latestHandoff(project, task);
5823
+ return h ? c.json({ handoff: h, text: formatHandoff(h) }) : c.json({ handoff: null, text: null }, 404);
5824
+ }
5825
+ return c.json(store.handoffs(project));
5826
+ });
5827
+ app.post("/v1/handoffs", async (c) => {
5828
+ const b = await c.req.json().catch(() => ({}));
5829
+ if (!b.projectId || !b.task)
5830
+ return c.json({ ok: false, error: "projectId and task required" }, 400);
5831
+ const r = store.recordHandoff(b.projectId, {
5832
+ task: b.task,
5833
+ done: b.done ?? "",
5834
+ remaining: b.remaining ?? "",
5835
+ files: Array.isArray(b.files) ? b.files : [],
5836
+ verify: b.verify ?? null,
5837
+ by: b.by ?? null,
5838
+ sessionId: b.sessionId ?? null
5839
+ });
5840
+ return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 400);
5841
+ });
5842
+ app.get("/v1/gates", (c) => {
5843
+ const project = c.req.query("project");
5844
+ if (!project)
5845
+ return c.json({ error: "project required" }, 400);
5846
+ const task = c.req.query("task") || undefined;
5847
+ const runs = store.gateRuns(project, task);
5848
+ const required = store.requiredGates(project);
5849
+ return c.json({
5850
+ required,
5851
+ runs,
5852
+ status: task ? store.gateStatusFor(runs, required) : undefined
5853
+ });
5854
+ });
5855
+ app.post("/v1/gates", async (c) => {
5856
+ const b = await c.req.json().catch(() => ({}));
5857
+ if (!b.projectId)
5858
+ return c.json({ ok: false, error: "projectId required" }, 400);
5859
+ const r = store.recordGate(b.projectId, {
5860
+ task: b.task ?? "",
5861
+ gate: b.gate ?? "",
5862
+ verdict: b.verdict,
5863
+ rubric: b.rubric,
5864
+ evidence: b.evidence,
5865
+ sessionId: b.sessionId ?? null
5866
+ });
5867
+ return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 400);
5868
+ });
4321
5869
  app.get("/v1/tasks", (c) => {
4322
5870
  const project = c.req.query("project");
4323
5871
  if (!project)
@@ -4362,6 +5910,10 @@ function createApp(store = new Store) {
4362
5910
  return e ? c.json(e) : c.json({ error: "not found" }, 404);
4363
5911
  });
4364
5912
  app.get("/v1/spend", (c) => c.json(store.spend()));
5913
+ app.get("/v1/attribution", (c) => {
5914
+ const project = c.req.query("project");
5915
+ return project ? c.json(store.attribution(project)) : c.json({ error: "project required" }, 400);
5916
+ });
4365
5917
  app.post("/v1/pricing/refresh", async (c) => {
4366
5918
  try {
4367
5919
  await store.refreshPricing();
@@ -4371,11 +5923,40 @@ function createApp(store = new Store) {
4371
5923
  }
4372
5924
  });
4373
5925
  app.get("/v1/pricing", (c) => c.json(store.prices));
5926
+ app.get("/v1/sessions/:id/resume", (c) => {
5927
+ const r = store.resumePlan(c.req.param("id"));
5928
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
5929
+ });
5930
+ app.post("/v1/sessions/:id/resume", async (c) => {
5931
+ const b = await c.req.json().catch(() => ({}));
5932
+ const plan = store.resumePlan(c.req.param("id"));
5933
+ if (!plan.ok)
5934
+ return c.json({ ok: false, error: plan.reason }, 404);
5935
+ const r = await runner.start({
5936
+ projectId: plan.projectId,
5937
+ task: plan.task,
5938
+ prompt: plan.prompt,
5939
+ owner: b.owner ?? plan.owner ?? "dashboard",
5940
+ model: b.model,
5941
+ permissionMode: b.permissionMode,
5942
+ allowedTools: b.allowedTools,
5943
+ maxTurns: b.maxTurns
5944
+ });
5945
+ return r.ok ? c.json({ ...r, resumedFrom: c.req.param("id") }, 201) : c.json({ ok: false, error: r.reason }, 409);
5946
+ });
4374
5947
  app.post("/v1/sessions/:id/tail", (c) => c.json({ turns: store.tailSession(c.req.param("id")) }));
4375
5948
  app.post("/v1/hook/:event", async (c) => {
4376
5949
  const event = c.req.param("event");
4377
5950
  const raw2 = await c.req.json().catch(() => ({}));
4378
5951
  store.ingestHook(event, raw2);
5952
+ if (event === "SessionStart" && typeof raw2.cwd === "string") {
5953
+ const ctx = store.sessionContext(raw2.cwd);
5954
+ if (ctx)
5955
+ return c.json({
5956
+ additionalContext: ctx,
5957
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
5958
+ });
5959
+ }
4379
5960
  if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
4380
5961
  const guard = store.guardHook(raw2);
4381
5962
  if (guard) {
@@ -4416,23 +5997,23 @@ function createApp(store = new Store) {
4416
5997
  });
4417
5998
  });
4418
5999
  });
4419
- app.get("/", (c) => c.html(readFileSync4(join6(WEB_DIR, "index.html"), "utf8")));
6000
+ app.get("/", (c) => c.html(readFileSync4(join7(WEB_DIR, "index.html"), "utf8")));
4420
6001
  const MIME = { js: "text/javascript", css: "text/css" };
4421
6002
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
4422
6003
  const f = c.req.param("file");
4423
- const p = join6(WEB_DIR, f);
6004
+ const p = join7(WEB_DIR, f);
4424
6005
  if (!existsSync5(p))
4425
6006
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
4426
6007
  return c.body(readFileSync4(p, "utf8"), 200, {
4427
6008
  "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
4428
6009
  });
4429
6010
  });
4430
- return { app, store, forge: forge2 };
6011
+ return { app, store, forge: forge2, runner };
4431
6012
  }
4432
6013
 
4433
6014
  // packages/daemon/src/bin.ts
4434
6015
  var DEFAULT_PORT2 = process.env.SWARM_PORT ? DEFAULT_PORT : loadConfig().daemon.port;
4435
- var { app, store } = createApp();
6016
+ var { app, store, runner } = createApp();
4436
6017
  function serve() {
4437
6018
  const bind = (p) => Bun.serve({ port: p, hostname: "127.0.0.1", idleTimeout: 0, fetch: app.fetch });
4438
6019
  try {
@@ -4461,6 +6042,8 @@ var tailer = setInterval(() => {
4461
6042
  }
4462
6043
  store.reapResources();
4463
6044
  store.reapProcesses();
6045
+ if (tick % 12 === 0)
6046
+ store.sweepOrphans();
4464
6047
  }, 5000);
4465
6048
  store.refreshAllWorktrees();
4466
6049
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);
@@ -4469,11 +6052,16 @@ var pruner = setInterval(() => store.prune(), 24 * 60 * 60000);
4469
6052
  if (process.env.SWARM_OFFLINE !== "1")
4470
6053
  store.refreshPricing().catch(() => {});
4471
6054
  console.log(`swarmd ${VERSION} listening on http://127.0.0.1:${port}`);
4472
- function shutdown() {
6055
+ var stopping = false;
6056
+ async function shutdown() {
6057
+ if (stopping)
6058
+ return;
6059
+ stopping = true;
4473
6060
  clearInterval(tailer);
4474
6061
  clearInterval(wtRefresh);
4475
6062
  clearInterval(pruner);
4476
6063
  clearDaemonInfo();
6064
+ await Promise.race([runner.stopAll(), Bun.sleep(6000)]);
4477
6065
  server.stop(true);
4478
6066
  process.exit(0);
4479
6067
  }