@ra3orblade/swarm 0.5.0 → 0.7.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
@@ -322,13 +322,96 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
322
322
  payload.prompt = raw.prompt;
323
323
  return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
324
324
  }
325
+ // packages/core/src/budget.ts
326
+ function budgetStatus(spent, cfg) {
327
+ const part = (s, l) => ({
328
+ spent: s,
329
+ limit: l,
330
+ pct: l && l > 0 ? s / l : 0
331
+ });
332
+ const daily = part(spent.today, cfg.daily);
333
+ const weekly = part(spent.week, cfg.weekly);
334
+ const candidates = [
335
+ ["daily", daily],
336
+ ["weekly", weekly]
337
+ ];
338
+ let kind = null;
339
+ let top = { spent: 0, limit: null, pct: 0 };
340
+ for (const [k, v] of candidates)
341
+ if (v.limit && v.pct >= top.pct)
342
+ ({ kind, top } = { kind: k, top: v });
343
+ const level = !kind ? "ok" : top.pct >= 1 ? "exceeded" : top.pct >= cfg.warn_at ? "warn" : "ok";
344
+ return { level, kind, spent: top.spent, limit: top.limit, pct: top.pct, daily, weekly };
345
+ }
346
+ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
347
+ function budgetMessage(s, project) {
348
+ const usd = (n) => `$${n.toFixed(2)}`;
349
+ if (s.level === "ok" || !s.limit)
350
+ return `${project}: within budget`;
351
+ return `${project} has spent ${usd(s.spent)} of its ${usd(s.limit)} ${s.kind} budget (${Math.round(s.pct * 100)}%)`;
352
+ }
353
+ var RUN_PROFILES = {
354
+ full: {
355
+ name: "full",
356
+ description: "every tool, rules still apply",
357
+ disallowedTools: [],
358
+ allowedTools: []
359
+ },
360
+ "no-edits": {
361
+ name: "no-edits",
362
+ description: "may run commands, may not edit files (review, triage, test runs)",
363
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
364
+ allowedTools: []
365
+ },
366
+ "read-only": {
367
+ name: "read-only",
368
+ description: "read and search only \u2014 no edits, no shell",
369
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"],
370
+ allowedTools: ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]
371
+ }
372
+ };
373
+ function runProfile(name) {
374
+ if (!name)
375
+ return null;
376
+ return RUN_PROFILES[name] ?? null;
377
+ }
325
378
  // packages/core/src/config.ts
326
379
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
327
380
  import { join as join2 } from "path";
381
+ var DEFAULT_GATE_TIMEOUT_S = 900;
382
+ var AUTO_MODES = ["session-end", "stop", "off"];
383
+ function parseGateDefs(gates) {
384
+ const out = {};
385
+ if (!isRecord(gates))
386
+ return out;
387
+ for (const [name, v] of Object.entries(gates)) {
388
+ if (!isRecord(v) || typeof v.cmd !== "string" || !v.cmd.trim())
389
+ continue;
390
+ if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name))
391
+ continue;
392
+ const t = Number(v.timeout);
393
+ out[name] = {
394
+ cmd: v.cmd.trim(),
395
+ timeout: Number.isFinite(t) && t > 0 ? Math.min(t, 86400) : DEFAULT_GATE_TIMEOUT_S,
396
+ cwd: isRepoRelative(v.cwd) ? v.cwd.trim() : null
397
+ };
398
+ }
399
+ return out;
400
+ }
328
401
  var DEFAULT_CONFIG = {
329
402
  daemon: { port: 7777 },
330
- tasks: { source: null },
331
- gates: { required: [] },
403
+ tasks: { source: null, labels: [], team: null },
404
+ gates: { required: [], auto: "session-end", defs: {} },
405
+ budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
406
+ dispatch: {
407
+ max_parallel: 2,
408
+ permission_mode: null,
409
+ model: null,
410
+ max_turns: null,
411
+ require_pr: true,
412
+ profile: null
413
+ },
414
+ worktree: { setup: null, copy: [], open: null },
332
415
  rules: {
333
416
  shared_tree: "ask",
334
417
  destructive_git: "ask",
@@ -359,15 +442,63 @@ function parseToml(text, source) {
359
442
  return {};
360
443
  }
361
444
  }
445
+ function isRepoRelative(f) {
446
+ if (typeof f !== "string")
447
+ return false;
448
+ const t = f.trim();
449
+ if (!t || t.startsWith("/") || t.startsWith("\\") || /^[a-zA-Z]:/.test(t))
450
+ return false;
451
+ return !t.split(/[/\\]/).some((seg) => seg === "..");
452
+ }
362
453
  function validate(c) {
363
454
  const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
364
455
  const port = Number(c.daemon?.port);
365
456
  const source = c.tasks?.source;
457
+ const setup = c.worktree?.setup;
458
+ const opener = c.worktree?.open;
459
+ const rawGates = c.gates;
460
+ const d = c.dispatch ?? {};
461
+ const mp = Number(d.max_parallel);
462
+ const mt = Number(d.max_turns);
463
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
464
+ const b = c.budget ?? {};
465
+ const usd = (v) => {
466
+ const n = Number(v);
467
+ return Number.isFinite(n) && n > 0 ? n : null;
468
+ };
469
+ const warnAt = Number(b.warn_at);
470
+ const auto = rawGates?.auto;
366
471
  return {
367
472
  ...c,
368
473
  daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
369
474
  tasks: {
370
- source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null
475
+ source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null,
476
+ labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
477
+ team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
478
+ },
479
+ gates: {
480
+ required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
481
+ auto: AUTO_MODES.includes(auto) ? auto : "session-end",
482
+ defs: parseGateDefs(rawGates)
483
+ },
484
+ budget: {
485
+ daily: usd(b.daily),
486
+ weekly: usd(b.weekly),
487
+ warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
488
+ on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
489
+ },
490
+ dispatch: {
491
+ max_parallel: Number.isInteger(mp) && mp > 0 ? Math.min(mp, 16) : 2,
492
+ permission_mode: str(d.permission_mode),
493
+ model: str(d.model),
494
+ max_turns: Number.isInteger(mt) && mt > 0 ? mt : null,
495
+ require_pr: d.require_pr === undefined ? true : d.require_pr === true,
496
+ profile: ["full", "no-edits", "read-only"].includes(String(d.profile)) ? String(d.profile) : null
497
+ },
498
+ worktree: {
499
+ setup: typeof setup === "string" && setup.trim() ? setup.trim() : null,
500
+ copy: Array.isArray(c.worktree?.copy) ? c.worktree.copy.filter((f) => isRepoRelative(f)) : [],
501
+ open: typeof opener === "string" && opener.trim() ? opener.trim() : null
371
502
  },
372
503
  rules: {
373
504
  ...c.rules,
@@ -396,6 +527,315 @@ function loadConfig(opts = {}) {
396
527
  }
397
528
  return validate(cfg);
398
529
  }
530
+ // packages/core/src/dispatch.ts
531
+ function planDispatch(tasks, wanted, opts) {
532
+ const byId = new Map(tasks.map((t) => [t.id, t]));
533
+ const queued = new Set(opts.alreadyQueued ?? []);
534
+ const rejected = [];
535
+ const picked = [];
536
+ const ids = wanted === "ready" ? tasks.filter((t) => t.ready).map((t) => t.id) : wanted;
537
+ for (const id of ids) {
538
+ const t = byId.get(id);
539
+ if (!t)
540
+ rejected.push({ id, reason: "not in the task source" });
541
+ else if (queued.has(id))
542
+ rejected.push({ id, reason: "already queued" });
543
+ else if (t.claimedBy)
544
+ rejected.push({ id, reason: `held by ${t.claimedBy}` });
545
+ else if (t.status === "done")
546
+ rejected.push({ id, reason: "already done" });
547
+ else if (!t.ready)
548
+ rejected.push({
549
+ id,
550
+ reason: t.status === "active" ? "in progress" : "blocked by dependencies"
551
+ });
552
+ else if (picked.some((p) => p.id === id))
553
+ rejected.push({ id, reason: "listed twice" });
554
+ else
555
+ picked.push(t);
556
+ }
557
+ const limit = opts.max && opts.max > 0 ? picked.slice(0, opts.max) : picked;
558
+ for (const t of picked.slice(limit.length))
559
+ rejected.push({ id: t.id, reason: `beyond --max ${opts.max}` });
560
+ const slots = Math.max(0, opts.maxParallel - opts.running);
561
+ return { start: limit.slice(0, slots), queued: limit.slice(slots), rejected };
562
+ }
563
+ function taskPrompt(task, ctx = {
564
+ requiredGates: [],
565
+ executableGates: [],
566
+ openPr: true
567
+ }) {
568
+ const manual = ctx.requiredGates.filter((g) => !ctx.executableGates.includes(g));
569
+ const exec = ctx.requiredGates.filter((g) => ctx.executableGates.includes(g));
570
+ const steps = [
571
+ "Work only inside this worktree; never touch the main checkout or another worktree.",
572
+ "Commit as you go with clear messages. Do not edit the task list or flip the task's status \u2014 Swarm derives it.",
573
+ exec.length ? `Run the executable gates with swarm_gate_run (${exec.join(", ")}) and fix what fails.` : null,
574
+ manual.length ? `Record the remaining required gates with swarm_gate_record and an honest rubric (${manual.join(", ")}).` : null,
575
+ "Call swarm_handoff with what was done, what remains, the files touched and how to verify.",
576
+ ctx.openPr ? "Then push and open the pull request with swarm_pr_open." : "Push the branch.",
577
+ "If you are blocked on a decision only a human can make, say so in the handoff and stop."
578
+ ].filter(Boolean);
579
+ return `Task ${task.id}: ${task.title}
580
+
581
+ ${steps.map((s, i) => `${i + 1}. ${s}`).join(`
582
+ `)}`;
583
+ }
584
+ function dispatchOutcome(facts) {
585
+ if (facts.stopped)
586
+ return "stopped";
587
+ if (facts.exitCode !== 0 || facts.isError)
588
+ return "crashed";
589
+ if (!facts.gatesSatisfied)
590
+ return "gates-failed";
591
+ if (facts.requirePr && !facts.prOpen)
592
+ return "no-pr";
593
+ return "done";
594
+ }
595
+ // packages/core/src/rules.ts
596
+ var LIVE_WINDOW_MS = 10 * 60000;
597
+ function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
598
+ if (!current.toplevel)
599
+ return null;
600
+ for (const s of sessions) {
601
+ if (s.id === current.id)
602
+ continue;
603
+ if (s.state === "ended")
604
+ continue;
605
+ if (s.toplevel !== current.toplevel)
606
+ continue;
607
+ if (now - new Date(s.lastSeenAt).getTime() > withinMs)
608
+ continue;
609
+ return s;
610
+ }
611
+ return null;
612
+ }
613
+ function isBroadStage(cmd) {
614
+ const c = cmd.trim();
615
+ if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
616
+ return true;
617
+ if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
618
+ return true;
619
+ if (/\bgit\s+add\s*$/.test(c))
620
+ return true;
621
+ return false;
622
+ }
623
+ function isDestructiveGit(cmd) {
624
+ const c = cmd.trim();
625
+ 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);
626
+ }
627
+ function isPatternKill(cmd) {
628
+ return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
629
+ }
630
+ function killedPorts(cmd) {
631
+ const ports = new Set;
632
+ const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
633
+ if (!killy)
634
+ return [];
635
+ for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
636
+ ports.add(Number(m[1]));
637
+ for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
638
+ ports.add(Number(m[1]));
639
+ for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
640
+ ports.add(Number(m[1]));
641
+ return [...ports];
642
+ }
643
+ var DEFAULT_MODES = {
644
+ shared_tree: "ask",
645
+ destructive_git: "ask",
646
+ pattern_kill: "ask",
647
+ protected_ports: "ask",
648
+ no_foreign_worktree: "ask",
649
+ claim_required_to_write: "off",
650
+ protected: { ports: [] }
651
+ };
652
+ function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
653
+ const other = () => otherLiveInSameTree(current, sessions, now);
654
+ const hit = (rule, reason) => {
655
+ const mode = modes[rule];
656
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
657
+ };
658
+ if (modes.protected_ports !== "off" && modes.protected.ports.length) {
659
+ const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
660
+ if (target.length) {
661
+ 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.`);
662
+ if (d.action !== "allow")
663
+ return d;
664
+ }
665
+ }
666
+ if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
667
+ 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.");
668
+ if (d.action !== "allow")
669
+ return d;
670
+ }
671
+ if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
672
+ const o = other();
673
+ if (o) {
674
+ 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.`);
675
+ if (d.action !== "allow")
676
+ return d;
677
+ }
678
+ }
679
+ if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
680
+ const o = other();
681
+ if (o) {
682
+ 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.`);
683
+ if (d.action !== "allow")
684
+ return d;
685
+ }
686
+ }
687
+ return { action: "allow" };
688
+ }
689
+ function norm(p) {
690
+ const parts = [];
691
+ for (const seg of p.split("/")) {
692
+ if (seg === "" || seg === ".")
693
+ continue;
694
+ if (seg === "..")
695
+ parts.pop();
696
+ else
697
+ parts.push(seg);
698
+ }
699
+ return `/${parts.join("/")}`;
700
+ }
701
+ function isInside(path, dir) {
702
+ if (!path || !dir)
703
+ return false;
704
+ const a = norm(path);
705
+ const d = norm(dir);
706
+ return a === d || a.startsWith(`${d}/`);
707
+ }
708
+ function absolutePath(path, cwd) {
709
+ if (path.startsWith("/"))
710
+ return path;
711
+ if (path.startsWith("~/"))
712
+ return path;
713
+ return `${cwd.replace(/\/+$/, "")}/${path}`;
714
+ }
715
+ var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
716
+ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
717
+ const hit = (rule, reason) => {
718
+ const mode = modes[rule];
719
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
720
+ };
721
+ const held = claims.filter((c) => c.worktree);
722
+ const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
723
+ if (modes.no_foreign_worktree !== "off") {
724
+ const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
725
+ if (foreign) {
726
+ 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.`);
727
+ if (d.action !== "allow")
728
+ return d;
729
+ }
730
+ }
731
+ if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
732
+ const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
733
+ if (inShared) {
734
+ 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.`);
735
+ if (d.action !== "allow")
736
+ return d;
737
+ }
738
+ }
739
+ return { action: "allow" };
740
+ }
741
+
742
+ // packages/core/src/dryrun.ts
743
+ var RULE_IDS = [
744
+ "pattern_kill",
745
+ "shared_tree",
746
+ "destructive_git",
747
+ "protected_ports",
748
+ "no_foreign_worktree",
749
+ "claim_required_to_write"
750
+ ];
751
+ function normalizeDisplay(s) {
752
+ return s.replace(/\s+/g, " ").trim().slice(0, 160);
753
+ }
754
+ function dryRunRules(calls, modes, ctx) {
755
+ const claims = ctx.claims ?? [];
756
+ const minRepeat = ctx.minRepeat ?? 3;
757
+ const maxHits = ctx.maxHits ?? 200;
758
+ const live = new Map;
759
+ const byRule = Object.fromEntries(RULE_IDS.map((r) => [r, { ask: 0, deny: 0 }]));
760
+ const hits = [];
761
+ const groups = new Map;
762
+ let evaluated = 0;
763
+ const sorted = [...calls].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0);
764
+ const writeRules = modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off";
765
+ for (const c of sorted) {
766
+ const toplevel = ctx.toplevel(c.cwd);
767
+ live.set(c.sessionId, { id: c.sessionId, toplevel, lastSeenAt: c.ts, state: "active" });
768
+ const now = new Date(c.ts).getTime();
769
+ const current = { id: c.sessionId, cwd: c.cwd, toplevel };
770
+ let d = { action: "allow" };
771
+ let display = c.tool;
772
+ const isWrite = WRITE_TOOLS.has(c.tool) && typeof c.filePath === "string";
773
+ if (isWrite) {
774
+ const target = absolutePath(c.filePath, c.cwd);
775
+ display = `${c.tool} ${target}`;
776
+ evaluated++;
777
+ if (writeRules)
778
+ d = guardWrite(target, current, claims, modes, "file");
779
+ } else if (c.tool === "Bash" && c.command) {
780
+ display = c.command;
781
+ evaluated++;
782
+ if (writeRules)
783
+ d = guardWrite(c.cwd, current, claims, modes, "bash");
784
+ if (d.action === "allow") {
785
+ const sessions = [...live.values()].filter((s) => now - new Date(s.lastSeenAt).getTime() <= LIVE_WINDOW_MS);
786
+ d = guardBash(c.command, current, sessions, now, modes);
787
+ }
788
+ } else
789
+ continue;
790
+ if (d.action === "allow")
791
+ continue;
792
+ byRule[d.rule][d.action]++;
793
+ const norm2 = normalizeDisplay(display);
794
+ if (hits.length < maxHits)
795
+ hits.push({
796
+ ts: c.ts,
797
+ sessionId: c.sessionId,
798
+ rule: d.rule,
799
+ action: d.action,
800
+ display: norm2,
801
+ completed: c.completed
802
+ });
803
+ const key = `${d.rule} ${norm2}`;
804
+ const g = groups.get(key) ?? {
805
+ rule: d.rule,
806
+ display: norm2,
807
+ fires: 0,
808
+ completedRatio: 0,
809
+ sessions: 0,
810
+ suggestion: "",
811
+ done: 0,
812
+ sids: new Set
813
+ };
814
+ g.fires++;
815
+ if (c.completed)
816
+ g.done++;
817
+ g.sids.add(c.sessionId);
818
+ groups.set(key, g);
819
+ }
820
+ const flaky = [];
821
+ for (const g of groups.values()) {
822
+ if (g.fires < minRepeat)
823
+ continue;
824
+ const ratio = g.done / g.fires;
825
+ if (ratio < 0.8)
826
+ continue;
827
+ flaky.push({
828
+ rule: g.rule,
829
+ display: g.display,
830
+ fires: g.fires,
831
+ completedRatio: Math.round(ratio * 100) / 100,
832
+ sessions: g.sids.size,
833
+ 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.`
834
+ });
835
+ }
836
+ flaky.sort((a, b) => b.fires - a.fires);
837
+ return { calls: calls.length, evaluated, hits, byRule, flaky };
838
+ }
399
839
  // packages/core/src/forge.ts
400
840
  function parseRemote(url) {
401
841
  const m = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/) ?? url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
@@ -460,6 +900,68 @@ function normalizeGitlab(raw, repo) {
460
900
  };
461
901
  });
462
902
  }
903
+ function parseNumstat(numstat, nameStatus = "") {
904
+ const status = new Map;
905
+ for (const line of nameStatus.split(`
906
+ `)) {
907
+ const [st, ...rest] = line.split("\t");
908
+ if (!st || !rest.length)
909
+ continue;
910
+ status.set(rest[rest.length - 1], st[0]);
911
+ }
912
+ const out = [];
913
+ for (const line of numstat.split(`
914
+ `)) {
915
+ const [a, d, ...rest] = line.split("\t");
916
+ if (a === undefined || d === undefined || !rest.length)
917
+ continue;
918
+ const raw = rest.join("\t");
919
+ const path = raw.includes(" => ") ? raw.replace(/\{?([^{}]*) => ([^{}]*)\}?/, "$2") : raw;
920
+ out.push({
921
+ path,
922
+ added: a === "-" ? -1 : Number(a),
923
+ deleted: d === "-" ? -1 : Number(d),
924
+ status: status.get(path) ?? "M"
925
+ });
926
+ }
927
+ return out;
928
+ }
929
+ function prDraft(i) {
930
+ const title = ((i.title?.trim()) ? `${i.task}: ${i.title.trim()}` : i.task).slice(0, 120);
931
+ const b = [];
932
+ b.push("## Summary");
933
+ if (i.handoff?.done.trim())
934
+ b.push(i.handoff.done.trim());
935
+ else if (i.commits?.length)
936
+ b.push(i.commits.map((c) => `- ${c}`).join(`
937
+ `));
938
+ else
939
+ b.push(`Work on ${i.task}.`);
940
+ if (i.handoff?.remaining.trim() && !/^(nothing|none|\u2014|-)\.?$/i.test(i.handoff.remaining.trim()))
941
+ b.push(`
942
+ ## Remaining
943
+ ${i.handoff.remaining.trim()}`);
944
+ if (i.gates?.length) {
945
+ b.push(`
946
+ ## Gates`);
947
+ b.push(i.gates.map((g) => `- ${g.verdict === "pass" ? "[x]" : "[ ]"} ${g.gate}${g.verdict === "fail" ? " \u2014 failed" : g.verdict ? "" : " \u2014 not run"}`).join(`
948
+ `));
949
+ }
950
+ if (i.handoff?.verify?.trim())
951
+ b.push(`
952
+ ## Verify
953
+ ${i.handoff.verify.trim()}`);
954
+ if (i.files?.length) {
955
+ const shown = i.files.slice(0, 30);
956
+ b.push(`
957
+ ## Files (${i.files.length})
958
+ ${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.deleted}` : " (binary)"}`).join(`
959
+ `)}${i.files.length > shown.length ? `
960
+ - \u2026 ${i.files.length - shown.length} more` : ""}`);
961
+ }
962
+ return { title, body: b.join(`
963
+ `) };
964
+ }
463
965
  // packages/core/src/gates.ts
464
966
  var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
465
967
  function validateGateRun(input) {
@@ -503,6 +1005,25 @@ function gatesSatisfied(runs, declared) {
503
1005
  const st = gateStatus(runs, declared);
504
1006
  return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
505
1007
  }
1008
+ function evidenceTail(output, max = 2000) {
1009
+ const t = output.trimEnd();
1010
+ if (t.length <= max)
1011
+ return t;
1012
+ const cut = t.slice(-max);
1013
+ const nl = cut.indexOf(`
1014
+ `);
1015
+ return `\u2026${nl >= 0 && nl < 200 ? cut.slice(nl + 1) : cut}`;
1016
+ }
1017
+ function executedGateInput(task, gate, cmd, outcome) {
1018
+ const how = outcome.timedOut === true ? "timed out" : outcome.exitCode === null ? "could not start" : `exit ${outcome.exitCode}`;
1019
+ return {
1020
+ task,
1021
+ gate,
1022
+ verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
1023
+ rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
1024
+ evidence: evidenceTail(outcome.output) || null
1025
+ };
1026
+ }
506
1027
  // packages/core/src/ledger.ts
507
1028
  var DEFAULT_LEASE_MINUTES = 45;
508
1029
  function isExpired(claim, now) {
@@ -576,51 +1097,282 @@ function formatHandoff(h) {
576
1097
  return lines.join(`
577
1098
  `);
578
1099
  }
579
- // packages/core/src/pricing.ts
580
- var PRICES = {
581
- "claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
582
- "claude-opus-4-5": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
583
- "claude-opus-4-6": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
584
- "claude-sonnet-4": { input: 3, output: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
585
- "claude-haiku-4-5": { input: 1, output: 5, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
586
- "claude-3-5-haiku": { input: 0.8, output: 4, cacheWrite: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
587
- "claude-opus-5": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
588
- "claude-sonnet-5": { input: 2, output: 10, cacheWrite: 2.5, cacheWrite1h: 4, cacheRead: 0.2 },
589
- "claude-fable-5": { input: 10, output: 50, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 1 },
590
- "gpt-4o": { input: 2.5, output: 10, cacheWrite: 2.5, cacheRead: 1.25 },
591
- "gpt-4o-mini": { input: 0.15, output: 0.6, cacheWrite: 0.15, cacheRead: 0.075 },
592
- "gpt-4.1": { input: 2, output: 8, cacheWrite: 2, cacheRead: 0.5 },
593
- "gpt-4.1-mini": { input: 0.4, output: 1.6, cacheWrite: 0.4, cacheRead: 0.1 },
594
- "gpt-5": { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
595
- o3: { input: 2, output: 8, cacheWrite: 2, cacheRead: 0.5 },
596
- "o4-mini": { input: 1.1, output: 4.4, cacheWrite: 1.1, cacheRead: 0.275 },
597
- "gemini-2.5-pro": { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
598
- "gemini-2.5-flash": { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
599
- "deepseek-chat": { input: 0.27, output: 1.1, cacheWrite: 0.27, cacheRead: 0.07 },
600
- "deepseek-reasoner": { input: 0.55, output: 2.19, cacheWrite: 0.55, cacheRead: 0.14 },
601
- "grok-4": { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
602
- "grok-3": { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
603
- "grok-code-fast": { input: 0.2, output: 1.5, cacheWrite: 0.2, cacheRead: 0.02 },
604
- "grok-composer": { input: 0.2, output: 1.5, cacheWrite: 0.2, cacheRead: 0.02 }
605
- };
606
- function priceFor(model, table = PRICES) {
607
- if (!model)
608
- return null;
609
- const m = model.toLowerCase();
610
- let best = null;
611
- for (const k of Object.keys(table)) {
612
- if (m.startsWith(k) && (!best || k.length > best.length))
613
- best = k;
1100
+ var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
1101
+ 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/;
1102
+ function deriveHandoff(task, ev, opts = {}) {
1103
+ const files = [];
1104
+ let verify = null;
1105
+ let lastPrompt = null;
1106
+ for (const e of ev) {
1107
+ const p = e.payload ?? {};
1108
+ if (e.type === "tool.requested" && p.tool) {
1109
+ const arg = (p.summary ?? "").slice(p.tool.length).trim();
1110
+ if (EDIT_TOOLS.has(p.tool) && arg && !files.includes(arg))
1111
+ files.push(arg);
1112
+ if (p.tool === "Bash") {
1113
+ if (arg && VERIFY_RE.test(arg))
1114
+ verify = arg;
1115
+ }
1116
+ } else if (p.hook === "UserPromptSubmit" && (p.prompt ?? p.summary)) {
1117
+ lastPrompt = (p.prompt ?? p.summary ?? "").trim().split(`
1118
+ `)[0]?.slice(0, 200) ?? null;
1119
+ }
614
1120
  }
615
- return best ? table[best] ?? null : null;
616
- }
617
- function costUsd(model, u, table = PRICES) {
618
- const p = priceFor(model, table);
619
- if (!p)
1121
+ const said = (opts.lastText ?? "").trim().replace(/\s+/g, " ").slice(0, 600);
1122
+ if (!files.length && !said)
620
1123
  return null;
621
- const w5 = u.cacheWrite - (u.cacheWrite1h ?? 0);
622
- return (u.input * p.input + u.output * p.output + w5 * p.cacheWrite + (u.cacheWrite1h ?? 0) * (p.cacheWrite1h ?? p.cacheWrite) + u.cacheRead * p.cacheRead) / 1e6;
623
- }
1124
+ const done = said || `edited ${files.length} file${files.length === 1 ? "" : "s"} (no summary)`;
1125
+ 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.";
1126
+ return {
1127
+ task,
1128
+ done,
1129
+ remaining,
1130
+ files: files.slice(-30),
1131
+ verify,
1132
+ by: `auto${opts.sessionId ? `:${opts.sessionId.slice(0, 8)}` : ""}`,
1133
+ createdAt: opts.now ?? new Date().toISOString()
1134
+ };
1135
+ }
1136
+ function isAutoHandoff(h) {
1137
+ return h.by === "auto" || (h.by?.startsWith("auto:") ?? false);
1138
+ }
1139
+ function formatResumePrompt(h, tail) {
1140
+ const out = [
1141
+ `You are resuming ${h.task}; the previous session on it stopped without finishing.`,
1142
+ "",
1143
+ formatHandoff(h)
1144
+ ];
1145
+ if (tail.length)
1146
+ out.push("", "Its last actions, oldest first:", ...tail.map((t) => ` - ${t}`));
1147
+ 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.");
1148
+ return out.join(`
1149
+ `);
1150
+ }
1151
+ // packages/core/src/lessons.ts
1152
+ function portsIn(cmd) {
1153
+ const ports = new Set;
1154
+ for (const m of cmd.matchAll(/(?::|-i\s*:?|kill-port\s+|fuser\s+-[a-z]*k\s+)(\d{2,5})\b/g))
1155
+ ports.add(Number(m[1]));
1156
+ return [...ports];
1157
+ }
1158
+ var RECURRING = 3;
1159
+ function suggestFromIncident(inc) {
1160
+ const n = inc.count ?? 1;
1161
+ switch (inc.rule) {
1162
+ case "protected_ports": {
1163
+ const ports = portsIn(inc.command);
1164
+ return {
1165
+ title: ports.length ? `Protect port${ports.length > 1 ? "s" : ""} ${ports.join(", ")} for good` : "Protect this port",
1166
+ toml: ports.length ? `[rules]
1167
+ protected_ports = "deny"
1168
+
1169
+ [rules.protected]
1170
+ ports = [${ports.join(", ")}]` : null,
1171
+ 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.`
1172
+ };
1173
+ }
1174
+ case "pattern_kill":
1175
+ return {
1176
+ title: n >= RECURRING ? "Deny pattern kills (recurring)" : "Discourage pattern kills",
1177
+ toml: `[rules]
1178
+ pattern_kill = "${n >= RECURRING ? "deny" : "ask"}"`,
1179
+ 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."
1180
+ };
1181
+ case "shared_tree":
1182
+ return {
1183
+ title: "Deny broad staging in a shared checkout",
1184
+ toml: `[rules]
1185
+ shared_tree = "deny"`,
1186
+ 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`."
1187
+ };
1188
+ case "destructive_git":
1189
+ return {
1190
+ title: "Deny destructive git in a shared checkout",
1191
+ toml: `[rules]
1192
+ destructive_git = "deny"`,
1193
+ lesson: "Never run `git reset --hard` / `checkout .` / `clean -f` in a checkout another session shares \u2014 coordinate, or use a separate worktree."
1194
+ };
1195
+ case "no_foreign_worktree":
1196
+ return {
1197
+ title: "Deny writes into others' worktrees",
1198
+ toml: `[rules]
1199
+ no_foreign_worktree = "deny"`,
1200
+ lesson: "Never edit inside a worktree you don't hold \u2014 work in your own checkout, or claim the task first."
1201
+ };
1202
+ case "claim_required_to_write":
1203
+ return {
1204
+ title: "Require a claim before writing",
1205
+ toml: `[rules]
1206
+ claim_required_to_write = "deny"`,
1207
+ lesson: "Claim a task (`swarm claim`) and work in the worktree it creates before editing this repo."
1208
+ };
1209
+ case "orphaned_claim":
1210
+ return {
1211
+ title: "A claim expired with unfinished work",
1212
+ toml: null,
1213
+ lesson: "Finish and push, or `swarm handoff`, before a lease expires \u2014 an orphaned worktree still holds work nobody owns."
1214
+ };
1215
+ case "gate_failed":
1216
+ return {
1217
+ title: "A verification gate failed",
1218
+ toml: null,
1219
+ lesson: `A gate failed here \u2014 ${inc.reason.slice(0, 120)}. Fix it and re-record the gate before marking the task done.`
1220
+ };
1221
+ default:
1222
+ return {
1223
+ title: `Codify the ${inc.rule} intent`,
1224
+ toml: null,
1225
+ lesson: inc.reason.slice(0, 160)
1226
+ };
1227
+ }
1228
+ }
1229
+ function incidentKey(inc) {
1230
+ if (inc.rule === "protected_ports")
1231
+ return `protected_ports:${portsIn(inc.command).join(",")}`;
1232
+ return inc.rule;
1233
+ }
1234
+ // packages/core/src/memory.ts
1235
+ var MEMORY_KINDS = ["handoff", "incident", "gate", "session"];
1236
+ function handoffDoc(projectId, id, h, sessionId) {
1237
+ return {
1238
+ kind: "handoff",
1239
+ ref: String(id),
1240
+ projectId,
1241
+ task: h.task,
1242
+ sessionId,
1243
+ ts: h.createdAt,
1244
+ title: `handoff on ${h.task}${h.by ? ` by ${h.by}` : ""}`,
1245
+ text: [
1246
+ `done: ${h.done}`,
1247
+ `remaining: ${h.remaining}`,
1248
+ h.files.length ? `files: ${h.files.join(" ")}` : "",
1249
+ h.verify ? `verify: ${h.verify}` : ""
1250
+ ].filter(Boolean).join(`
1251
+ `)
1252
+ };
1253
+ }
1254
+ function incidentDoc(projectId, seq, p, ts, sessionId) {
1255
+ return {
1256
+ kind: "incident",
1257
+ ref: String(seq),
1258
+ projectId,
1259
+ task: null,
1260
+ sessionId,
1261
+ ts,
1262
+ title: `${p.action ?? "ask"} \xB7 ${p.rule ?? "rule"}`,
1263
+ text: [p.command ? `command: ${p.command}` : "", p.reason ? `reason: ${p.reason}` : ""].filter(Boolean).join(`
1264
+ `)
1265
+ };
1266
+ }
1267
+ function gateDoc(projectId, id, g, sessionId) {
1268
+ return {
1269
+ kind: "gate",
1270
+ ref: String(id),
1271
+ projectId,
1272
+ task: g.task,
1273
+ sessionId,
1274
+ ts: g.createdAt,
1275
+ title: `${g.gate} ${g.verdict} on ${g.task}`,
1276
+ text: [`rubric: ${g.rubric}`, g.evidence ? `evidence: ${g.evidence}` : ""].filter(Boolean).join(`
1277
+ `)
1278
+ };
1279
+ }
1280
+ function sessionDoc(projectId, s) {
1281
+ const text = (s.lastText ?? "").trim();
1282
+ if (!text)
1283
+ return null;
1284
+ return {
1285
+ kind: "session",
1286
+ ref: s.id,
1287
+ projectId,
1288
+ task: s.task ?? null,
1289
+ sessionId: s.id,
1290
+ ts: s.ts,
1291
+ title: s.title?.trim() || `session ${s.id.slice(0, 8)}`,
1292
+ text: text.slice(0, 4000)
1293
+ };
1294
+ }
1295
+ function parseMemoryQuery(q) {
1296
+ let kind = null;
1297
+ let task = null;
1298
+ const terms = [];
1299
+ const re = /"([^"]+)"|(\S+)/g;
1300
+ for (const m of q.matchAll(re)) {
1301
+ if (m[1] !== undefined) {
1302
+ const phrase = m[1].replace(/"/g, "").trim();
1303
+ if (phrase)
1304
+ terms.push(`"${phrase}"`);
1305
+ continue;
1306
+ }
1307
+ const w = m[2] ?? "";
1308
+ const k = /^kind:(\w+)$/i.exec(w);
1309
+ if (k) {
1310
+ const v = (k[1] ?? "").toLowerCase();
1311
+ if (MEMORY_KINDS.includes(v))
1312
+ kind = v;
1313
+ continue;
1314
+ }
1315
+ const t = /^task:(\S+)$/i.exec(w);
1316
+ if (t) {
1317
+ task = t[1] ?? null;
1318
+ continue;
1319
+ }
1320
+ const clean = w.replace(/"/g, "").replace(/^\*+|\*+$/g, "");
1321
+ if (clean)
1322
+ terms.push(`"${clean}"`);
1323
+ }
1324
+ if (terms.length) {
1325
+ const last = terms[terms.length - 1];
1326
+ if (!/\s/.test(last) && !q.trim().endsWith('"'))
1327
+ terms[terms.length - 1] = `${last}*`;
1328
+ }
1329
+ return { match: terms.join(" "), kind, task };
1330
+ }
1331
+ // packages/core/src/pricing.ts
1332
+ var PRICES = {
1333
+ "claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
1334
+ "claude-opus-4-5": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
1335
+ "claude-opus-4-6": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
1336
+ "claude-sonnet-4": { input: 3, output: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
1337
+ "claude-haiku-4-5": { input: 1, output: 5, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
1338
+ "claude-3-5-haiku": { input: 0.8, output: 4, cacheWrite: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
1339
+ "claude-opus-5": { input: 5, output: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
1340
+ "claude-sonnet-5": { input: 2, output: 10, cacheWrite: 2.5, cacheWrite1h: 4, cacheRead: 0.2 },
1341
+ "claude-fable-5": { input: 10, output: 50, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 1 },
1342
+ "gpt-4o": { input: 2.5, output: 10, cacheWrite: 2.5, cacheRead: 1.25 },
1343
+ "gpt-4o-mini": { input: 0.15, output: 0.6, cacheWrite: 0.15, cacheRead: 0.075 },
1344
+ "gpt-4.1": { input: 2, output: 8, cacheWrite: 2, cacheRead: 0.5 },
1345
+ "gpt-4.1-mini": { input: 0.4, output: 1.6, cacheWrite: 0.4, cacheRead: 0.1 },
1346
+ "gpt-5": { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
1347
+ o3: { input: 2, output: 8, cacheWrite: 2, cacheRead: 0.5 },
1348
+ "o4-mini": { input: 1.1, output: 4.4, cacheWrite: 1.1, cacheRead: 0.275 },
1349
+ "gemini-2.5-pro": { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
1350
+ "gemini-2.5-flash": { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
1351
+ "deepseek-chat": { input: 0.27, output: 1.1, cacheWrite: 0.27, cacheRead: 0.07 },
1352
+ "deepseek-reasoner": { input: 0.55, output: 2.19, cacheWrite: 0.55, cacheRead: 0.14 },
1353
+ "grok-4": { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
1354
+ "grok-3": { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
1355
+ "grok-code-fast": { input: 0.2, output: 1.5, cacheWrite: 0.2, cacheRead: 0.02 },
1356
+ "grok-composer": { input: 0.2, output: 1.5, cacheWrite: 0.2, cacheRead: 0.02 }
1357
+ };
1358
+ function priceFor(model, table = PRICES) {
1359
+ if (!model)
1360
+ return null;
1361
+ const m = model.toLowerCase();
1362
+ let best = null;
1363
+ for (const k of Object.keys(table)) {
1364
+ if (m.startsWith(k) && (!best || k.length > best.length))
1365
+ best = k;
1366
+ }
1367
+ return best ? table[best] ?? null : null;
1368
+ }
1369
+ function costUsd(model, u, table = PRICES) {
1370
+ const p = priceFor(model, table);
1371
+ if (!p)
1372
+ return null;
1373
+ const w5 = u.cacheWrite - (u.cacheWrite1h ?? 0);
1374
+ return (u.input * p.input + u.output * p.output + w5 * p.cacheWrite + (u.cacheWrite1h ?? 0) * (p.cacheWrite1h ?? p.cacheWrite) + u.cacheRead * p.cacheRead) / 1e6;
1375
+ }
624
1376
  function fromLiteLLM(json) {
625
1377
  const out = {};
626
1378
  for (const [k, v] of Object.entries(json)) {
@@ -674,6 +1426,29 @@ function projectIdentity(opts) {
674
1426
  const name = parts[parts.length - 1] ?? opts.root;
675
1427
  return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
676
1428
  }
1429
+ // packages/core/src/questions.ts
1430
+ function validateQuestion(text, options) {
1431
+ const t = typeof text === "string" ? text.trim() : "";
1432
+ if (t.length < 5)
1433
+ return { ok: false, reason: "a question needs at least a few words" };
1434
+ if (t.length > 4000)
1435
+ return { ok: false, reason: "keep the question under 4000 characters" };
1436
+ const opts = Array.isArray(options) ? options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim()).slice(0, 8) : [];
1437
+ return { ok: true, text: t, options: opts };
1438
+ }
1439
+ function formatAnswers(qs) {
1440
+ const answered = qs.filter((q) => q.answer !== null);
1441
+ if (!answered.length)
1442
+ return null;
1443
+ return answered.map((q) => `[swarm] answer from ${q.answeredBy ?? "a human"} to your question "${q.text.slice(0, 200)}": ${q.answer}`).join(`
1444
+ `);
1445
+ }
1446
+ function formatOpenQuestions(qs) {
1447
+ const open = qs.filter((q) => q.answer === null);
1448
+ if (!open.length)
1449
+ return null;
1450
+ return `[swarm] waiting on a human for: ${open.map((q) => `#${q.id} "${q.text.slice(0, 120)}"`).join("; ")} \u2014 the answer arrives as context on a later tool call, or via swarm_inbox`;
1451
+ }
677
1452
  // packages/core/src/resources.ts
678
1453
  var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
679
1454
  function isTrackedPid(pid) {
@@ -699,152 +1474,6 @@ function acquireRefusalMessage(holder) {
699
1474
  const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
700
1475
  return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
701
1476
  }
702
- // packages/core/src/rules.ts
703
- var LIVE_WINDOW_MS = 10 * 60000;
704
- function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
705
- if (!current.toplevel)
706
- return null;
707
- for (const s of sessions) {
708
- if (s.id === current.id)
709
- continue;
710
- if (s.state === "ended")
711
- continue;
712
- if (s.toplevel !== current.toplevel)
713
- continue;
714
- if (now - new Date(s.lastSeenAt).getTime() > withinMs)
715
- continue;
716
- return s;
717
- }
718
- return null;
719
- }
720
- function isBroadStage(cmd) {
721
- const c = cmd.trim();
722
- if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
723
- return true;
724
- if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
725
- return true;
726
- if (/\bgit\s+add\s*$/.test(c))
727
- return true;
728
- return false;
729
- }
730
- function isDestructiveGit(cmd) {
731
- const c = cmd.trim();
732
- 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);
733
- }
734
- function isPatternKill(cmd) {
735
- return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
736
- }
737
- function killedPorts(cmd) {
738
- const ports = new Set;
739
- const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
740
- if (!killy)
741
- return [];
742
- for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
743
- ports.add(Number(m[1]));
744
- for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
745
- ports.add(Number(m[1]));
746
- for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
747
- ports.add(Number(m[1]));
748
- return [...ports];
749
- }
750
- var DEFAULT_MODES = {
751
- shared_tree: "ask",
752
- destructive_git: "ask",
753
- pattern_kill: "ask",
754
- protected_ports: "ask",
755
- no_foreign_worktree: "ask",
756
- claim_required_to_write: "off",
757
- protected: { ports: [] }
758
- };
759
- function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
760
- const other = () => otherLiveInSameTree(current, sessions, now);
761
- const hit = (rule, reason) => {
762
- const mode = modes[rule];
763
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
764
- };
765
- if (modes.protected_ports !== "off" && modes.protected.ports.length) {
766
- const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
767
- if (target.length) {
768
- 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.`);
769
- if (d.action !== "allow")
770
- return d;
771
- }
772
- }
773
- if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
774
- 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.");
775
- if (d.action !== "allow")
776
- return d;
777
- }
778
- if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
779
- const o = other();
780
- if (o) {
781
- 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.`);
782
- if (d.action !== "allow")
783
- return d;
784
- }
785
- }
786
- if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
787
- const o = other();
788
- if (o) {
789
- 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.`);
790
- if (d.action !== "allow")
791
- return d;
792
- }
793
- }
794
- return { action: "allow" };
795
- }
796
- function norm(p) {
797
- const parts = [];
798
- for (const seg of p.split("/")) {
799
- if (seg === "" || seg === ".")
800
- continue;
801
- if (seg === "..")
802
- parts.pop();
803
- else
804
- parts.push(seg);
805
- }
806
- return `/${parts.join("/")}`;
807
- }
808
- function isInside(path, dir) {
809
- if (!path || !dir)
810
- return false;
811
- const a = norm(path);
812
- const d = norm(dir);
813
- return a === d || a.startsWith(`${d}/`);
814
- }
815
- function absolutePath(path, cwd) {
816
- if (path.startsWith("/"))
817
- return path;
818
- if (path.startsWith("~/"))
819
- return path;
820
- return `${cwd.replace(/\/+$/, "")}/${path}`;
821
- }
822
- var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
823
- function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
824
- const hit = (rule, reason) => {
825
- const mode = modes[rule];
826
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
827
- };
828
- const held = claims.filter((c) => c.worktree);
829
- const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
830
- if (modes.no_foreign_worktree !== "off") {
831
- const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
832
- if (foreign) {
833
- 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.`);
834
- if (d.action !== "allow")
835
- return d;
836
- }
837
- }
838
- if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
839
- const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
840
- if (inShared) {
841
- 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.`);
842
- if (d.action !== "allow")
843
- return d;
844
- }
845
- }
846
- return { action: "allow" };
847
- }
848
1477
  // packages/core/src/tasks.ts
849
1478
  var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
850
1479
  var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
@@ -945,18 +1574,150 @@ function taskBoard(tasks, activeClaims) {
945
1574
  };
946
1575
  });
947
1576
  }
948
- // packages/daemon/src/app.ts
949
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
1577
+ var TASK_SOURCE_KINDS = ["github", "linear"];
1578
+ function taskSourceKind(source) {
1579
+ if (!source)
1580
+ return null;
1581
+ return TASK_SOURCE_KINDS.includes(source) ? source : "markdown";
1582
+ }
1583
+ var ACTIVE_LABEL_RE = /^(in[- ]progress|wip|doing|active|started)$/i;
1584
+ var GH_DEP_RE = /\b(?:depends on|blocked by|after|requires)\b[^\n.]*?((?:#\d+[,\s]*(?:and)?\s*)+)/gi;
1585
+ function normalizeGithubIssues(issues) {
1586
+ return issues.filter((i) => Number.isInteger(i.number) && typeof i.title === "string").sort((a, b) => a.number - b.number).map((i) => {
1587
+ const labels = (i.labels ?? []).map((l) => l.name);
1588
+ const closed = (i.state ?? "").toUpperCase() === "CLOSED";
1589
+ const active = !closed && labels.some((l) => ACTIVE_LABEL_RE.test(l));
1590
+ const depends = [];
1591
+ for (const m of (i.body ?? "").matchAll(GH_DEP_RE))
1592
+ for (const n of (m[1] ?? "").matchAll(/#(\d+)/g)) {
1593
+ const id = `GH-${n[1]}`;
1594
+ if (!depends.includes(id))
1595
+ depends.push(id);
1596
+ }
1597
+ const statusText = closed ? "closed" : active ? `in progress${i.assignees?.length ? ` (${i.assignees.map((a) => a.login).join(", ")})` : ""}` : labels.length ? labels.join(", ") : "open";
1598
+ return {
1599
+ id: `GH-${i.number}`,
1600
+ title: i.title,
1601
+ depends,
1602
+ status: closed ? "done" : active ? "active" : "todo",
1603
+ statusText,
1604
+ milestone: i.milestone?.title ?? null
1605
+ };
1606
+ });
1607
+ }
1608
+ function normalizeLinearIssues(issues) {
1609
+ return issues.filter((i) => typeof i.identifier === "string" && typeof i.title === "string").map((i) => {
1610
+ const type = i.state?.type ?? "unstarted";
1611
+ const done = type === "completed" || type === "canceled";
1612
+ const active = type === "started";
1613
+ const depends = (i.inverseRelations?.nodes ?? []).filter((r) => r.type === "blocks").map((r) => r.issue.identifier);
1614
+ const statusText = `${i.state?.name ?? type}${active && i.assignee ? ` (${i.assignee.name})` : ""}`;
1615
+ return {
1616
+ id: i.identifier,
1617
+ title: i.title,
1618
+ depends: [...new Set(depends)],
1619
+ status: done ? "done" : active ? "active" : "todo",
1620
+ statusText,
1621
+ milestone: i.cycle?.name ?? (i.cycle ? `Cycle ${i.cycle.number}` : i.project?.name ?? null)
1622
+ };
1623
+ });
1624
+ }
1625
+ function linearIssuesQuery(teamKey, first = 200) {
1626
+ const filter = teamKey ? `, filter: { team: { key: { eq: "${teamKey.replace(/"/g, "")}" } } }` : "";
1627
+ return `{ issues(first: ${first}, orderBy: createdAt${filter}) { nodes {
1628
+ identifier title sortOrder
1629
+ state { name type }
1630
+ assignee { name }
1631
+ project { name }
1632
+ cycle { name number }
1633
+ inverseRelations { nodes { type issue { identifier } } }
1634
+ } } }`;
1635
+ }
1636
+ // packages/core/src/worktree.ts
1637
+ import { join as join3 } from "path";
1638
+ function planBootstrap(cfg, repoRoot, worktree) {
1639
+ const seen = new Set;
1640
+ const copies = [];
1641
+ for (const raw of cfg.worktree.copy) {
1642
+ if (!isRepoRelative(raw))
1643
+ continue;
1644
+ const rel = raw.trim().replace(/^\.\//, "");
1645
+ if (seen.has(rel))
1646
+ continue;
1647
+ seen.add(rel);
1648
+ copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
1649
+ }
1650
+ return { copies, setup: cfg.worktree.setup };
1651
+ }
1652
+ var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
1653
+ function summarizeBootstrap(o) {
1654
+ const parts = [];
1655
+ if (o.copied.length)
1656
+ parts.push(`copied ${o.copied.join(", ")}`);
1657
+ if (o.skipped.length)
1658
+ parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
1659
+ if (o.setup)
1660
+ parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
1661
+ return parts.join("; ") || "nothing to do";
1662
+ }
1663
+ function canRemoveWorktree(w, heldByClaim, force) {
1664
+ if (w.main)
1665
+ return { ok: false, reason: "main" };
1666
+ if (heldByClaim)
1667
+ return { ok: false, reason: "held" };
1668
+ if (force)
1669
+ return { ok: true };
1670
+ if (w.dirty > 0)
1671
+ return { ok: false, reason: "dirty" };
1672
+ if (w.ahead > 0)
1673
+ return { ok: false, reason: "unpushed" };
1674
+ return { ok: true };
1675
+ }
1676
+ function removeRefusalMessage(reason, path, task) {
1677
+ switch (reason) {
1678
+ case "main":
1679
+ return `${path} is the main checkout \u2014 it is never removed`;
1680
+ case "held":
1681
+ return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
1682
+ case "dirty":
1683
+ return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
1684
+ case "unpushed":
1685
+ return `${path} has unpushed commits \u2014 push them, or --force to discard`;
1686
+ }
1687
+ }
1688
+ function planGc(worktrees, claims) {
1689
+ const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
1690
+ const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
1691
+ const out = [];
1692
+ for (const w of worktrees) {
1693
+ if (w.main || held.has(w.path))
1694
+ continue;
1695
+ const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
1696
+ if (!why)
1697
+ continue;
1698
+ const can = canRemoveWorktree(w, null, false);
1699
+ out.push({
1700
+ path: w.path,
1701
+ branch: w.branch,
1702
+ why,
1703
+ removable: can.ok,
1704
+ blocker: can.ok ? null : can.reason
1705
+ });
1706
+ }
1707
+ return out;
1708
+ }
1709
+ // packages/daemon/src/app.ts
1710
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
950
1711
  import { homedir as homedir4 } from "os";
951
- import { dirname as dirname2, join as join7 } from "path";
1712
+ import { dirname as dirname3, join as join9 } from "path";
952
1713
  import { fileURLToPath } from "url";
953
1714
 
954
1715
  // node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
955
1716
  var compose = (middleware, onError, onNotFound) => {
956
1717
  return (context, next) => {
957
1718
  let index = -1;
958
- return dispatch(0);
959
- async function dispatch(i) {
1719
+ return dispatch2(0);
1720
+ async function dispatch2(i) {
960
1721
  if (i <= index) {
961
1722
  throw new Error("next() called multiple times");
962
1723
  }
@@ -972,7 +1733,7 @@ var compose = (middleware, onError, onNotFound) => {
972
1733
  }
973
1734
  if (handler) {
974
1735
  try {
975
- res = await handler(context, () => dispatch(i + 1));
1736
+ res = await handler(context, () => dispatch2(i + 1));
976
1737
  } catch (err) {
977
1738
  if (err instanceof Error && onError) {
978
1739
  context.error = err;
@@ -2568,16 +3329,269 @@ var streamSSE = (c, cb, onError) => {
2568
3329
  return c.newResponse(stream.responseReadable);
2569
3330
  };
2570
3331
 
3332
+ // packages/daemon/src/dispatcher.ts
3333
+ class Dispatcher {
3334
+ store;
3335
+ runner;
3336
+ forge;
3337
+ entries = new Map;
3338
+ opts = new Map;
3339
+ constructor(store, runner, forge2) {
3340
+ this.store = store;
3341
+ this.runner = runner;
3342
+ this.forge = forge2;
3343
+ runner.onEnd((run2) => void this.onRunEnd(run2));
3344
+ }
3345
+ project(projectId) {
3346
+ let m = this.entries.get(projectId);
3347
+ if (!m) {
3348
+ m = new Map;
3349
+ this.entries.set(projectId, m);
3350
+ }
3351
+ return m;
3352
+ }
3353
+ status(projectId) {
3354
+ return [...this.entries.get(projectId)?.values() ?? []];
3355
+ }
3356
+ async dispatch(projectId, wanted, o = {}) {
3357
+ const board = this.store.tasks(projectId);
3358
+ if (!board)
3359
+ return {
3360
+ ok: false,
3361
+ error: "this repo has no task source ([tasks] source in .swarm.toml)"
3362
+ };
3363
+ if (board.error)
3364
+ return { ok: false, error: `task source: ${board.error}` };
3365
+ const cfg = this.store.config(projectId).dispatch;
3366
+ const opts = {
3367
+ owner: o.owner ?? "dispatch",
3368
+ ...o,
3369
+ maxParallel: o.maxParallel ?? cfg.max_parallel
3370
+ };
3371
+ this.opts.set(projectId, opts);
3372
+ const m = this.project(projectId);
3373
+ const running = [...m.values()].filter((e) => e.state === "running").length;
3374
+ const plan = planDispatch(board.tasks, wanted, {
3375
+ maxParallel: opts.maxParallel,
3376
+ running,
3377
+ max: o.max,
3378
+ alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
3379
+ });
3380
+ const now = new Date().toISOString();
3381
+ for (const t of [...plan.start, ...plan.queued]) {
3382
+ m.set(t.id, {
3383
+ task: t.id,
3384
+ title: t.title,
3385
+ state: "queued",
3386
+ runId: null,
3387
+ sessionId: null,
3388
+ queuedAt: now,
3389
+ startedAt: null,
3390
+ endedAt: null,
3391
+ outcome: null,
3392
+ detail: null,
3393
+ costUsd: null
3394
+ });
3395
+ }
3396
+ if (plan.start.length || plan.queued.length)
3397
+ this.store.append({
3398
+ ts: now,
3399
+ type: "dispatch.queued",
3400
+ projectId,
3401
+ sessionId: null,
3402
+ payload: {
3403
+ tasks: [...plan.start, ...plan.queued].map((t) => t.id),
3404
+ maxParallel: opts.maxParallel,
3405
+ summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
3406
+ }
3407
+ });
3408
+ const started = [];
3409
+ const failed = [];
3410
+ for (const t of plan.start) {
3411
+ const r = await this.startOne(projectId, t);
3412
+ if (r.ok)
3413
+ started.push(t.id);
3414
+ else
3415
+ failed.push({ id: t.id, reason: r.reason });
3416
+ }
3417
+ await this.fill(projectId);
3418
+ return {
3419
+ ok: true,
3420
+ started,
3421
+ queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
3422
+ rejected: [...plan.rejected, ...failed]
3423
+ };
3424
+ }
3425
+ async startOne(projectId, t) {
3426
+ const m = this.project(projectId);
3427
+ const e = m.get(t.id);
3428
+ const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
3429
+ const cfg = this.store.config(projectId);
3430
+ const gates2 = cfg.gates;
3431
+ const prompt = taskPrompt(t, {
3432
+ requiredGates: gates2.required,
3433
+ executableGates: gates2.required.filter((g) => gates2.defs[g]),
3434
+ openPr: cfg.dispatch.require_pr
3435
+ });
3436
+ const r = await this.runner.start({
3437
+ projectId,
3438
+ task: t.id,
3439
+ prompt,
3440
+ owner: opts.owner,
3441
+ permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
3442
+ model: opts.model ?? cfg.dispatch.model ?? undefined,
3443
+ maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
3444
+ profile: opts.profile ?? cfg.dispatch.profile ?? undefined
3445
+ });
3446
+ if (!r.ok) {
3447
+ if (e) {
3448
+ e.state = "finished";
3449
+ e.endedAt = new Date().toISOString();
3450
+ e.outcome = "crashed";
3451
+ e.detail = r.reason;
3452
+ }
3453
+ this.store.append({
3454
+ ts: new Date().toISOString(),
3455
+ type: "dispatch.finished",
3456
+ projectId,
3457
+ sessionId: null,
3458
+ payload: {
3459
+ task: t.id,
3460
+ outcome: "crashed",
3461
+ detail: r.reason,
3462
+ summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
3463
+ }
3464
+ });
3465
+ return { ok: false, reason: r.reason };
3466
+ }
3467
+ if (e) {
3468
+ e.state = "running";
3469
+ e.runId = r.run.id;
3470
+ e.sessionId = r.run.sessionId;
3471
+ e.startedAt = r.run.startedAt;
3472
+ }
3473
+ this.store.append({
3474
+ ts: r.run.startedAt,
3475
+ type: "dispatch.started",
3476
+ projectId,
3477
+ sessionId: r.run.sessionId,
3478
+ payload: {
3479
+ task: t.id,
3480
+ runId: r.run.id,
3481
+ worktree: r.run.worktree,
3482
+ summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
3483
+ }
3484
+ });
3485
+ return { ok: true };
3486
+ }
3487
+ async fill(projectId) {
3488
+ const m = this.project(projectId);
3489
+ const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
3490
+ for (const e of m.values()) {
3491
+ const running = [...m.values()].filter((x) => x.state === "running").length;
3492
+ if (running >= cap)
3493
+ return;
3494
+ if (e.state !== "queued")
3495
+ continue;
3496
+ await this.startOne(projectId, { id: e.task, title: e.title });
3497
+ }
3498
+ }
3499
+ async onRunEnd(run2) {
3500
+ const m = this.entries.get(run2.projectId);
3501
+ const e = m?.get(run2.task);
3502
+ if (!e || e.runId !== run2.id)
3503
+ return;
3504
+ const cfg = this.store.config(run2.projectId);
3505
+ const required = cfg.gates.required;
3506
+ let runs = this.store.gateRuns(run2.projectId, run2.task);
3507
+ const status = this.store.gateStatusFor(runs, required);
3508
+ const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
3509
+ if (missing.length && !run2.stopped) {
3510
+ await this.store.runGates(run2.projectId, run2.task, missing, {
3511
+ sessionId: run2.sessionId,
3512
+ owner: "dispatch"
3513
+ });
3514
+ runs = this.store.gateRuns(run2.projectId, run2.task);
3515
+ }
3516
+ const satisfied = gatesSatisfied(runs, required);
3517
+ await this.forge.refresh(0).catch(() => {});
3518
+ const branch = `task/${run2.task}`;
3519
+ const pr = this.forge.prs().find((p) => p.projectId === run2.projectId && p.branch === branch);
3520
+ const outcome = dispatchOutcome({
3521
+ exitCode: run2.exitCode,
3522
+ isError: run2.result?.isError ?? false,
3523
+ gatesSatisfied: satisfied,
3524
+ prOpen: Boolean(pr),
3525
+ requirePr: cfg.dispatch.require_pr,
3526
+ stopped: run2.stopped ?? false
3527
+ });
3528
+ const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
3529
+ const detail = [
3530
+ `exit ${run2.exitCode}${run2.result?.isError ? " (error)" : ""}`,
3531
+ required.length ? `gates: ${verdicts}` : null,
3532
+ pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
3533
+ ].filter(Boolean).join(" \xB7 ");
3534
+ e.state = "finished";
3535
+ e.endedAt = run2.endedAt;
3536
+ e.outcome = outcome;
3537
+ e.detail = detail;
3538
+ e.costUsd = run2.result?.costUsd ?? null;
3539
+ const ts = run2.endedAt ?? new Date().toISOString();
3540
+ this.store.append({
3541
+ ts,
3542
+ type: "dispatch.finished",
3543
+ projectId: run2.projectId,
3544
+ sessionId: run2.sessionId,
3545
+ payload: {
3546
+ task: run2.task,
3547
+ runId: run2.id,
3548
+ outcome,
3549
+ detail,
3550
+ costUsd: e.costUsd,
3551
+ summary: `dispatch ${run2.task}: ${outcome} \u2014 ${detail}`
3552
+ }
3553
+ });
3554
+ if (outcome !== "done" && outcome !== "stopped")
3555
+ this.store.append({
3556
+ ts,
3557
+ type: "incident.opened",
3558
+ projectId: run2.projectId,
3559
+ sessionId: run2.sessionId,
3560
+ payload: {
3561
+ rule: "dispatch_failed",
3562
+ action: outcome,
3563
+ command: run2.task,
3564
+ reason: `dispatched run on ${run2.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
3565
+ }
3566
+ });
3567
+ this.store.touch();
3568
+ await this.fill(run2.projectId);
3569
+ }
3570
+ clear(projectId, task) {
3571
+ const m = this.project(projectId);
3572
+ let n = 0;
3573
+ for (const [id, e] of m) {
3574
+ if (task && id !== task)
3575
+ continue;
3576
+ if (e.state === "queued" || e.state === "finished" && !task) {
3577
+ m.delete(id);
3578
+ n++;
3579
+ }
3580
+ }
3581
+ return n;
3582
+ }
3583
+ }
3584
+
2571
3585
  // packages/daemon/src/forge.ts
2572
3586
  import { existsSync as existsSync3 } from "fs";
2573
3587
  import { homedir as homedir2 } from "os";
2574
- import { join as join3 } from "path";
3588
+ import { join as join4 } from "path";
2575
3589
  var EXTRA_BIN_DIRS = [
2576
3590
  "/opt/homebrew/bin",
2577
3591
  "/usr/local/bin",
2578
3592
  "/home/linuxbrew/.linuxbrew/bin",
2579
- join3(homedir2(), ".local", "bin"),
2580
- join3(homedir2(), "bin")
3593
+ join4(homedir2(), ".local", "bin"),
3594
+ join4(homedir2(), "bin")
2581
3595
  ];
2582
3596
  function findBin(name) {
2583
3597
  if (!name)
@@ -2586,7 +3600,7 @@ function findBin(name) {
2586
3600
  if (onPath)
2587
3601
  return onPath;
2588
3602
  for (const d of EXTRA_BIN_DIRS) {
2589
- const p = join3(d, name);
3603
+ const p = join4(d, name);
2590
3604
  if (existsSync3(p))
2591
3605
  return p;
2592
3606
  }
@@ -2653,31 +3667,301 @@ class ForgeService {
2653
3667
  if (out)
2654
3668
  prs = normalizeGitlab(JSON.parse(out), remote.repo);
2655
3669
  }
2656
- return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3670
+ return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3671
+ }
3672
+ async openPR(projectId, worktree2, draft) {
3673
+ const p = this.store.projects().find((x) => x.id === projectId);
3674
+ if (!p)
3675
+ return { ok: false, error: "unknown project" };
3676
+ if (worktree2.main)
3677
+ return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
3678
+ if (!worktree2.branch)
3679
+ return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
3680
+ if (worktree2.dirty > 0)
3681
+ return {
3682
+ ok: false,
3683
+ error: `${worktree2.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
3684
+ };
3685
+ const remote = this.remote(p.root);
3686
+ if (!remote)
3687
+ return { ok: false, error: "no GitHub/GitLab remote on origin" };
3688
+ const cli = remote.forge === "github" ? "gh" : "glab";
3689
+ const bin = findBin(cli);
3690
+ if (!bin)
3691
+ return { ok: false, error: `${cli} is not installed` };
3692
+ const sh = async (cmd2, cwd) => {
3693
+ const proc = Bun.spawn(cmd2, { cwd, stdout: "pipe", stderr: "pipe" });
3694
+ const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
3695
+ return { ok: await proc.exited === 0, out: out.trim() };
3696
+ };
3697
+ const push = await sh(["git", "push", "-u", "origin", worktree2.branch], worktree2.path);
3698
+ if (!push.ok)
3699
+ return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
3700
+ const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree2.branch);
3701
+ if (existing)
3702
+ return { ok: true, url: existing.url, number: existing.number };
3703
+ const cmd = remote.forge === "github" ? [
3704
+ bin,
3705
+ "pr",
3706
+ "create",
3707
+ "--head",
3708
+ worktree2.branch,
3709
+ "--title",
3710
+ draft.title,
3711
+ "--body",
3712
+ draft.body,
3713
+ ...draft.isDraft ? ["--draft"] : []
3714
+ ] : [
3715
+ bin,
3716
+ "mr",
3717
+ "create",
3718
+ "--source-branch",
3719
+ worktree2.branch,
3720
+ "--title",
3721
+ draft.title,
3722
+ "--description",
3723
+ draft.body,
3724
+ "--yes",
3725
+ ...draft.isDraft ? ["--draft"] : []
3726
+ ];
3727
+ const r = await sh(cmd, worktree2.path);
3728
+ if (!r.ok)
3729
+ return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
3730
+ const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
3731
+ const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
3732
+ this.cache.delete(projectId);
3733
+ return { ok: true, url, number: Number.isFinite(num) ? num : null };
3734
+ }
3735
+ async merge(projectId, number) {
3736
+ const p = this.store.projects().find((x) => x.id === projectId);
3737
+ if (!p)
3738
+ return { ok: false, output: "unknown project" };
3739
+ const remote = this.remote(p.root);
3740
+ if (!remote)
3741
+ return { ok: false, output: "no forge remote" };
3742
+ const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
3743
+ const bin = findBin(cmd[0]);
3744
+ if (!bin)
3745
+ return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
3746
+ const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
3747
+ const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
3748
+ const ok = await proc.exited === 0;
3749
+ if (ok)
3750
+ this.cache.delete(projectId);
3751
+ return { ok, output: out.trim().slice(0, 800) };
3752
+ }
3753
+ }
3754
+
3755
+ // packages/daemon/src/git.ts
3756
+ import { realpathSync } from "fs";
3757
+ import { join as join5 } from "path";
3758
+ function git(cwd, args) {
3759
+ try {
3760
+ const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3761
+ return r.exitCode === 0 ? r.stdout.toString() : null;
3762
+ } catch {
3763
+ return null;
3764
+ }
3765
+ }
3766
+ function gitCommonDir(cwd) {
3767
+ const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
3768
+ if (!out)
3769
+ return null;
3770
+ try {
3771
+ return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
3772
+ } catch {
3773
+ return null;
3774
+ }
3775
+ }
3776
+ function gitToplevel(cwd) {
3777
+ const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
3778
+ if (!out)
3779
+ return null;
3780
+ try {
3781
+ return realpathSync(out);
3782
+ } catch {
3783
+ return null;
3784
+ }
3785
+ }
3786
+ function parseWorktreeList(out) {
3787
+ const wts = [];
3788
+ let cur = null;
3789
+ const flush = () => {
3790
+ if (cur?.path) {
3791
+ wts.push({
3792
+ path: cur.path,
3793
+ branch: cur.branch ?? null,
3794
+ head: (cur.head ?? "").slice(0, 7),
3795
+ main: wts.length === 0,
3796
+ dirty: -1,
3797
+ ahead: -1,
3798
+ behind: -1,
3799
+ merged: false
3800
+ });
3801
+ }
3802
+ cur = null;
3803
+ };
3804
+ for (const line of out.split(`
3805
+ `)) {
3806
+ if (line.startsWith("worktree ")) {
3807
+ flush();
3808
+ cur = { path: line.slice(9) };
3809
+ } else if (line.startsWith("HEAD ") && cur)
3810
+ cur.head = line.slice(5);
3811
+ else if (line.startsWith("branch ") && cur)
3812
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3813
+ else if (line === "")
3814
+ flush();
3815
+ }
3816
+ flush();
3817
+ return wts;
3818
+ }
3819
+ function applyStatus(w, st, ah) {
3820
+ w.dirty = st === null ? -1 : st.split(`
3821
+ `).filter(Boolean).length;
3822
+ const a = ah?.trim();
3823
+ w.ahead = a === undefined || a === "" ? -1 : Number(a);
3824
+ }
3825
+ function applyDrift(w, behind, ancestor, firstParents) {
3826
+ const b = behind?.trim();
3827
+ w.behind = b === undefined || b === "" ? -1 : Number(b);
3828
+ const onLine = firstParents?.split(`
3829
+ `).some((sha) => sha.startsWith(w.head)) ?? true;
3830
+ w.merged = ancestor && !onLine;
3831
+ }
3832
+ var FIRST_PARENT_DEPTH = "5000";
3833
+ var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
3834
+ async function gitAsync(cwd, args) {
3835
+ try {
3836
+ const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3837
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3838
+ return code === 0 ? out : null;
3839
+ } catch {
3840
+ return null;
3841
+ }
3842
+ }
3843
+ async function listWorktreesAsync(root) {
3844
+ const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
3845
+ if (!out)
3846
+ return [];
3847
+ const wts = parseWorktreeList(out);
3848
+ const base = baseOf(wts);
3849
+ const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
3850
+ await Promise.all(wts.map(async (w) => {
3851
+ const drift = base && !w.main;
3852
+ const [st, ah, be, mg] = await Promise.all([
3853
+ gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
3854
+ gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
3855
+ drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
3856
+ drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
3857
+ ]);
3858
+ applyStatus(w, st, ah);
3859
+ if (drift)
3860
+ applyDrift(w, be, mg !== null, line);
3861
+ }));
3862
+ return wts;
3863
+ }
3864
+ var branchCache = new Map;
3865
+ function currentBranch(cwd) {
3866
+ const hit = branchCache.get(cwd);
3867
+ const now = Date.now();
3868
+ if (hit && now - hit.t < 5000)
3869
+ return hit.v;
3870
+ const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
3871
+ branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
3872
+ return branchCache.get(cwd)?.v ?? null;
3873
+ }
3874
+ function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
3875
+ const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
3876
+ const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
3877
+ if (git(repoRoot, args) === null)
3878
+ return null;
3879
+ try {
3880
+ return realpathSync(path);
3881
+ } catch {
3882
+ return path;
3883
+ }
3884
+ }
3885
+ function worktreeRemove(repoRoot, path, force) {
3886
+ const args = ["worktree", "remove", path];
3887
+ if (force)
3888
+ args.push("--force");
3889
+ return git(repoRoot, args) !== null;
3890
+ }
3891
+ function heldWork(path) {
3892
+ const status = git(path, ["status", "--porcelain"]);
3893
+ const dirty = status !== null && status.trim().length > 0;
3894
+ const count = (args) => {
3895
+ const out = git(path, ["rev-list", "--count", ...args])?.trim();
3896
+ return out !== undefined && out !== "" ? Number(out) : 0;
3897
+ };
3898
+ let unpushed;
3899
+ if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
3900
+ unpushed = count(["@{upstream}..HEAD"]) > 0;
3901
+ } else {
3902
+ const baselines = ["--remotes"];
3903
+ for (const b of ["main", "master"]) {
3904
+ if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
3905
+ baselines.push(b);
3906
+ }
3907
+ unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
3908
+ }
3909
+ return { dirty, unpushed };
3910
+ }
3911
+ async function worktreeDiff(root, path) {
3912
+ const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
3913
+ const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
3914
+ const isMain = wts[0]?.path === path;
3915
+ const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
3916
+ const from = mb || "HEAD";
3917
+ const [numstat, names, log, status] = await Promise.all([
3918
+ gitAsync(path, ["diff", "--numstat", from]),
3919
+ gitAsync(path, ["diff", "--name-status", from]),
3920
+ mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
3921
+ gitAsync(path, ["status", "--porcelain"])
3922
+ ]);
3923
+ const files = parseNumstat(numstat ?? "", names ?? "");
3924
+ for (const line of (status ?? "").split(`
3925
+ `)) {
3926
+ if (line.startsWith("?? "))
3927
+ files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
3928
+ }
3929
+ return {
3930
+ base: mb ?? null,
3931
+ baseRef,
3932
+ files,
3933
+ commits: (log ?? "").split(`
3934
+ `).filter(Boolean),
3935
+ dirty: (status ?? "").trim().length > 0
3936
+ };
3937
+ }
3938
+ async function worktreePatch(path, base, file) {
3939
+ const from = base ?? "HEAD";
3940
+ if (file) {
3941
+ const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
3942
+ if (!tracked) {
3943
+ const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
3944
+ stdout: "pipe",
3945
+ stderr: "ignore"
3946
+ });
3947
+ const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3948
+ return out;
3949
+ }
3950
+ return await gitAsync(path, ["diff", from, "--", file]) ?? "";
2657
3951
  }
2658
- async merge(projectId, number) {
2659
- const p = this.store.projects().find((x) => x.id === projectId);
2660
- if (!p)
2661
- return { ok: false, output: "unknown project" };
2662
- const remote = this.remote(p.root);
2663
- if (!remote)
2664
- return { ok: false, output: "no forge remote" };
2665
- const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
2666
- const bin = findBin(cmd[0]);
2667
- if (!bin)
2668
- return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
2669
- const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
2670
- const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
2671
- const ok = await proc.exited === 0;
2672
- if (ok)
2673
- this.cache.delete(projectId);
2674
- return { ok, output: out.trim().slice(0, 800) };
3952
+ return await gitAsync(path, ["diff", from]) ?? "";
3953
+ }
3954
+ function realpathOr(p) {
3955
+ try {
3956
+ return realpathSync(p);
3957
+ } catch {
3958
+ return p;
2675
3959
  }
2676
3960
  }
2677
3961
 
2678
3962
  // packages/daemon/src/runner.ts
2679
3963
  import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
2680
- import { join as join4 } from "path";
3964
+ import { join as join6 } from "path";
2681
3965
  var PERMISSION_MODES = [
2682
3966
  "acceptEdits",
2683
3967
  "auto",
@@ -2691,6 +3975,11 @@ class Runner {
2691
3975
  store;
2692
3976
  home;
2693
3977
  live = new Map;
3978
+ endListeners = new Set;
3979
+ onEnd(fn) {
3980
+ this.endListeners.add(fn);
3981
+ return () => this.endListeners.delete(fn);
3982
+ }
2694
3983
  constructor(store, home) {
2695
3984
  this.store = store;
2696
3985
  this.home = home;
@@ -2721,18 +4010,19 @@ class Runner {
2721
4010
  reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
2722
4011
  };
2723
4012
  const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
2724
- let worktree = held?.worktree ?? "";
2725
- if (!worktree) {
4013
+ let worktree2 = held?.worktree ?? "";
4014
+ if (!worktree2) {
2726
4015
  const c = this.store.claim(input.projectId, input.task, input.owner);
2727
4016
  if (!c.ok)
2728
4017
  return { ok: false, reason: c.error };
2729
- worktree = c.worktree;
4018
+ worktree2 = c.worktree;
2730
4019
  }
4020
+ await this.store.awaitBootstrap(worktree2);
2731
4021
  const sessionId = crypto.randomUUID();
2732
4022
  const id = sessionId.slice(0, 8);
2733
- const logDir = join4(this.home, "logs", project.id);
4023
+ const logDir = join6(this.home, "logs", project.id);
2734
4024
  mkdirSync2(logDir, { recursive: true });
2735
- const log = join4(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
4025
+ const log = join6(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
2736
4026
  const logFd = openSync(log, "a");
2737
4027
  const args = [
2738
4028
  bin,
@@ -2751,13 +4041,22 @@ class Runner {
2751
4041
  args.push("--model", input.model);
2752
4042
  if (input.permissionMode)
2753
4043
  args.push("--permission-mode", input.permissionMode);
2754
- if (input.allowedTools?.length)
2755
- args.push("--allowedTools", ...input.allowedTools);
4044
+ const profile = runProfile(input.profile);
4045
+ if (input.profile && !profile)
4046
+ return {
4047
+ ok: false,
4048
+ reason: `unknown profile ${input.profile} \u2014 one of ${Object.keys(RUN_PROFILES).join(", ")}`
4049
+ };
4050
+ const allowed = [...input.allowedTools ?? [], ...profile?.allowedTools ?? []];
4051
+ if (allowed.length)
4052
+ args.push("--allowedTools", ...allowed);
4053
+ if (profile?.disallowedTools.length)
4054
+ args.push("--disallowedTools", ...profile.disallowedTools);
2756
4055
  if (input.maxTurns)
2757
4056
  args.push("--max-turns", String(input.maxTurns));
2758
- this.store.preregisterSpawnedSession(sessionId, project.id, worktree, input.task);
4057
+ this.store.preregisterSpawnedSession(sessionId, project.id, worktree2, input.task);
2759
4058
  const proc = Bun.spawn(args, {
2760
- cwd: worktree,
4059
+ cwd: worktree2,
2761
4060
  env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
2762
4061
  stdin: "pipe",
2763
4062
  stdout: "pipe",
@@ -2768,11 +4067,12 @@ class Runner {
2768
4067
  sessionId,
2769
4068
  projectId: project.id,
2770
4069
  task: input.task,
2771
- worktree,
4070
+ worktree: worktree2,
2772
4071
  pid: proc.pid,
2773
4072
  owner: input.owner,
2774
4073
  model: input.model ?? null,
2775
4074
  permissionMode: input.permissionMode ?? null,
4075
+ profile: input.profile ?? null,
2776
4076
  prompt: input.prompt,
2777
4077
  log,
2778
4078
  startedAt: new Date().toISOString(),
@@ -2788,7 +4088,7 @@ class Runner {
2788
4088
  sessionId,
2789
4089
  kind: "proc",
2790
4090
  name: `run:${input.task}`,
2791
- cwd: worktree,
4091
+ cwd: worktree2,
2792
4092
  cmd: `claude -p (run ${id})`,
2793
4093
  owner: input.owner,
2794
4094
  log
@@ -2853,6 +4153,13 @@ class Runner {
2853
4153
  });
2854
4154
  this.store.endSpawnedSession(entry.run.sessionId);
2855
4155
  this.live.delete(id);
4156
+ for (const fn of this.endListeners) {
4157
+ try {
4158
+ fn(entry.run);
4159
+ } catch (e) {
4160
+ console.error("swarm run: onEnd listener failed:", e.message);
4161
+ }
4162
+ }
2856
4163
  }
2857
4164
  onLine(run2, line) {
2858
4165
  if (!line.startsWith("{"))
@@ -2976,6 +4283,7 @@ class Runner {
2976
4283
  if (!run2)
2977
4284
  return { ok: false, reason: "no live run" };
2978
4285
  const entry = this.live.get(run2.id);
4286
+ run2.stopped = true;
2979
4287
  try {
2980
4288
  const stdin = entry?.proc.stdin;
2981
4289
  if (stdin && typeof stdin !== "number")
@@ -2992,9 +4300,9 @@ class Runner {
2992
4300
  import { Database } from "bun:sqlite";
2993
4301
  import {
2994
4302
  closeSync,
2995
- existsSync as existsSync4,
2996
- mkdirSync as mkdirSync3,
2997
- openSync as openSync2,
4303
+ existsSync as existsSync5,
4304
+ mkdirSync as mkdirSync4,
4305
+ openSync as openSync3,
2998
4306
  readdirSync,
2999
4307
  readFileSync as readFileSync3,
3000
4308
  readSync,
@@ -3004,145 +4312,131 @@ import {
3004
4312
  writeFileSync as writeFileSync2
3005
4313
  } from "fs";
3006
4314
  import { homedir as homedir3 } from "os";
3007
- import { basename, dirname, join as join6 } from "path";
4315
+ import { basename, dirname as dirname2, join as join8 } from "path";
3008
4316
 
3009
- // packages/daemon/src/git.ts
3010
- import { realpathSync } from "fs";
3011
- import { join as join5 } from "path";
3012
- function git(cwd, args) {
3013
- try {
3014
- const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3015
- return r.exitCode === 0 ? r.stdout.toString() : null;
3016
- } catch {
3017
- return null;
3018
- }
3019
- }
3020
- function gitCommonDir(cwd) {
3021
- const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
3022
- if (!out)
3023
- return null;
3024
- try {
3025
- return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
3026
- } catch {
3027
- return null;
3028
- }
3029
- }
3030
- function gitToplevel(cwd) {
3031
- const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
3032
- if (!out)
3033
- return null;
3034
- try {
3035
- return realpathSync(out);
3036
- } catch {
3037
- return null;
4317
+ // packages/daemon/src/bootstrap.ts
4318
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
4319
+ import { dirname, join as join7 } from "path";
4320
+ function runBootstrap(plan, opts) {
4321
+ const logDir = join7(opts.home, "logs", opts.projectId);
4322
+ mkdirSync3(logDir, { recursive: true });
4323
+ const log = join7(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
4324
+ const copied = [];
4325
+ const skipped = [];
4326
+ for (const c of plan.copies) {
4327
+ if (!existsSync4(c.from)) {
4328
+ skipped.push(c.rel);
4329
+ continue;
4330
+ }
4331
+ try {
4332
+ mkdirSync3(dirname(c.to), { recursive: true });
4333
+ cpSync(c.from, c.to, { recursive: true, force: true });
4334
+ copied.push(c.rel);
4335
+ } catch (e) {
4336
+ skipped.push(`${c.rel} (${e.message})`);
4337
+ }
3038
4338
  }
3039
- }
3040
- function parseWorktreeList(out) {
3041
- const wts = [];
3042
- let cur = null;
3043
- const flush = () => {
3044
- if (cur?.path) {
3045
- wts.push({
3046
- path: cur.path,
3047
- branch: cur.branch ?? null,
3048
- head: (cur.head ?? "").slice(0, 7),
3049
- main: wts.length === 0,
3050
- dirty: -1,
3051
- ahead: -1
4339
+ const done = (async () => {
4340
+ if (!plan.setup)
4341
+ return { copied, skipped, setup: null };
4342
+ const command = plan.setup;
4343
+ const started = Date.now();
4344
+ let exitCode = -1;
4345
+ try {
4346
+ const fd = openSync2(log, "a");
4347
+ const proc = Bun.spawn(["sh", "-c", command], {
4348
+ cwd: opts.worktree,
4349
+ stdin: "ignore",
4350
+ stdout: fd,
4351
+ stderr: fd,
4352
+ env: { ...process.env, SWARM_WORKTREE: opts.worktree, SWARM_TASK: opts.task }
3052
4353
  });
4354
+ exitCode = await proc.exited;
4355
+ } catch (e) {
4356
+ exitCode = -1;
4357
+ await Bun.write(log, `swarm: could not start setup: ${e.message}
4358
+ `);
3053
4359
  }
3054
- cur = null;
3055
- };
3056
- for (const line of out.split(`
3057
- `)) {
3058
- if (line.startsWith("worktree ")) {
3059
- flush();
3060
- cur = { path: line.slice(9) };
3061
- } else if (line.startsWith("HEAD ") && cur)
3062
- cur.head = line.slice(5);
3063
- else if (line.startsWith("branch ") && cur)
3064
- cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3065
- else if (line === "")
3066
- flush();
3067
- }
3068
- flush();
3069
- return wts;
4360
+ return { copied, skipped, setup: { command, exitCode, durationMs: Date.now() - started } };
4361
+ })();
4362
+ return { log, done };
3070
4363
  }
3071
- function applyStatus(w, st, ah) {
3072
- w.dirty = st === null ? -1 : st.split(`
3073
- `).filter(Boolean).length;
3074
- const a = ah?.trim();
3075
- w.ahead = a === undefined || a === "" ? -1 : Number(a);
3076
- }
3077
- async function gitAsync(cwd, args) {
3078
- try {
3079
- const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3080
- const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3081
- return code === 0 ? out : null;
3082
- } catch {
3083
- return null;
4364
+
4365
+ // packages/daemon/src/task-sources.ts
4366
+ class TaskSources {
4367
+ env;
4368
+ cache = new Map;
4369
+ inflight = new Set;
4370
+ constructor(env = process.env) {
4371
+ this.env = env;
4372
+ }
4373
+ get(projectId, kind, root, opts, ttlMs = 60000) {
4374
+ const hit = this.cache.get(projectId);
4375
+ if (!hit || Date.now() - hit.at >= ttlMs)
4376
+ this.refresh(projectId, kind, root, opts);
4377
+ return hit ?? { at: 0, tasks: [], error: null };
4378
+ }
4379
+ async refresh(projectId, kind, root, opts) {
4380
+ if (this.inflight.has(projectId))
4381
+ return this.cache.get(projectId) ?? { at: 0, tasks: [], error: null };
4382
+ this.inflight.add(projectId);
4383
+ const prev = this.cache.get(projectId);
4384
+ let entry;
4385
+ try {
4386
+ const tasks2 = kind === "github" ? await this.github(root, opts.labels) : await this.linear(opts.team);
4387
+ entry = { at: Date.now(), tasks: tasks2, error: null };
4388
+ } catch (e) {
4389
+ entry = { at: Date.now(), tasks: prev?.tasks ?? [], error: e.message };
4390
+ } finally {
4391
+ this.inflight.delete(projectId);
4392
+ }
4393
+ this.cache.set(projectId, entry);
4394
+ return entry;
3084
4395
  }
3085
- }
3086
- async function listWorktreesAsync(root) {
3087
- const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
3088
- if (!out)
3089
- return [];
3090
- const wts = parseWorktreeList(out);
3091
- await Promise.all(wts.map(async (w) => {
3092
- const [st, ah] = await Promise.all([
3093
- gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
3094
- gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"])
4396
+ async github(root, labels) {
4397
+ const bin = findBin("gh");
4398
+ if (!bin)
4399
+ throw new Error("gh not installed \u2014 GitHub Issues need the gh CLI (brew install gh)");
4400
+ const args = [
4401
+ bin,
4402
+ "issue",
4403
+ "list",
4404
+ "--state",
4405
+ "all",
4406
+ "--limit",
4407
+ "300",
4408
+ "--json",
4409
+ "number,title,state,labels,body,assignees,milestone"
4410
+ ];
4411
+ for (const l of labels)
4412
+ args.push("--label", l);
4413
+ const proc = Bun.spawn(args, { cwd: root, stdout: "pipe", stderr: "pipe" });
4414
+ const [out, err, code] = await Promise.all([
4415
+ new Response(proc.stdout).text(),
4416
+ new Response(proc.stderr).text(),
4417
+ proc.exited
3095
4418
  ]);
3096
- applyStatus(w, st, ah);
3097
- }));
3098
- return wts;
3099
- }
3100
- var branchCache = new Map;
3101
- function currentBranch(cwd) {
3102
- const hit = branchCache.get(cwd);
3103
- const now = Date.now();
3104
- if (hit && now - hit.t < 5000)
3105
- return hit.v;
3106
- const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
3107
- branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
3108
- return branchCache.get(cwd)?.v ?? null;
3109
- }
3110
- function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
3111
- const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
3112
- const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
3113
- if (git(repoRoot, args) === null)
3114
- return null;
3115
- try {
3116
- return realpathSync(path);
3117
- } catch {
3118
- return path;
3119
- }
3120
- }
3121
- function worktreeRemove(repoRoot, path, force) {
3122
- const args = ["worktree", "remove", path];
3123
- if (force)
3124
- args.push("--force");
3125
- return git(repoRoot, args) !== null;
3126
- }
3127
- function heldWork(path) {
3128
- const status = git(path, ["status", "--porcelain"]);
3129
- const dirty = status !== null && status.trim().length > 0;
3130
- const count = (args) => {
3131
- const out = git(path, ["rev-list", "--count", ...args])?.trim();
3132
- return out !== undefined && out !== "" ? Number(out) : 0;
3133
- };
3134
- let unpushed;
3135
- if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
3136
- unpushed = count(["@{upstream}..HEAD"]) > 0;
3137
- } else {
3138
- const baselines = ["--remotes"];
3139
- for (const b of ["main", "master"]) {
3140
- if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
3141
- baselines.push(b);
3142
- }
3143
- unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
4419
+ if (code !== 0)
4420
+ throw new Error(`gh issue list failed: ${err.trim().split(`
4421
+ `)[0] ?? code}`);
4422
+ return normalizeGithubIssues(JSON.parse(out));
4423
+ }
4424
+ async linear(team) {
4425
+ const key = this.env.LINEAR_API_KEY;
4426
+ if (!key)
4427
+ throw new Error("LINEAR_API_KEY not set \u2014 export it in the environment swarmd starts from (never stored)");
4428
+ const r = await fetch("https://api.linear.app/graphql", {
4429
+ method: "POST",
4430
+ headers: { "content-type": "application/json", authorization: key },
4431
+ body: JSON.stringify({ query: linearIssuesQuery(team) })
4432
+ });
4433
+ if (!r.ok)
4434
+ throw new Error(`Linear API ${r.status}`);
4435
+ const j = await r.json();
4436
+ if (j.errors?.length)
4437
+ throw new Error(`Linear: ${j.errors[0]?.message}`);
4438
+ return normalizeLinearIssues(j.data?.issues?.nodes ?? []);
3144
4439
  }
3145
- return { dirty, unpushed };
3146
4440
  }
3147
4441
 
3148
4442
  // packages/daemon/src/store.ts
@@ -3171,6 +4465,10 @@ CREATE TABLE IF NOT EXISTS resources (
3171
4465
  PRIMARY KEY (name, project_id)
3172
4466
  );
3173
4467
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
4468
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory USING fts5(
4469
+ kind UNINDEXED, ref UNINDEXED, project_id UNINDEXED, task, session_id UNINDEXED, ts UNINDEXED,
4470
+ title, text, tokenize = 'unicode61 remove_diacritics 2'
4471
+ );
3174
4472
  CREATE TABLE IF NOT EXISTS incident_acks (seq INTEGER PRIMARY KEY, acked_at TEXT);
3175
4473
  CREATE TABLE IF NOT EXISTS processes (
3176
4474
  pid INTEGER, start_time TEXT, project_id TEXT, session_id TEXT, kind TEXT, name TEXT, port INTEGER,
@@ -3187,6 +4485,12 @@ CREATE TABLE IF NOT EXISTS handoffs (
3187
4485
  files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
3188
4486
  );
3189
4487
  CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
4488
+ CREATE TABLE IF NOT EXISTS messages (
4489
+ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, session_id TEXT, task TEXT, kind TEXT,
4490
+ text TEXT, options TEXT, asked_by TEXT, created_at TEXT,
4491
+ answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
4492
+ );
4493
+ CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
3190
4494
  CREATE TABLE IF NOT EXISTS claims (
3191
4495
  project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
3192
4496
  acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
@@ -3207,18 +4511,19 @@ class Store {
3207
4511
  gen = 0;
3208
4512
  memo = new Map;
3209
4513
  constructor(home = swarmHome()) {
3210
- mkdirSync3(home, { recursive: true });
4514
+ mkdirSync4(home, { recursive: true });
3211
4515
  this.home = home;
3212
- this.db = new Database(join6(home, "swarm.db"));
4516
+ this.db = new Database(join8(home, "swarm.db"));
3213
4517
  this.loadPricing();
3214
4518
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
3215
4519
  this.db.exec(SCHEMA);
3216
4520
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
3217
4521
  this.ensureColumn("projects", "sort_order", "INTEGER");
3218
- this.migrateProjectsJson(join6(home, "projects.json"));
4522
+ this.migrateProjectsJson(join8(home, "projects.json"));
3219
4523
  this.reconcileMovedProjects();
3220
4524
  this.slimExistingEvents();
3221
4525
  this.retypeNotificationIncidents();
4526
+ this.backfillMemory();
3222
4527
  }
3223
4528
  retypeNotificationIncidents() {
3224
4529
  if (this.meta("notifications_retyped") === "1")
@@ -3262,9 +4567,9 @@ class Store {
3262
4567
  reconcileMovedProjects() {
3263
4568
  const all = this.projects();
3264
4569
  for (const stale of all) {
3265
- if (existsSync4(stale.root))
4570
+ if (existsSync5(stale.root))
3266
4571
  continue;
3267
- const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync4(p.root));
4572
+ const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync5(p.root));
3268
4573
  if (live.length !== 1)
3269
4574
  continue;
3270
4575
  this.mergeProject(stale.id, live[0].id);
@@ -3297,7 +4602,7 @@ class Store {
3297
4602
  }
3298
4603
  }
3299
4604
  migrateProjectsJson(file) {
3300
- if (!existsSync4(file))
4605
+ if (!existsSync5(file))
3301
4606
  return;
3302
4607
  try {
3303
4608
  const list = JSON.parse(readFileSync3(file, "utf8"));
@@ -3312,7 +4617,7 @@ class Store {
3312
4617
  const hit = this.topCache.get(cwd);
3313
4618
  if (hit && Date.now() - hit.t < 1e4)
3314
4619
  return hit.v;
3315
- const v = cwd && existsSync4(cwd) ? gitToplevel(cwd) : null;
4620
+ const v = cwd && existsSync5(cwd) ? gitToplevel(cwd) : null;
3316
4621
  this.topCache.set(cwd, { v, t: Date.now() });
3317
4622
  return v;
3318
4623
  }
@@ -3345,8 +4650,9 @@ class Store {
3345
4650
  createdAt: new Date().toISOString()
3346
4651
  };
3347
4652
  const sessionId = this.knownSession(h.sessionId);
3348
- this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
4653
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3349
4654
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
4655
+ this.remember(handoffDoc(projectId, Number(ins.lastInsertRowid), handoff, sessionId));
3350
4656
  this.append({
3351
4657
  ts: handoff.createdAt,
3352
4658
  type: "handoff.recorded",
@@ -3383,8 +4689,140 @@ class Store {
3383
4689
  sessionId: r.session_id ?? null
3384
4690
  }));
3385
4691
  }
4692
+ autoHandoff(sessionId, cwd) {
4693
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
4694
+ if (!held)
4695
+ return null;
4696
+ const manual = this.db.query("SELECT id, by FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ?").all(held.projectId, held.task, sessionId);
4697
+ if (manual.some((h2) => !isAutoHandoff(h2)))
4698
+ return null;
4699
+ const row = this.db.query("SELECT last_text FROM sessions WHERE id = ?").get(sessionId);
4700
+ const h = deriveHandoff(held.task, this.sessionEvents(sessionId, 2000), { lastText: row?.last_text ?? null, sessionId });
4701
+ if (!h)
4702
+ return null;
4703
+ this.db.query("DELETE FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(held.projectId, held.task, sessionId);
4704
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
4705
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt);
4706
+ this.remember(handoffDoc(held.projectId, Number(ins.lastInsertRowid), h, sessionId));
4707
+ this.touch();
4708
+ return h;
4709
+ }
4710
+ resumePlan(sessionId) {
4711
+ const s = this.db.query("SELECT project_id, cwd, last_text FROM sessions WHERE id = ?").get(sessionId);
4712
+ if (!s)
4713
+ return { ok: false, reason: "unknown session" };
4714
+ const byHandoff = this.db.query("SELECT project_id, task FROM handoffs WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
4715
+ const claim = this.claimRows(s.project_id).find((c) => c.worktree && s.cwd && isInside(s.cwd, c.worktree));
4716
+ const task = byHandoff?.task ?? claim?.task;
4717
+ const projectId = byHandoff?.project_id ?? s.project_id;
4718
+ if (!task)
4719
+ return { ok: false, reason: "this session was not working on a claimed task" };
4720
+ const ev = this.sessionEvents(sessionId, 2000);
4721
+ let handoff = this.latestHandoff(projectId, task);
4722
+ if (!handoff)
4723
+ handoff = deriveHandoff(task, ev, {
4724
+ lastText: s.last_text,
4725
+ sessionId
4726
+ });
4727
+ if (!handoff)
4728
+ return { ok: false, reason: "nothing to resume \u2014 the session left no trail" };
4729
+ 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));
4730
+ const owner = claim && claim.state === "held" ? claim.owner : null;
4731
+ return { ok: true, projectId, task, owner, prompt: formatResumePrompt(handoff, tail), handoff };
4732
+ }
4733
+ remember(doc) {
4734
+ if (!doc)
4735
+ return;
4736
+ this.db.query("DELETE FROM memory WHERE kind = ? AND ref = ?").run(doc.kind, doc.ref);
4737
+ 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);
4738
+ }
4739
+ rememberSession(sessionId) {
4740
+ const r = this.db.query("SELECT id, project_id, title, last_text, last_seen_at, cwd FROM sessions WHERE id = ?").get(sessionId);
4741
+ if (!r)
4742
+ return;
4743
+ const held = r.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(r.cwd, c.worktree)) : null;
4744
+ this.remember(sessionDoc(r.project_id, {
4745
+ id: r.id,
4746
+ title: r.title,
4747
+ lastText: r.last_text,
4748
+ ts: r.last_seen_at,
4749
+ task: held?.task ?? null
4750
+ }));
4751
+ }
4752
+ backfillMemory() {
4753
+ if (this.db.query("SELECT value FROM meta WHERE key = 'memory_backfilled'").get())
4754
+ return;
4755
+ const tx = this.db.transaction(() => {
4756
+ for (const r of this.db.query("SELECT * FROM handoffs").all())
4757
+ this.remember(handoffDoc(r.project_id, r.id, {
4758
+ task: r.task,
4759
+ done: r.done,
4760
+ remaining: r.remaining,
4761
+ files: JSON.parse(r.files || "[]"),
4762
+ verify: r.verify ?? null,
4763
+ by: r.by ?? null,
4764
+ createdAt: r.created_at
4765
+ }, r.session_id ?? null));
4766
+ for (const r of this.db.query("SELECT * FROM gates").all())
4767
+ this.remember(gateDoc(r.project_id, r.id, this.rowToGate(r), r.session_id ?? null));
4768
+ for (const r of this.db.query("SELECT seq, ts, project_id, session_id, payload FROM events WHERE type = 'incident.opened'").all()) {
4769
+ let p = {};
4770
+ try {
4771
+ p = JSON.parse(r.payload || "{}");
4772
+ } catch {}
4773
+ this.remember(incidentDoc(r.project_id, r.seq, p, r.ts, r.session_id ?? null));
4774
+ }
4775
+ 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())
4776
+ this.remember(sessionDoc(r.project_id, {
4777
+ id: r.id,
4778
+ title: r.title ?? null,
4779
+ lastText: r.last_text,
4780
+ ts: r.last_seen_at
4781
+ }));
4782
+ this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('memory_backfilled', ?)").run(new Date().toISOString());
4783
+ });
4784
+ tx();
4785
+ }
4786
+ memorySearch(q, opts = {}) {
4787
+ const parsed = parseMemoryQuery(q);
4788
+ if (!parsed.match)
4789
+ return [];
4790
+ const kind = opts.kind ?? parsed.kind;
4791
+ const task = opts.task ?? parsed.task;
4792
+ const where = ["memory MATCH ?"];
4793
+ const args = [parsed.match];
4794
+ if (opts.projectId) {
4795
+ where.push("project_id = ?");
4796
+ args.push(opts.projectId);
4797
+ }
4798
+ if (kind) {
4799
+ where.push("kind = ?");
4800
+ args.push(kind);
4801
+ }
4802
+ if (task) {
4803
+ where.push("task = ?");
4804
+ args.push(task);
4805
+ }
4806
+ args.push(Math.min(200, Math.max(1, opts.limit ?? 30)));
4807
+ const rows = this.db.query(`SELECT kind, ref, project_id, task, session_id, ts, title, text,
4808
+ bm25(memory, 0, 0, 0, 2.0, 0, 0, 4.0, 1.0) AS score,
4809
+ snippet(memory, 7, '\x01', '\x02', ' \u2026 ', 24) AS snippet
4810
+ FROM memory WHERE ${where.join(" AND ")} ORDER BY score LIMIT ?`).all(...args);
4811
+ return rows.map((r) => ({
4812
+ kind: r.kind,
4813
+ ref: r.ref,
4814
+ projectId: r.project_id,
4815
+ task: r.task ?? null,
4816
+ sessionId: r.session_id ?? null,
4817
+ ts: r.ts,
4818
+ title: r.title,
4819
+ text: r.text,
4820
+ score: -r.score,
4821
+ snippet: r.snippet
4822
+ }));
4823
+ }
3386
4824
  sessionContext(cwd) {
3387
- if (!cwd || !existsSync4(cwd))
4825
+ if (!cwd || !existsSync5(cwd))
3388
4826
  return null;
3389
4827
  const toplevel = this.toplevel(cwd);
3390
4828
  const project = this.resolveProject(cwd);
@@ -3396,6 +4834,9 @@ class Store {
3396
4834
  const h = this.latestHandoff(held.projectId, held.task);
3397
4835
  if (h)
3398
4836
  lines.push(formatHandoff(h));
4837
+ const qc = this.questionContext(held.task, held.projectId);
4838
+ if (qc)
4839
+ lines.push(qc);
3399
4840
  const required = this.requiredGates(held.projectId);
3400
4841
  if (required.length) {
3401
4842
  const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
@@ -3422,6 +4863,131 @@ class Store {
3422
4863
  lines.push(`[swarm] rules: ${on.join(" ")}`);
3423
4864
  return lines.length ? lines.join(`
3424
4865
  `) : null;
4866
+ }
4867
+ rowToQuestion(r) {
4868
+ return {
4869
+ id: r.id,
4870
+ projectId: r.project_id,
4871
+ sessionId: r.session_id ?? null,
4872
+ task: r.task ?? null,
4873
+ text: r.text,
4874
+ options: JSON.parse(r.options || "[]"),
4875
+ askedBy: r.asked_by ?? null,
4876
+ createdAt: r.created_at,
4877
+ answer: r.answer ?? null,
4878
+ answeredBy: r.answered_by ?? null,
4879
+ answeredAt: r.answered_at ?? null,
4880
+ deliveredAt: r.delivered_at ?? null
4881
+ };
4882
+ }
4883
+ questions(opts = {}) {
4884
+ const where = ["kind = 'question'"];
4885
+ const args = [];
4886
+ if (opts.projectId) {
4887
+ where.push("project_id = ?");
4888
+ args.push(opts.projectId);
4889
+ }
4890
+ if (opts.sessionId) {
4891
+ where.push("session_id = ?");
4892
+ args.push(opts.sessionId);
4893
+ }
4894
+ if (opts.open)
4895
+ where.push("answered_at IS NULL");
4896
+ args.push(opts.limit ?? 100);
4897
+ return this.db.query(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...args).map((r) => this.rowToQuestion(r));
4898
+ }
4899
+ question(id) {
4900
+ const r = this.db.query("SELECT * FROM messages WHERE id = ? AND kind = 'question'").get(id);
4901
+ return r ? this.rowToQuestion(r) : null;
4902
+ }
4903
+ ask(projectId, input) {
4904
+ if (!this.project(projectId))
4905
+ return { ok: false, error: "unknown project" };
4906
+ const v = validateQuestion(input.text, input.options);
4907
+ if (!v.ok)
4908
+ return { ok: false, error: v.reason };
4909
+ const sessionId = this.knownSession(input.sessionId ?? null);
4910
+ const task = (input.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(input.cwd, c.worktree))?.task : null) ?? null;
4911
+ const createdAt = new Date().toISOString();
4912
+ const r = this.db.query(`INSERT INTO messages (project_id, session_id, task, kind, text, options, asked_by, created_at)
4913
+ VALUES (?, ?, ?, 'question', ?, ?, ?, ?)`).run(projectId, sessionId, task, v.text, JSON.stringify(v.options), input.askedBy ?? null, createdAt);
4914
+ const q = this.question(Number(r.lastInsertRowid));
4915
+ this.append({
4916
+ ts: createdAt,
4917
+ type: "question.asked",
4918
+ projectId,
4919
+ sessionId,
4920
+ payload: {
4921
+ id: q.id,
4922
+ task,
4923
+ text: v.text,
4924
+ options: v.options,
4925
+ summary: `question #${q.id}: ${v.text.slice(0, 120)}`
4926
+ }
4927
+ });
4928
+ this.touch();
4929
+ return { ok: true, question: q };
4930
+ }
4931
+ answer(id, text, by) {
4932
+ const q = this.question(id);
4933
+ if (!q)
4934
+ return { ok: false, error: `no question #${id}` };
4935
+ if (q.answer !== null)
4936
+ return {
4937
+ ok: false,
4938
+ error: `#${id} was already answered by ${q.answeredBy ?? "someone"}`
4939
+ };
4940
+ const a = typeof text === "string" ? text.trim() : "";
4941
+ if (!a)
4942
+ return { ok: false, error: "an answer is required" };
4943
+ const at = new Date().toISOString();
4944
+ this.db.query("UPDATE messages SET answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(a, by, at, id);
4945
+ this.append({
4946
+ ts: at,
4947
+ type: "question.answered",
4948
+ projectId: q.projectId,
4949
+ sessionId: q.sessionId,
4950
+ payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
4951
+ });
4952
+ this.touch();
4953
+ return { ok: true, question: this.question(id) };
4954
+ }
4955
+ inbox(sessionId, opts = {}) {
4956
+ if (!sessionId)
4957
+ return [];
4958
+ const rows = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND session_id = ? AND answered_at IS NOT NULL AND delivered_at IS NULL ORDER BY id").all(sessionId);
4959
+ const qs = rows.map((r) => this.rowToQuestion(r));
4960
+ if (qs.length && !opts.peek)
4961
+ this.db.query(`UPDATE messages SET delivered_at = ? WHERE id IN (${qs.map(() => "?").join(",")})`).run(new Date().toISOString(), ...qs.map((q) => q.id));
4962
+ return qs;
4963
+ }
4964
+ answerContext(sessionId) {
4965
+ return formatAnswers(this.inbox(sessionId));
4966
+ }
4967
+ questionContext(task, projectId) {
4968
+ if (!task)
4969
+ return null;
4970
+ const qs = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND project_id = ? AND task = ? AND (answered_at IS NULL OR delivered_at IS NULL) ORDER BY id").all(projectId, task);
4971
+ const list = qs.map((r) => this.rowToQuestion(r));
4972
+ const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
4973
+ if (list.some((q) => q.answer !== null))
4974
+ this.db.query("UPDATE messages SET delivered_at = ? WHERE kind = 'question' AND project_id = ? AND task = ? AND answered_at IS NOT NULL AND delivered_at IS NULL").run(new Date().toISOString(), projectId, task);
4975
+ return parts.length ? parts.join(`
4976
+ `) : null;
4977
+ }
4978
+ contextFor(cwd, sessionId) {
4979
+ const parts = [];
4980
+ const base = this.sessionContext(cwd);
4981
+ if (base)
4982
+ parts.push(base);
4983
+ const answers = this.answerContext(sessionId);
4984
+ if (answers)
4985
+ parts.push(answers);
4986
+ const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
4987
+ if (open && !base?.includes(open))
4988
+ parts.push(open);
4989
+ return { text: parts.length ? parts.join(`
4990
+ `) : null, parts };
3425
4991
  }
3426
4992
  rowToGate(r) {
3427
4993
  return {
@@ -3443,6 +5009,187 @@ class Store {
3443
5009
  gateStatusFor(runs, required) {
3444
5010
  return gateStatus(runs, required);
3445
5011
  }
5012
+ config(projectId) {
5013
+ const p = this.project(projectId);
5014
+ return loadConfig({ repoRoot: p?.root ?? null, home: this.home });
5015
+ }
5016
+ gateDefs(projectId) {
5017
+ const p = this.project(projectId);
5018
+ return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates : null;
5019
+ }
5020
+ gateJobs = new Map;
5021
+ gateBatches = new Map;
5022
+ async awaitGates(projectId, task) {
5023
+ const prefix = `${projectId}:${task}:`;
5024
+ await Promise.all([
5025
+ ...[...this.gateJobs].filter(([k]) => k.startsWith(prefix)).map(([, v]) => v),
5026
+ ...this.gateBatches.get(`${projectId}:${task}`) ?? []
5027
+ ]);
5028
+ }
5029
+ runGate(projectId, task, gate, opts = {}) {
5030
+ const p = this.project(projectId);
5031
+ if (!p)
5032
+ return { ok: false, reason: "unknown project" };
5033
+ const cfg = this.gateDefs(projectId);
5034
+ const def = cfg?.defs[gate];
5035
+ if (!def)
5036
+ return {
5037
+ ok: false,
5038
+ reason: `gate ${gate} has no command \u2014 add [gates.${gate}] cmd = "\u2026" to .swarm.toml, or record it with swarm gate record`
5039
+ };
5040
+ const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
5041
+ const worktree2 = claim?.worktree;
5042
+ if (!worktree2 || !existsSync5(worktree2))
5043
+ return {
5044
+ ok: false,
5045
+ reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
5046
+ };
5047
+ const cwd = def.cwd ? join8(worktree2, def.cwd) : worktree2;
5048
+ if (!existsSync5(cwd))
5049
+ return { ok: false, reason: `gate cwd ${cwd} does not exist` };
5050
+ const key = `${projectId}:${task}:${gate}`;
5051
+ if (this.gateJobs.has(key))
5052
+ return { ok: false, reason: `${gate} is already running on ${task}` };
5053
+ const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
5054
+ const logDir = join8(this.home, "logs", projectId);
5055
+ mkdirSync4(logDir, { recursive: true });
5056
+ const log = join8(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
5057
+ writeFileSync2(log, `$ ${def.cmd}
5058
+ # cwd ${cwd} \xB7 ${new Date().toISOString()}
5059
+ `);
5060
+ const fd = openSync3(log, "a");
5061
+ let proc;
5062
+ try {
5063
+ proc = Bun.spawn(["sh", "-c", def.cmd], {
5064
+ cwd,
5065
+ stdin: "ignore",
5066
+ stdout: fd,
5067
+ stderr: fd,
5068
+ env: {
5069
+ ...process.env,
5070
+ SWARM_WORKTREE: worktree2,
5071
+ SWARM_TASK: task,
5072
+ SWARM_GATE: gate,
5073
+ CI: process.env.CI ?? "1"
5074
+ }
5075
+ });
5076
+ } catch (e) {
5077
+ closeSync(fd);
5078
+ const run2 = this.recordGate(projectId, {
5079
+ ...executedGateInput(task, gate, def.cmd, {
5080
+ exitCode: null,
5081
+ durationMs: 0,
5082
+ output: e.message
5083
+ }),
5084
+ sessionId: opts.sessionId ?? null
5085
+ });
5086
+ return { ok: true, pid: 0, log, done: Promise.resolve(run2.ok ? run2.run : null) };
5087
+ }
5088
+ const started = Date.now();
5089
+ const reg = this.registerProcess({
5090
+ pid: proc.pid,
5091
+ projectId,
5092
+ sessionId: opts.sessionId ?? null,
5093
+ kind: "gate",
5094
+ name: `gate:${task}:${gate}`,
5095
+ cwd,
5096
+ cmd: def.cmd,
5097
+ owner: opts.owner ?? "daemon",
5098
+ log
5099
+ });
5100
+ let timedOut = false;
5101
+ const timer = setTimeout(() => {
5102
+ timedOut = true;
5103
+ try {
5104
+ proc.kill("SIGTERM");
5105
+ setTimeout(() => {
5106
+ try {
5107
+ proc.kill("SIGKILL");
5108
+ } catch {}
5109
+ }, 5000).unref();
5110
+ } catch {}
5111
+ }, def.timeout * 1000);
5112
+ const done = proc.exited.then((code) => {
5113
+ clearTimeout(timer);
5114
+ closeSync(fd);
5115
+ let output = "";
5116
+ try {
5117
+ output = readFileSync3(log, "utf8");
5118
+ } catch {}
5119
+ const input = executedGateInput(task, gate, def.cmd, {
5120
+ exitCode: timedOut ? null : code,
5121
+ timedOut,
5122
+ durationMs: Date.now() - started,
5123
+ output
5124
+ });
5125
+ const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
5126
+ if (reg.ok)
5127
+ this.processes(projectId);
5128
+ return run2.ok ? run2.run : null;
5129
+ }).finally(() => {
5130
+ this.gateJobs.delete(key);
5131
+ this.touch();
5132
+ });
5133
+ this.gateJobs.set(key, done);
5134
+ return { ok: true, pid: proc.pid, log, done };
5135
+ }
5136
+ async runGates(projectId, task, gates2, opts = {}) {
5137
+ const cfg = this.gateDefs(projectId);
5138
+ const names = gates2?.length ? gates2 : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
5139
+ const key = `${projectId}:${task}`;
5140
+ const batch = (async () => {
5141
+ const started = [];
5142
+ const skipped = [];
5143
+ const runs = [];
5144
+ for (const g of names) {
5145
+ const r = this.runGate(projectId, task, g, opts);
5146
+ if (!r.ok) {
5147
+ skipped.push({ gate: g, reason: r.reason });
5148
+ continue;
5149
+ }
5150
+ started.push(g);
5151
+ const run2 = await r.done;
5152
+ if (run2)
5153
+ runs.push(run2);
5154
+ }
5155
+ return { started, skipped, runs };
5156
+ })();
5157
+ const set = this.gateBatches.get(key) ?? new Set;
5158
+ set.add(batch);
5159
+ this.gateBatches.set(key, set);
5160
+ try {
5161
+ return await batch;
5162
+ } finally {
5163
+ set.delete(batch);
5164
+ if (!set.size)
5165
+ this.gateBatches.delete(key);
5166
+ }
5167
+ }
5168
+ autoGateAt = new Map;
5169
+ autoGate(event, sessionId, cwd) {
5170
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
5171
+ if (!held)
5172
+ return;
5173
+ const cfg = this.gateDefs(held.projectId);
5174
+ if (!cfg || cfg.auto === "off")
5175
+ return;
5176
+ if (cfg.auto === "session-end" && event !== "SessionEnd")
5177
+ return;
5178
+ if (!cfg.required.some((g) => cfg.defs[g]))
5179
+ return;
5180
+ const key = `${held.projectId}:${held.task}`;
5181
+ const now = Date.now();
5182
+ if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
5183
+ return;
5184
+ this.autoGateAt.set(key, now);
5185
+ this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => {
5186
+ if (!r.runs.length)
5187
+ return;
5188
+ const line = r.runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
5189
+ this.db.query("UPDATE handoffs SET verify = ? WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(`auto-gates: ${line}`, held.projectId, held.task, sessionId);
5190
+ this.touch();
5191
+ });
5192
+ }
3446
5193
  requiredGates(projectId) {
3447
5194
  const p = this.project(projectId);
3448
5195
  return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates.required : [];
@@ -3458,6 +5205,7 @@ class Store {
3458
5205
  const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
3459
5206
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
3460
5207
  const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
5208
+ this.remember(gateDoc(projectId, run2.id, run2, sessionId));
3461
5209
  this.append({
3462
5210
  ts: createdAt,
3463
5211
  type: "gate.recorded",
@@ -3487,21 +5235,36 @@ class Store {
3487
5235
  return { ok: true, run: run2 };
3488
5236
  }
3489
5237
  taskCache = new Map;
5238
+ taskSources = new TaskSources;
3490
5239
  tasks(projectId) {
3491
5240
  const p = this.project(projectId);
3492
5241
  if (!p)
3493
5242
  return null;
3494
- const source = loadConfig({ repoRoot: p.root, home: this.home }).tasks.source;
5243
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).tasks;
5244
+ const source = cfg.source;
3495
5245
  if (!source)
3496
5246
  return null;
3497
- const path = join6(p.root, source);
3498
- if (!existsSync4(path))
3499
- return { source, required: this.requiredGates(projectId), tasks: [] };
3500
- const mtime = statSync(path).mtimeMs;
3501
- let hit = this.taskCache.get(projectId);
3502
- if (!hit || hit.path !== path || hit.mtime !== mtime) {
3503
- hit = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
3504
- this.taskCache.set(projectId, hit);
5247
+ let hit;
5248
+ let error = null;
5249
+ const kind = taskSourceKind(source);
5250
+ if (kind === "github" || kind === "linear") {
5251
+ const e = this.taskSources.get(projectId, kind, p.root, {
5252
+ labels: cfg.labels,
5253
+ team: cfg.team
5254
+ });
5255
+ hit = { tasks: e.tasks };
5256
+ error = e.error;
5257
+ } else {
5258
+ const path = join8(p.root, source);
5259
+ if (!existsSync5(path))
5260
+ return { source, required: this.requiredGates(projectId), tasks: [] };
5261
+ const mtime = statSync(path).mtimeMs;
5262
+ let md = this.taskCache.get(projectId);
5263
+ if (!md || md.path !== path || md.mtime !== mtime) {
5264
+ md = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
5265
+ this.taskCache.set(projectId, md);
5266
+ }
5267
+ hit = md;
3505
5268
  }
3506
5269
  const now = Date.now();
3507
5270
  const active = this.claimRows(projectId).filter((c) => isActive(c, now));
@@ -3523,7 +5286,7 @@ class Store {
3523
5286
  gated: gatesSatisfied(tr, required)
3524
5287
  };
3525
5288
  });
3526
- return { source, required, tasks: board };
5289
+ return { source, required, tasks: board, error };
3527
5290
  }
3528
5291
  rulesFor(repoRoot) {
3529
5292
  const key = repoRoot ?? "";
@@ -3535,6 +5298,18 @@ class Store {
3535
5298
  return rules2;
3536
5299
  }
3537
5300
  evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
5301
+ if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync5(cwd)) {
5302
+ const project = this.resolveProject(cwd);
5303
+ const b = this.budgetFor(project.id);
5304
+ if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
5305
+ const d = {
5306
+ action: "ask",
5307
+ rule: "budget",
5308
+ reason: `${budgetMessage(b.status, project.name)} \u2014 [budget] on_exceed = "ask": confirm each change, or raise the ceiling in .swarm.toml`
5309
+ };
5310
+ return { decision: d, display: input.command ?? input.file_path ?? tool };
5311
+ }
5312
+ }
3538
5313
  const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
3539
5314
  const cmd = tool === "Bash" ? input.command : undefined;
3540
5315
  const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
@@ -3614,7 +5389,7 @@ class Store {
3614
5389
  return this.openIncident(d, cwd, id, cmd);
3615
5390
  }
3616
5391
  openIncident(d, cwd, sessionId, command) {
3617
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
5392
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
3618
5393
  this.append({
3619
5394
  ts: new Date().toISOString(),
3620
5395
  type: "incident.opened",
@@ -3625,6 +5400,51 @@ class Store {
3625
5400
  return d;
3626
5401
  }
3627
5402
  heldWorktreesCache = null;
5403
+ dryRun(projectId, overrides = {}, limit = 5000) {
5404
+ const project = this.project(projectId);
5405
+ const modes = { ...this.rulesFor(project?.root ?? null), ...overrides };
5406
+ const rows = this.db.query(`SELECT * FROM (SELECT seq, ts, type, session_id, payload FROM events
5407
+ WHERE project_id = ? AND type IN ('tool.requested', 'tool.completed')
5408
+ ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(projectId, limit);
5409
+ const calls = [];
5410
+ const pending = new Map;
5411
+ for (const r of rows) {
5412
+ let p;
5413
+ try {
5414
+ p = JSON.parse(r.payload);
5415
+ } catch {
5416
+ continue;
5417
+ }
5418
+ if (!p.tool || !r.session_id)
5419
+ continue;
5420
+ const key = `${r.session_id} ${p.summary ?? p.tool}`;
5421
+ if (r.type === "tool.requested") {
5422
+ const input = p.toolInput ?? {};
5423
+ const call = {
5424
+ ts: r.ts,
5425
+ sessionId: r.session_id,
5426
+ cwd: p.cwd ?? "",
5427
+ tool: p.tool,
5428
+ command: typeof input.command === "string" ? input.command : undefined,
5429
+ filePath: typeof input.file_path === "string" ? input.file_path : undefined,
5430
+ completed: false
5431
+ };
5432
+ calls.push(call);
5433
+ pending.set(key, call);
5434
+ } else {
5435
+ const c = pending.get(key);
5436
+ if (c) {
5437
+ c.completed = true;
5438
+ pending.delete(key);
5439
+ }
5440
+ }
5441
+ }
5442
+ const report = dryRunRules(calls, modes, {
5443
+ toplevel: (cwd) => cwd && existsSync5(cwd) ? this.toplevel(cwd) : null,
5444
+ claims: this.heldWorktrees()
5445
+ });
5446
+ return { ...report, modes };
5447
+ }
3628
5448
  heldWorktrees() {
3629
5449
  if (this.heldWorktreesCache && Date.now() - this.heldWorktreesCache.at < 2000)
3630
5450
  return this.heldWorktreesCache.v;
@@ -3635,8 +5455,8 @@ class Store {
3635
5455
  loadPricing() {
3636
5456
  this.prices = { ...PRICES };
3637
5457
  for (const f of ["pricing.litellm.json", "pricing.json"]) {
3638
- const p = join6(this.home, f);
3639
- if (!existsSync4(p))
5458
+ const p = join8(this.home, f);
5459
+ if (!existsSync5(p))
3640
5460
  continue;
3641
5461
  try {
3642
5462
  const j = JSON.parse(readFileSync3(p, "utf8"));
@@ -3651,7 +5471,7 @@ class Store {
3651
5471
  throw new Error(`pricing fetch ${r.status}`);
3652
5472
  const j = await r.json();
3653
5473
  const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
3654
- writeFileSync2(join6(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
5474
+ writeFileSync2(join8(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
3655
5475
  this.loadPricing();
3656
5476
  this.reprice();
3657
5477
  }
@@ -3773,6 +5593,8 @@ class Store {
3773
5593
  const slim = slimForStorage(e);
3774
5594
  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));
3775
5595
  const stored = { ...e, seq: Number(r.lastInsertRowid) };
5596
+ if (stored.type === "incident.opened")
5597
+ this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
3776
5598
  this.projectSession(stored);
3777
5599
  this.touch();
3778
5600
  const wire = toWire(stored);
@@ -3793,8 +5615,15 @@ class Store {
3793
5615
  if (typeof raw2.cwd === "string")
3794
5616
  this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
3795
5617
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
3796
- const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
5618
+ const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
3797
5619
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
5620
+ if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
5621
+ if (existsSync5(cwd)) {
5622
+ this.autoHandoff(e.sessionId, cwd);
5623
+ this.autoGate(event, e.sessionId, cwd);
5624
+ }
5625
+ this.rememberSession(e.sessionId);
5626
+ }
3798
5627
  if (e.sessionId && typeof raw2.transcript_path === "string") {
3799
5628
  this.db.query("UPDATE sessions SET transcript_path = ? WHERE id = ? AND transcript_path IS NULL").run(raw2.transcript_path, e.sessionId);
3800
5629
  const last = this.lastTail.get(e.sessionId) ?? 0;
@@ -3815,6 +5644,15 @@ class Store {
3815
5644
  "claim.renewed",
3816
5645
  "claim.released",
3817
5646
  "claim.orphaned",
5647
+ "worktree.bootstrapped",
5648
+ "worktree.created",
5649
+ "worktree.removed",
5650
+ "pr.opened",
5651
+ "question.asked",
5652
+ "question.answered",
5653
+ "dispatch.queued",
5654
+ "dispatch.started",
5655
+ "dispatch.finished",
3818
5656
  "gate.recorded",
3819
5657
  "handoff.recorded",
3820
5658
  "incident.opened",
@@ -3826,7 +5664,7 @@ class Store {
3826
5664
  return;
3827
5665
  const p = e.payload;
3828
5666
  const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
3829
- const branch = p.cwd && existsSync4(p.cwd) ? currentBranch(p.cwd) : null;
5667
+ const branch = p.cwd && existsSync5(p.cwd) ? currentBranch(p.cwd) : null;
3830
5668
  if (!row) {
3831
5669
  this.db.query("INSERT INTO sessions (id, project_id, kind, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, ?, 'active')").run(e.sessionId, e.projectId, p.cwd ?? "", branch, e.ts, e.ts, p.summary ?? e.type, e.type);
3832
5670
  }
@@ -3846,7 +5684,7 @@ class Store {
3846
5684
  const size = statSync(path).size;
3847
5685
  if (size <= offset)
3848
5686
  return null;
3849
- const fd = openSync2(path, "r");
5687
+ const fd = openSync3(path, "r");
3850
5688
  const buf = Buffer.alloc(size - offset);
3851
5689
  readSync(fd, buf, 0, buf.length, offset);
3852
5690
  closeSync(fd);
@@ -3896,12 +5734,12 @@ class Store {
3896
5734
  }
3897
5735
  tailSession(sessionId) {
3898
5736
  const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
3899
- if (!s?.transcript_path || !existsSync4(s.transcript_path))
5737
+ if (!s?.transcript_path || !existsSync5(s.transcript_path))
3900
5738
  return 0;
3901
5739
  let n = this.tailFile(s.transcript_path, sessionId, null);
3902
- const subDir = join6(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
5740
+ const subDir = join8(dirname2(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
3903
5741
  for (const f of this.subagentFiles(subDir)) {
3904
- n += this.tailFile(join6(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
5742
+ n += this.tailFile(join8(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
3905
5743
  }
3906
5744
  return n;
3907
5745
  }
@@ -3933,7 +5771,7 @@ class Store {
3933
5771
  return n;
3934
5772
  }
3935
5773
  codexRoot() {
3936
- return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
5774
+ return process.env.SWARM_CODEX_DIR ?? join8(homedir3(), ".codex", "sessions");
3937
5775
  }
3938
5776
  codexRolloutFiles(sinceMs) {
3939
5777
  const root = this.codexRoot();
@@ -3948,18 +5786,18 @@ class Store {
3948
5786
  for (const y of ls(root)) {
3949
5787
  if (!/^\d{4}$/.test(y))
3950
5788
  continue;
3951
- for (const m of ls(join6(root, y))) {
5789
+ for (const m of ls(join8(root, y))) {
3952
5790
  if (!/^\d\d$/.test(m))
3953
5791
  continue;
3954
- for (const day of ls(join6(root, y, m))) {
5792
+ for (const day of ls(join8(root, y, m))) {
3955
5793
  if (!/^\d\d$/.test(day))
3956
5794
  continue;
3957
5795
  if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
3958
5796
  continue;
3959
- const dir = join6(root, y, m, day);
5797
+ const dir = join8(root, y, m, day);
3960
5798
  for (const f of ls(dir)) {
3961
5799
  if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
3962
- out.push(join6(dir, f));
5800
+ out.push(join8(dir, f));
3963
5801
  }
3964
5802
  }
3965
5803
  }
@@ -3967,7 +5805,7 @@ class Store {
3967
5805
  return out;
3968
5806
  }
3969
5807
  tailCodex(windowMs = 3 * 24 * 60 * 60000) {
3970
- if (!existsSync4(this.codexRoot()))
5808
+ if (!existsSync5(this.codexRoot()))
3971
5809
  return 0;
3972
5810
  let n = 0;
3973
5811
  for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
@@ -3976,12 +5814,12 @@ class Store {
3976
5814
  return n;
3977
5815
  }
3978
5816
  grokRoot() {
3979
- return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
5817
+ return process.env.SWARM_GROK_DIR ?? join8(homedir3(), ".grok", "sessions");
3980
5818
  }
3981
5819
  grokSummary = new Map;
3982
5820
  tailGrok(windowMs = 3 * 24 * 60 * 60000) {
3983
5821
  const root = this.grokRoot();
3984
- if (!existsSync4(root))
5822
+ if (!existsSync5(root))
3985
5823
  return 0;
3986
5824
  const since = Date.now() - windowMs;
3987
5825
  const ls = (p) => {
@@ -4001,10 +5839,10 @@ class Store {
4001
5839
  } catch {
4002
5840
  cwd = enc;
4003
5841
  }
4004
- const cwdDir = join6(root, enc);
5842
+ const cwdDir = join8(root, enc);
4005
5843
  for (const sid of ls(cwdDir)) {
4006
- const path = join6(cwdDir, sid, "updates.jsonl");
4007
- if (!existsSync4(path))
5844
+ const path = join8(cwdDir, sid, "updates.jsonl");
5845
+ if (!existsSync5(path))
4008
5846
  continue;
4009
5847
  try {
4010
5848
  if (statSync(path).mtimeMs < since)
@@ -4012,7 +5850,7 @@ class Store {
4012
5850
  } catch {
4013
5851
  continue;
4014
5852
  }
4015
- const sumPath = join6(cwdDir, sid, "summary.json");
5853
+ const sumPath = join8(cwdDir, sid, "summary.json");
4016
5854
  let title;
4017
5855
  let fresh = false;
4018
5856
  try {
@@ -4063,9 +5901,9 @@ class Store {
4063
5901
  ensureAgentSession(sid, agent, cwd, mtime) {
4064
5902
  if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
4065
5903
  return;
4066
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
5904
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
4067
5905
  const ts = new Date(mtime).toISOString();
4068
- this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd && existsSync4(cwd) ? currentBranch(cwd) : null, ts, ts);
5906
+ this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd && existsSync5(cwd) ? currentBranch(cwd) : null, ts, ts);
4069
5907
  }
4070
5908
  claimRows(projectId) {
4071
5909
  return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
@@ -4099,7 +5937,7 @@ class Store {
4099
5937
  worktreePath(projectId, task) {
4100
5938
  const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
4101
5939
  const p = this.project(projectId);
4102
- return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
5940
+ return join8(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
4103
5941
  }
4104
5942
  claim(projectId, task, owner, baseRef = "HEAD") {
4105
5943
  const p = this.project(projectId);
@@ -4110,11 +5948,11 @@ class Store {
4110
5948
  if (!decision.ok)
4111
5949
  return { ok: false, error: claimRefusalMessage(decision, task) };
4112
5950
  const branch = `task/${task}`;
4113
- const worktree = this.worktreePath(projectId, task);
4114
- if (existsSync4(worktree))
4115
- return { ok: false, error: `${worktree} already exists; release ${task} first` };
4116
- mkdirSync3(dirname(worktree), { recursive: true });
4117
- const created = worktreeAdd(p.root, worktree, branch, baseRef);
5951
+ const worktree2 = this.worktreePath(projectId, task);
5952
+ if (existsSync5(worktree2))
5953
+ return { ok: false, error: `${worktree2} already exists; release ${task} first` };
5954
+ mkdirSync4(dirname2(worktree2), { recursive: true });
5955
+ const created = worktreeAdd(p.root, worktree2, branch, baseRef);
4118
5956
  if (!created)
4119
5957
  return { ok: false, error: `git worktree add failed for ${task}` };
4120
5958
  this.invalidateWorktrees(projectId);
@@ -4131,7 +5969,54 @@ class Store {
4131
5969
  sessionId: null,
4132
5970
  payload: { task, owner, worktree: created, branch, summary: `claim ${task} by ${owner}` }
4133
5971
  });
4134
- return { ok: true, task, owner, worktree: created, branch, expiresAt };
5972
+ const bootstrap = this.bootstrapWorktree(projectId, task, p.root, created);
5973
+ return { ok: true, task, owner, worktree: created, branch, expiresAt, bootstrap };
5974
+ }
5975
+ bootstraps = new Map;
5976
+ bootstrapWorktree(projectId, task, repoRoot, worktree2) {
5977
+ const plan = planBootstrap(loadConfig({ repoRoot, home: this.home }), repoRoot, worktree2);
5978
+ if (!needsBootstrap(plan))
5979
+ return null;
5980
+ const job = runBootstrap(plan, { worktree: worktree2, home: this.home, projectId, task });
5981
+ const done = job.done.then((o) => {
5982
+ this.bootstraps.delete(worktree2);
5983
+ const ts = new Date().toISOString();
5984
+ const ok = !o.setup || o.setup.exitCode === 0;
5985
+ this.append({
5986
+ ts,
5987
+ type: "worktree.bootstrapped",
5988
+ projectId,
5989
+ sessionId: null,
5990
+ payload: {
5991
+ task,
5992
+ worktree: worktree2,
5993
+ ok,
5994
+ log: job.log,
5995
+ ...o,
5996
+ summary: `bootstrap ${task}: ${summarizeBootstrap(o)}`
5997
+ }
5998
+ });
5999
+ if (!ok)
6000
+ this.append({
6001
+ ts,
6002
+ type: "incident.opened",
6003
+ projectId,
6004
+ sessionId: null,
6005
+ payload: {
6006
+ rule: "bootstrap_failed",
6007
+ action: "failed",
6008
+ command: o.setup?.command ?? "",
6009
+ reason: `worktree setup for ${task} exited ${o.setup?.exitCode} \u2014 see ${job.log}`
6010
+ }
6011
+ });
6012
+ this.touch();
6013
+ return o;
6014
+ });
6015
+ this.bootstraps.set(worktree2, done);
6016
+ return job.log;
6017
+ }
6018
+ awaitBootstrap(worktree2) {
6019
+ return this.bootstraps.get(worktree2) ?? Promise.resolve();
4135
6020
  }
4136
6021
  autoRenewAt = new Map;
4137
6022
  autoRenewFor(sessionId, cwd) {
@@ -4174,7 +6059,7 @@ class Store {
4174
6059
  for (const c of this.claimRows(p.id)) {
4175
6060
  if (c.state !== "held" || isActive(c, now))
4176
6061
  continue;
4177
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6062
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4178
6063
  const work = exists ? heldWork(c.worktree) : null;
4179
6064
  if (reapAction(c, now, exists, work) !== "keep-orphaned")
4180
6065
  continue;
@@ -4229,18 +6114,18 @@ class Store {
4229
6114
  const row = this.db.query("SELECT * FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
4230
6115
  if (!row)
4231
6116
  return { ok: false, error: `no claim on ${task}` };
4232
- const worktree = row.worktree ?? "";
4233
- if (worktree && existsSync4(worktree)) {
4234
- const work = heldWork(worktree);
6117
+ const worktree2 = row.worktree ?? "";
6118
+ if (worktree2 && existsSync5(worktree2)) {
6119
+ const work = heldWork(worktree2);
4235
6120
  const can = canRelease(work, force);
4236
6121
  if (!can.ok)
4237
6122
  return {
4238
6123
  ok: false,
4239
- error: releaseRefusalMessage(can, worktree),
6124
+ error: releaseRefusalMessage(can, worktree2),
4240
6125
  refused: can.reason
4241
6126
  };
4242
- if (p && !worktreeRemove(p.root, worktree, force))
4243
- return { ok: false, error: `git worktree remove failed for ${worktree}` };
6127
+ if (p && !worktreeRemove(p.root, worktree2, force))
6128
+ return { ok: false, error: `git worktree remove failed for ${worktree2}` };
4244
6129
  this.invalidateWorktrees(projectId);
4245
6130
  }
4246
6131
  const releasedAt = new Date().toISOString();
@@ -4265,7 +6150,7 @@ class Store {
4265
6150
  continue;
4266
6151
  if (isActive({ ...c, state: "held" }, now))
4267
6152
  continue;
4268
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6153
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4269
6154
  const work = exists ? heldWork(c.worktree) : null;
4270
6155
  const action = reapAction({ ...c, state: "held" }, now, exists, work);
4271
6156
  if (action === "not-expired")
@@ -4356,6 +6241,141 @@ class Store {
4356
6241
  this.wtInflight.set(projectId, run2);
4357
6242
  return run2;
4358
6243
  }
6244
+ createWorktree(projectId, name, baseRef = "HEAD", branch) {
6245
+ const p = this.project(projectId);
6246
+ if (!p)
6247
+ return { ok: false, error: "unknown project" };
6248
+ const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
6249
+ if (!slug || slug === "." || slug === "..")
6250
+ return { ok: false, error: "bad worktree name" };
6251
+ const path = this.worktreePath(projectId, slug);
6252
+ if (existsSync5(path))
6253
+ return { ok: false, error: `${path} already exists` };
6254
+ mkdirSync4(dirname2(path), { recursive: true });
6255
+ const br = branch?.trim() || `wt/${slug}`;
6256
+ const created = worktreeAdd(p.root, path, br, baseRef);
6257
+ if (!created)
6258
+ return { ok: false, error: `git worktree add failed for ${name}` };
6259
+ this.invalidateWorktrees(projectId);
6260
+ this.append({
6261
+ ts: new Date().toISOString(),
6262
+ type: "worktree.created",
6263
+ projectId,
6264
+ sessionId: null,
6265
+ payload: { name: slug, worktree: created, branch: br, summary: `worktree ${slug} created` }
6266
+ });
6267
+ const bootstrap = this.bootstrapWorktree(projectId, slug, p.root, created);
6268
+ return { ok: true, name: slug, worktree: created, branch: br, bootstrap };
6269
+ }
6270
+ findWorktree(projectId, ref) {
6271
+ const wts = this.wtCache.get(projectId)?.v ?? [];
6272
+ const abs = ref.startsWith("/") ? ref.replace(/\/+$/, "") : null;
6273
+ return wts.find((w) => w.path === abs) ?? wts.find((w) => !w.main && basename(w.path) === ref) ?? wts.find((w) => w.branch === ref) ?? null;
6274
+ }
6275
+ async removeWorktree(projectId, ref, force = false) {
6276
+ const p = this.project(projectId);
6277
+ if (!p)
6278
+ return { ok: false, error: "unknown project" };
6279
+ await this.refreshWorktrees(projectId);
6280
+ const w = this.findWorktree(projectId, ref);
6281
+ if (!w)
6282
+ return { ok: false, error: `no worktree ${ref} in ${p.name}` };
6283
+ const held = this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path);
6284
+ const can = canRemoveWorktree(w, held?.task ?? null, force);
6285
+ if (!can.ok)
6286
+ return {
6287
+ ok: false,
6288
+ error: removeRefusalMessage(can.reason, w.path, held?.task),
6289
+ refused: can.reason
6290
+ };
6291
+ if (!worktreeRemove(p.root, w.path, force))
6292
+ return { ok: false, error: `git worktree remove failed for ${w.path}` };
6293
+ this.invalidateWorktrees(projectId);
6294
+ this.append({
6295
+ ts: new Date().toISOString(),
6296
+ type: "worktree.removed",
6297
+ projectId,
6298
+ sessionId: null,
6299
+ payload: {
6300
+ worktree: w.path,
6301
+ branch: w.branch,
6302
+ force,
6303
+ summary: `worktree ${basename(w.path)} removed`
6304
+ }
6305
+ });
6306
+ return { ok: true, worktree: w.path };
6307
+ }
6308
+ async gcWorktrees(projectId, apply = false) {
6309
+ await this.refreshWorktrees(projectId);
6310
+ const plan = planGc(this.wtCache.get(projectId)?.v ?? [], this.claims(projectId));
6311
+ const removed = [];
6312
+ if (apply)
6313
+ for (const c of plan) {
6314
+ if (!c.removable)
6315
+ continue;
6316
+ const r = await this.removeWorktree(projectId, c.path, false);
6317
+ if (r.ok)
6318
+ removed.push(c.path);
6319
+ }
6320
+ return { candidates: plan, removed };
6321
+ }
6322
+ openWorktree(projectId, ref) {
6323
+ const p = this.project(projectId);
6324
+ if (!p)
6325
+ return { ok: false, error: "unknown project" };
6326
+ const w = this.findWorktree(projectId, ref);
6327
+ if (!w)
6328
+ return { ok: false, error: `no worktree ${ref}` };
6329
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).worktree.open;
6330
+ const cmd = cfg ? ["sh", "-c", cfg.replace(/\{path\}/g, `'${w.path.replace(/'/g, "'\\''")}'`)] : [
6331
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open",
6332
+ w.path
6333
+ ];
6334
+ try {
6335
+ Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
6336
+ return { ok: true, worktree: w.path, command: cmd.join(" ") };
6337
+ } catch (e) {
6338
+ return { ok: false, error: e.message };
6339
+ }
6340
+ }
6341
+ async prDraftFor(projectId, ref) {
6342
+ const p = this.project(projectId);
6343
+ if (!p)
6344
+ return { ok: false, error: "unknown project" };
6345
+ await this.refreshWorktrees(projectId);
6346
+ const claim = this.claims(projectId).find((c) => c.task === ref && c.state === "held");
6347
+ const w = this.findWorktree(projectId, claim?.worktree ?? ref);
6348
+ if (!w)
6349
+ return { ok: false, error: `no worktree or held task ${ref}` };
6350
+ const task = claim?.task ?? this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path)?.task ?? (w.branch?.startsWith("task/") ? w.branch.slice(5) : null) ?? basename(w.path);
6351
+ const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
6352
+ const handoff = this.latestHandoff(projectId, task);
6353
+ const required = this.requiredGates(projectId);
6354
+ const gates2 = required.length ? this.gateStatusFor(this.gateRuns(projectId, task), required).map((g) => ({
6355
+ gate: g.gate,
6356
+ verdict: g.verdict
6357
+ })) : [];
6358
+ const diff = await worktreeDiff(p.root, w.path);
6359
+ const d = prDraft({
6360
+ task,
6361
+ title: taskRow?.title ?? null,
6362
+ handoff,
6363
+ gates: gates2,
6364
+ files: diff.files,
6365
+ commits: diff.commits
6366
+ });
6367
+ return { ok: true, task, worktree: w, ...d, diff };
6368
+ }
6369
+ recordPrOpened(projectId, task, worktree2, url) {
6370
+ this.append({
6371
+ ts: new Date().toISOString(),
6372
+ type: "pr.opened",
6373
+ projectId,
6374
+ sessionId: null,
6375
+ payload: { task, worktree: worktree2, url, summary: `PR opened for ${task}: ${url}` }
6376
+ });
6377
+ this.touch();
6378
+ }
4359
6379
  invalidateWorktrees(projectId) {
4360
6380
  if (projectId)
4361
6381
  this.wtCache.delete(projectId);
@@ -4425,6 +6445,56 @@ class Store {
4425
6445
  };
4426
6446
  });
4427
6447
  }
6448
+ projectSpend(projectId) {
6449
+ const dayStart = new Date;
6450
+ dayStart.setHours(0, 0, 0, 0);
6451
+ const weekStart = new Date(Date.now() - 7 * 86400000).toISOString();
6452
+ const q = (since) => this.db.query("SELECT COALESCE(SUM(t.cost_usd), 0) AS cost FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.project_id = ? AND t.ts >= ?").get(projectId, since).cost;
6453
+ return { today: q(dayStart.toISOString()), week: q(weekStart) };
6454
+ }
6455
+ budgetFor(projectId) {
6456
+ const cfg = this.config(projectId).budget;
6457
+ if (!cfg.daily && !cfg.weekly)
6458
+ return null;
6459
+ return { status: budgetStatus(this.projectSpend(projectId), cfg), config: cfg };
6460
+ }
6461
+ budgetNotified = new Map;
6462
+ budgetListeners = new Set;
6463
+ onBudgetStop(fn) {
6464
+ this.budgetListeners.add(fn);
6465
+ }
6466
+ checkBudgets() {
6467
+ const day = new Date().toDateString();
6468
+ const out = [];
6469
+ for (const p of this.projects()) {
6470
+ const b = this.budgetFor(p.id);
6471
+ if (!b || b.status.level === "ok")
6472
+ continue;
6473
+ out.push({ projectId: p.id, status: b.status });
6474
+ const key = `${day}:${b.status.level}`;
6475
+ if (this.budgetNotified.get(p.id) === key)
6476
+ continue;
6477
+ this.budgetNotified.set(p.id, key);
6478
+ const msg = budgetMessage(b.status, p.name);
6479
+ this.append({
6480
+ ts: new Date().toISOString(),
6481
+ type: "incident.opened",
6482
+ projectId: p.id,
6483
+ sessionId: null,
6484
+ payload: {
6485
+ rule: "budget",
6486
+ action: b.status.level === "exceeded" ? b.config.on_exceed : "warn",
6487
+ command: `${b.status.kind} budget`,
6488
+ reason: b.status.level === "exceeded" ? `${msg}. ${b.config.on_exceed === "stop" ? "Spawned runs were stopped and the dispatch queue cleared." : b.config.on_exceed === "ask" ? "Every Bash/Edit/Write now asks first." : "Raise [budget] in .swarm.toml or wait for the next day."}` : `${msg} \u2014 approaching the ceiling`
6489
+ }
6490
+ });
6491
+ if (b.status.level === "exceeded" && b.config.on_exceed === "stop")
6492
+ for (const fn of this.budgetListeners)
6493
+ fn(p.id, b.status);
6494
+ this.touch();
6495
+ }
6496
+ return out;
6497
+ }
4428
6498
  spend() {
4429
6499
  const dayStart = new Date;
4430
6500
  dayStart.setHours(0, 0, 0, 0);
@@ -4448,6 +6518,23 @@ class Store {
4448
6518
  daily
4449
6519
  };
4450
6520
  }
6521
+ attribution(projectId) {
6522
+ const claims = this.db.query("SELECT task, owner, worktree, state FROM claims WHERE project_id = ? AND worktree != ''").all(projectId);
6523
+ const byTask = claims.map((c) => {
6524
+ const r = this.db.query(`SELECT COALESCE(SUM(t.cost_usd),0) AS cost, COALESCE(SUM(t.output),0) AS output, COUNT(*) AS turns,
6525
+ COUNT(DISTINCT s.id) AS sessions
6526
+ FROM sessions s JOIN turns t ON t.session_id = s.id
6527
+ WHERE s.cwd = ? OR s.cwd LIKE ?`).get(c.worktree, `${c.worktree}/%`);
6528
+ return { task: c.task, owner: c.owner, state: c.state, worktree: c.worktree, ...r };
6529
+ }).filter((t) => t.turns > 0).sort((a, b) => b.cost - a.cost);
6530
+ const contextBudget = this.db.query(`SELECT s.id, s.title, s.project_id AS projectId,
6531
+ COALESCE(SUM(t.cache_read),0) AS cacheRead,
6532
+ COALESCE(SUM(t.input + t.cache_write + t.cache_read),0) AS input,
6533
+ COALESCE(SUM(t.cost_usd),0) AS cost, COUNT(*) AS turns
6534
+ FROM sessions s JOIN turns t ON t.session_id = s.id
6535
+ 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 }));
6536
+ return { byTask, contextBudget };
6537
+ }
4451
6538
  stats(projectId) {
4452
6539
  const scope = projectId ? "s.project_id = ?" : "? IS NULL";
4453
6540
  const arg = projectId ?? null;
@@ -4522,7 +6609,7 @@ class Store {
4522
6609
  args.push(limit);
4523
6610
  const rows = this.db.query(`SELECT e.seq, e.ts, e.project_id, e.session_id, e.payload, a.acked_at FROM events e
4524
6611
  LEFT JOIN incident_acks a ON a.seq = e.seq WHERE ${where.join(" AND ")} ORDER BY e.seq DESC LIMIT ?`).all(...args);
4525
- return rows.map((r) => ({
6612
+ const list = rows.map((r) => ({
4526
6613
  seq: r.seq,
4527
6614
  ts: r.ts,
4528
6615
  projectId: r.project_id,
@@ -4530,6 +6617,25 @@ class Store {
4530
6617
  acked: r.acked_at,
4531
6618
  ...JSON.parse(r.payload || "{}")
4532
6619
  }));
6620
+ const counts = new Map;
6621
+ for (const i of list) {
6622
+ const key = incidentKey(i);
6623
+ counts.set(key, (counts.get(key) ?? 0) + 1);
6624
+ }
6625
+ return list.map((i) => {
6626
+ const incident = i;
6627
+ if (!incident.rule)
6628
+ return i;
6629
+ const key = incidentKey(incident);
6630
+ const suggestion = suggestFromIncident({
6631
+ rule: incident.rule,
6632
+ action: incident.action ?? "",
6633
+ command: incident.command ?? "",
6634
+ reason: incident.reason ?? "",
6635
+ count: counts.get(key) ?? 1
6636
+ });
6637
+ return { ...i, count: counts.get(key) ?? 1, suggestion };
6638
+ });
4533
6639
  }
4534
6640
  openIncidents(projectId) {
4535
6641
  const r = this.db.query(`SELECT COUNT(*) AS n FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
@@ -4843,6 +6949,7 @@ class Store {
4843
6949
  processes: this.memoised("processes", 5000, () => this.processes()),
4844
6950
  incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
4845
6951
  openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
6952
+ questions: this.questions({ open: true, limit: 50 }),
4846
6953
  resources: this.resources(),
4847
6954
  seq: this.seq()
4848
6955
  };
@@ -4918,13 +7025,13 @@ function rowToEvent(r) {
4918
7025
  }
4919
7026
 
4920
7027
  // packages/daemon/src/app.ts
4921
- var VERSION = "0.5.0";
7028
+ var VERSION = "0.7.0";
4922
7029
  var WEB_DIR = (() => {
4923
7030
  if (process.env.SWARM_WEB_DIR)
4924
7031
  return process.env.SWARM_WEB_DIR;
4925
- const here = dirname2(fileURLToPath(import.meta.url));
4926
- const dev = join7(here, "../../web/public");
4927
- return existsSync5(join7(dev, "index.html")) ? dev : join7(here, "../web");
7032
+ const here = dirname3(fileURLToPath(import.meta.url));
7033
+ const dev = join9(here, "../../web/public");
7034
+ return existsSync6(join9(dev, "index.html")) ? dev : join9(here, "../web");
4928
7035
  })();
4929
7036
  var REPLAY_TAIL = 200;
4930
7037
  var wireCache = new WeakMap;
@@ -4940,6 +7047,12 @@ function createApp(store = new Store) {
4940
7047
  const app = new Hono2;
4941
7048
  const forge2 = new ForgeService(store);
4942
7049
  const runner = new Runner(store, store.home);
7050
+ const dispatcher = new Dispatcher(store, runner, forge2);
7051
+ store.onBudgetStop((projectId) => {
7052
+ dispatcher.clear(projectId);
7053
+ for (const run2 of runner.list(projectId))
7054
+ runner.stop(run2.id);
7055
+ });
4943
7056
  app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
4944
7057
  app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
4945
7058
  app.post("/v1/projects", async (c) => {
@@ -4968,13 +7081,13 @@ function createApp(store = new Store) {
4968
7081
  const q = c.req.query("path");
4969
7082
  let dir;
4970
7083
  try {
4971
- dir = realpathSync3(q && existsSync5(q) ? q : homedir4());
7084
+ dir = realpathSync3(q && existsSync6(q) ? q : homedir4());
4972
7085
  } catch {
4973
7086
  dir = homedir4();
4974
7087
  }
4975
7088
  try {
4976
- const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync5(join7(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
4977
- const parent = dirname2(dir);
7089
+ const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync6(join9(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
7090
+ const parent = dirname3(dir);
4978
7091
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
4979
7092
  } catch (e) {
4980
7093
  return c.json({ error: e.message, path: dir }, 400);
@@ -4986,6 +7099,30 @@ function createApp(store = new Store) {
4986
7099
  open: c.req.query("open") === "1",
4987
7100
  projectId: c.req.query("project") || undefined
4988
7101
  })));
7102
+ app.get("/v1/memory", (c) => {
7103
+ const q = c.req.query("q") ?? "";
7104
+ const kind = c.req.query("kind");
7105
+ return c.json({
7106
+ q,
7107
+ hits: store.memorySearch(q, {
7108
+ projectId: c.req.query("project") || null,
7109
+ kind: MEMORY_KINDS.includes(kind) ? kind : null,
7110
+ task: c.req.query("task") || null,
7111
+ limit: Number(c.req.query("limit")) || 30
7112
+ })
7113
+ });
7114
+ });
7115
+ app.get("/v1/rules/dryrun", (c) => {
7116
+ const projectId = c.req.query("project");
7117
+ if (!projectId)
7118
+ return c.json({ ok: false, error: "project required" }, 400);
7119
+ const overrides = {};
7120
+ for (const [k, v] of Object.entries(c.req.query()))
7121
+ if (RULE_IDS.includes(k) && ["ask", "deny", "off"].includes(v))
7122
+ overrides[k] = v;
7123
+ const limit = Math.min(20000, Math.max(100, Number(c.req.query("limit")) || 5000));
7124
+ return c.json(store.dryRun(projectId, overrides, limit));
7125
+ });
4989
7126
  app.post("/v1/incidents/ack", async (c) => {
4990
7127
  const body = await c.req.json().catch(() => ({}));
4991
7128
  return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
@@ -5060,10 +7197,84 @@ function createApp(store = new Store) {
5060
7197
  model: b.model,
5061
7198
  permissionMode: b.permissionMode,
5062
7199
  allowedTools: b.allowedTools,
5063
- maxTurns: b.maxTurns
7200
+ maxTurns: b.maxTurns,
7201
+ profile: b.profile
5064
7202
  });
5065
7203
  return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
5066
7204
  });
7205
+ app.get("/v1/budget", (c) => {
7206
+ const project = c.req.query("project");
7207
+ if (!project)
7208
+ return c.json({ error: "project required" }, 400);
7209
+ return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
7210
+ });
7211
+ app.get("/v1/context", (c) => {
7212
+ const cwd = c.req.query("cwd");
7213
+ if (!cwd)
7214
+ return c.json({ error: "cwd required" }, 400);
7215
+ return c.json(store.contextFor(cwd, c.req.query("session") || null));
7216
+ });
7217
+ app.get("/v1/questions", (c) => c.json(store.questions({
7218
+ projectId: c.req.query("project") || undefined,
7219
+ sessionId: c.req.query("session") || undefined,
7220
+ open: c.req.query("open") === "1"
7221
+ })));
7222
+ app.post("/v1/questions", async (c) => {
7223
+ const b = await c.req.json().catch(() => ({}));
7224
+ if (!b.projectId)
7225
+ return c.json({ ok: false, error: "projectId required" }, 400);
7226
+ const r = store.ask(b.projectId, {
7227
+ sessionId: b.sessionId ?? null,
7228
+ text: b.text,
7229
+ options: b.options,
7230
+ askedBy: b.askedBy ?? null,
7231
+ cwd: b.cwd ?? null
7232
+ });
7233
+ return c.json(r, r.ok ? 201 : 400);
7234
+ });
7235
+ app.post("/v1/questions/:id/answer", async (c) => {
7236
+ const b = await c.req.json().catch(() => ({}));
7237
+ const r = store.answer(Number(c.req.param("id")), b.text, b.by ?? null);
7238
+ if (r.ok && r.question.sessionId) {
7239
+ const run2 = runner.get(r.question.sessionId);
7240
+ if (run2 && run2.sessionId === r.question.sessionId) {
7241
+ const sent = runner.send(run2.id, `[swarm] answer from ${b.by ?? "a human"} to your question "${r.question.text.slice(0, 200)}": ${r.question.answer}`);
7242
+ if (sent.ok)
7243
+ store.inbox(r.question.sessionId);
7244
+ }
7245
+ }
7246
+ return c.json(r, r.ok ? 200 : 409);
7247
+ });
7248
+ app.get("/v1/inbox", (c) => c.json(store.inbox(c.req.query("session") || null, { peek: c.req.query("peek") === "1" })));
7249
+ app.get("/v1/dispatch", (c) => {
7250
+ const project = c.req.query("project");
7251
+ if (!project)
7252
+ return c.json({ error: "project required" }, 400);
7253
+ return c.json({ entries: dispatcher.status(project), config: store.config(project).dispatch });
7254
+ });
7255
+ app.post("/v1/dispatch", async (c) => {
7256
+ const b = await c.req.json().catch(() => ({}));
7257
+ if (!b.projectId)
7258
+ return c.json({ ok: false, error: "projectId required" }, 400);
7259
+ if (!b.ready && !b.tasks?.length)
7260
+ return c.json({ ok: false, error: "tasks or ready:true required" }, 400);
7261
+ const r = await dispatcher.dispatch(b.projectId, b.ready ? "ready" : b.tasks, {
7262
+ owner: b.owner ?? "dispatch",
7263
+ max: b.max,
7264
+ maxParallel: b.maxParallel,
7265
+ permissionMode: b.permissionMode,
7266
+ model: b.model,
7267
+ maxTurns: b.maxTurns,
7268
+ profile: b.profile
7269
+ });
7270
+ return c.json(r, r.ok ? 202 : 409);
7271
+ });
7272
+ app.delete("/v1/dispatch", async (c) => {
7273
+ const b = await c.req.json().catch(() => ({}));
7274
+ if (!b.projectId)
7275
+ return c.json({ ok: false, error: "projectId required" }, 400);
7276
+ return c.json({ ok: true, cleared: dispatcher.clear(b.projectId, b.task) });
7277
+ });
5067
7278
  app.post("/v1/runs/:id/send", async (c) => {
5068
7279
  const b = await c.req.json().catch(() => ({}));
5069
7280
  if (!b.text?.trim())
@@ -5115,10 +7326,31 @@ function createApp(store = new Store) {
5115
7326
  const required = store.requiredGates(project);
5116
7327
  return c.json({
5117
7328
  required,
7329
+ executable: Object.keys(store.gateDefs(project)?.defs ?? {}),
5118
7330
  runs,
5119
7331
  status: task ? store.gateStatusFor(runs, required) : undefined
5120
7332
  });
5121
7333
  });
7334
+ app.post("/v1/gates/run", async (c) => {
7335
+ const b = await c.req.json().catch(() => ({}));
7336
+ if (!b.projectId || !b.task)
7337
+ return c.json({ ok: false, error: "projectId and task required" }, 400);
7338
+ const opts = { sessionId: b.sessionId ?? null, owner: "cli" };
7339
+ if (b.wait === false) {
7340
+ const projectId = b.projectId;
7341
+ const cfg = store.gateDefs(projectId);
7342
+ const names = b.gates?.length ? b.gates : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
7343
+ store.runGates(projectId, b.task, names, opts);
7344
+ return c.json({ ok: true, started: names, runs: [] }, 202);
7345
+ }
7346
+ const r = await store.runGates(b.projectId, b.task, b.gates, opts);
7347
+ const ok = r.started.length > 0 && r.runs.every((x) => x.verdict === "pass");
7348
+ return c.json({
7349
+ ok,
7350
+ ...r,
7351
+ error: r.started.length ? undefined : r.skipped[0]?.reason ?? "no executable gates"
7352
+ }, r.started.length ? 200 : 409);
7353
+ });
5122
7354
  app.post("/v1/gates", async (c) => {
5123
7355
  const b = await c.req.json().catch(() => ({}));
5124
7356
  if (!b.projectId)
@@ -5158,6 +7390,81 @@ function createApp(store = new Store) {
5158
7390
  const r = store.release(b.projectId ?? "", b.task ?? "", b.force ?? false);
5159
7391
  return c.json(r, r.ok ? 200 : 409);
5160
7392
  });
7393
+ app.get("/v1/worktrees", async (c) => {
7394
+ const project = c.req.query("project");
7395
+ if (!project)
7396
+ return c.json({ error: "project required" }, 400);
7397
+ return c.json(await store.refreshWorktrees(project));
7398
+ });
7399
+ app.post("/v1/worktrees", async (c) => {
7400
+ const b = await c.req.json();
7401
+ if (!b.projectId || !b.name)
7402
+ return c.json({ error: "projectId and name required" }, 400);
7403
+ const r = store.createWorktree(b.projectId, b.name, b.baseRef, b.branch);
7404
+ return c.json(r, r.ok ? 201 : 409);
7405
+ });
7406
+ app.post("/v1/worktrees/remove", async (c) => {
7407
+ const b = await c.req.json();
7408
+ if (!b.projectId || !b.worktree)
7409
+ return c.json({ error: "projectId and worktree required" }, 400);
7410
+ const r = await store.removeWorktree(b.projectId, b.worktree, b.force ?? false);
7411
+ return c.json(r, r.ok ? 200 : 409);
7412
+ });
7413
+ app.post("/v1/worktrees/open", async (c) => {
7414
+ const b = await c.req.json();
7415
+ if (!b.projectId || !b.worktree)
7416
+ return c.json({ error: "projectId and worktree required" }, 400);
7417
+ await store.refreshWorktrees(b.projectId);
7418
+ const r = store.openWorktree(b.projectId, b.worktree);
7419
+ return c.json(r, r.ok ? 200 : 404);
7420
+ });
7421
+ app.get("/v1/worktrees/diff", async (c) => {
7422
+ const project = c.req.query("project");
7423
+ const ref = c.req.query("worktree");
7424
+ if (!project || !ref)
7425
+ return c.json({ error: "project and worktree required" }, 400);
7426
+ await store.refreshWorktrees(project);
7427
+ const w = store.findWorktree(project, ref);
7428
+ const p = store.project(project);
7429
+ if (!w || !p)
7430
+ return c.json({ error: `no worktree ${ref}` }, 404);
7431
+ const file = c.req.query("file") || undefined;
7432
+ const d = await worktreeDiff(p.root, w.path);
7433
+ if (file || c.req.query("patch") === "1")
7434
+ return c.json({ ...d, worktree: w.path, patch: await worktreePatch(w.path, d.base, file) });
7435
+ return c.json({ ...d, worktree: w.path });
7436
+ });
7437
+ app.get("/v1/prs/draft", async (c) => {
7438
+ const project = c.req.query("project");
7439
+ const ref = c.req.query("worktree") || c.req.query("task");
7440
+ if (!project || !ref)
7441
+ return c.json({ error: "project and worktree|task required" }, 400);
7442
+ const r = await store.prDraftFor(project, ref);
7443
+ return c.json(r, r.ok ? 200 : 404);
7444
+ });
7445
+ app.post("/v1/prs/open", async (c) => {
7446
+ const b = await c.req.json().catch(() => ({}));
7447
+ const ref = b.worktree || b.task;
7448
+ if (!b.projectId || !ref)
7449
+ return c.json({ ok: false, error: "projectId and worktree|task required" }, 400);
7450
+ const d = await store.prDraftFor(b.projectId, ref);
7451
+ if (!d.ok)
7452
+ return c.json(d, 404);
7453
+ const r = await forge2.openPR(b.projectId, d.worktree, {
7454
+ title: b.title?.trim() || d.title,
7455
+ body: b.body ?? d.body,
7456
+ isDraft: b.draft ?? false
7457
+ });
7458
+ if (r.ok)
7459
+ store.recordPrOpened(b.projectId, d.task, d.worktree.path, r.url);
7460
+ return c.json(r, r.ok ? 201 : 409);
7461
+ });
7462
+ app.post("/v1/worktrees/gc", async (c) => {
7463
+ const b = await c.req.json().catch(() => ({}));
7464
+ if (!b.projectId)
7465
+ return c.json({ error: "projectId required" }, 400);
7466
+ return c.json(await store.gcWorktrees(b.projectId, b.apply ?? false));
7467
+ });
5161
7468
  app.post("/v1/claims/reap", async (c) => {
5162
7469
  const b = await c.req.json().catch(() => ({}));
5163
7470
  return c.json({ reaped: store.reap(b.projectId) });
@@ -5177,6 +7484,10 @@ function createApp(store = new Store) {
5177
7484
  return e ? c.json(e) : c.json({ error: "not found" }, 404);
5178
7485
  });
5179
7486
  app.get("/v1/spend", (c) => c.json(store.spend()));
7487
+ app.get("/v1/attribution", (c) => {
7488
+ const project = c.req.query("project");
7489
+ return project ? c.json(store.attribution(project)) : c.json({ error: "project required" }, 400);
7490
+ });
5180
7491
  app.post("/v1/pricing/refresh", async (c) => {
5181
7492
  try {
5182
7493
  await store.refreshPricing();
@@ -5186,6 +7497,27 @@ function createApp(store = new Store) {
5186
7497
  }
5187
7498
  });
5188
7499
  app.get("/v1/pricing", (c) => c.json(store.prices));
7500
+ app.get("/v1/sessions/:id/resume", (c) => {
7501
+ const r = store.resumePlan(c.req.param("id"));
7502
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
7503
+ });
7504
+ app.post("/v1/sessions/:id/resume", async (c) => {
7505
+ const b = await c.req.json().catch(() => ({}));
7506
+ const plan = store.resumePlan(c.req.param("id"));
7507
+ if (!plan.ok)
7508
+ return c.json({ ok: false, error: plan.reason }, 404);
7509
+ const r = await runner.start({
7510
+ projectId: plan.projectId,
7511
+ task: plan.task,
7512
+ prompt: plan.prompt,
7513
+ owner: b.owner ?? plan.owner ?? "dashboard",
7514
+ model: b.model,
7515
+ permissionMode: b.permissionMode,
7516
+ allowedTools: b.allowedTools,
7517
+ maxTurns: b.maxTurns
7518
+ });
7519
+ return r.ok ? c.json({ ...r, resumedFrom: c.req.param("id") }, 201) : c.json({ ok: false, error: r.reason }, 409);
7520
+ });
5189
7521
  app.post("/v1/sessions/:id/tail", (c) => c.json({ turns: store.tailSession(c.req.param("id")) }));
5190
7522
  app.post("/v1/hook/:event", async (c) => {
5191
7523
  const event = c.req.param("event");
@@ -5199,6 +7531,8 @@ function createApp(store = new Store) {
5199
7531
  hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
5200
7532
  });
5201
7533
  }
7534
+ const sid = typeof raw2.session_id === "string" ? raw2.session_id : null;
7535
+ const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
5202
7536
  if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
5203
7537
  const guard = store.guardHook(raw2);
5204
7538
  if (guard) {
@@ -5206,11 +7540,17 @@ function createApp(store = new Store) {
5206
7540
  hookSpecificOutput: {
5207
7541
  hookEventName: "PreToolUse",
5208
7542
  permissionDecision: guard.action,
5209
- permissionDecisionReason: `[swarm] ${guard.reason}`
7543
+ permissionDecisionReason: `[swarm] ${guard.reason}`,
7544
+ ...answers ? { additionalContext: answers } : {}
5210
7545
  }
5211
7546
  });
5212
7547
  }
5213
7548
  }
7549
+ if (answers)
7550
+ return c.json({
7551
+ additionalContext: answers,
7552
+ hookSpecificOutput: { hookEventName: event, additionalContext: answers }
7553
+ });
5214
7554
  return c.json({});
5215
7555
  });
5216
7556
  app.post("/v1/events", async (c) => {
@@ -5239,18 +7579,18 @@ function createApp(store = new Store) {
5239
7579
  });
5240
7580
  });
5241
7581
  });
5242
- app.get("/", (c) => c.html(readFileSync4(join7(WEB_DIR, "index.html"), "utf8")));
7582
+ app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
5243
7583
  const MIME = { js: "text/javascript", css: "text/css" };
5244
7584
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
5245
7585
  const f = c.req.param("file");
5246
- const p = join7(WEB_DIR, f);
5247
- if (!existsSync5(p))
7586
+ const p = join9(WEB_DIR, f);
7587
+ if (!existsSync6(p))
5248
7588
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
5249
7589
  return c.body(readFileSync4(p, "utf8"), 200, {
5250
7590
  "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
5251
7591
  });
5252
7592
  });
5253
- return { app, store, forge: forge2, runner };
7593
+ return { app, store, forge: forge2, runner, dispatcher };
5254
7594
  }
5255
7595
 
5256
7596
  // packages/daemon/src/bin.ts
@@ -5286,6 +7626,8 @@ var tailer = setInterval(() => {
5286
7626
  store.reapProcesses();
5287
7627
  if (tick % 12 === 0)
5288
7628
  store.sweepOrphans();
7629
+ if (tick % 6 === 0)
7630
+ store.checkBudgets();
5289
7631
  }, 5000);
5290
7632
  store.refreshAllWorktrees();
5291
7633
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);