@ra3orblade/swarm 0.6.0 → 0.8.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
@@ -2,6 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // packages/client/src/daemon.ts
5
+ import { randomBytes } from "crypto";
5
6
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
6
7
  import { homedir } from "os";
7
8
  import { join } from "path";
@@ -18,9 +19,65 @@ function writeDaemonInfo(info) {
18
19
  `);
19
20
  return full;
20
21
  }
22
+ var tokenFile = (home = swarmHome()) => join(home, "token");
23
+ function readToken(home = swarmHome()) {
24
+ try {
25
+ const t = readFileSync(tokenFile(home), "utf8").trim();
26
+ return /^[a-f0-9]{64}$/.test(t) ? t : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function ensureToken(home = swarmHome()) {
32
+ const cur = readToken(home);
33
+ if (cur)
34
+ return cur;
35
+ mkdirSync(home, { recursive: true });
36
+ const t = randomBytes(32).toString("hex");
37
+ writeFileSync(tokenFile(home), `${t}
38
+ `, { mode: 384 });
39
+ return t;
40
+ }
21
41
  function clearDaemonInfo() {
22
42
  rmSync(infoFile(), { force: true });
23
43
  }
44
+ // packages/core/src/actor.ts
45
+ var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
46
+ var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
47
+ function actorFrom(owner, sessionId, opts = {}) {
48
+ const o = (owner ?? "").trim();
49
+ const sid = sessionId?.trim() || undefined;
50
+ if (opts.runId)
51
+ return { kind: "run", id: opts.runId, session: sid };
52
+ if (o.startsWith("auto:"))
53
+ return { kind: "daemon", id: "daemon", session: sid };
54
+ if (DAEMON_ALIASES.has(o))
55
+ return { kind: "daemon", id: "daemon" };
56
+ if (o === "agent" || o.startsWith("agent:") || o.startsWith("session:"))
57
+ return {
58
+ kind: "agent",
59
+ id: sid ?? o.replace(/^(agent|session):/, "") ?? "agent",
60
+ session: sid
61
+ };
62
+ if (!o || HUMAN_ALIASES.has(o)) {
63
+ if (!o && sid)
64
+ return { kind: "agent", id: sid, session: sid };
65
+ return { kind: "human", id: opts.user?.trim() || "me" };
66
+ }
67
+ if (sid && o === sid)
68
+ return { kind: "agent", id: sid, session: sid };
69
+ return { kind: "human", id: o };
70
+ }
71
+ function actorFromColumns(kind, id, session) {
72
+ if (!kind || !id)
73
+ return null;
74
+ if (!["human", "agent", "run", "daemon"].includes(kind))
75
+ return null;
76
+ const a = { kind, id };
77
+ if (session && kind !== "human" && kind !== "daemon")
78
+ a.session = session;
79
+ return a;
80
+ }
24
81
  // packages/core/src/adapters/claude-code/transcript.ts
25
82
  function parseTranscriptChunk(chunk) {
26
83
  const out = {
@@ -229,6 +286,18 @@ function parseGrokUpdates(chunk) {
229
286
  return out;
230
287
  }
231
288
  // packages/core/src/adapters/claude-code/hooks.ts
289
+ var HOOK_EVENTS = [
290
+ "SessionStart",
291
+ "UserPromptSubmit",
292
+ "PreToolUse",
293
+ "PostToolUse",
294
+ "SubagentStart",
295
+ "SubagentStop",
296
+ "Stop",
297
+ "SessionEnd",
298
+ "Notification",
299
+ "PreCompact"
300
+ ];
232
301
  var MAP = {
233
302
  SessionStart: "session.started",
234
303
  UserPromptSubmit: "prompt.submitted",
@@ -322,13 +391,254 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
322
391
  payload.prompt = raw.prompt;
323
392
  return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
324
393
  }
394
+ // packages/core/src/audit.ts
395
+ var AUDIT_TYPES = new Set([
396
+ "session.started",
397
+ "session.ended",
398
+ "tool.denied",
399
+ "claim.acquired",
400
+ "claim.renewed",
401
+ "claim.released",
402
+ "claim.expired",
403
+ "claim.orphaned",
404
+ "worktree.created",
405
+ "worktree.removed",
406
+ "worktree.bootstrapped",
407
+ "pr.opened",
408
+ "question.asked",
409
+ "question.answered",
410
+ "dispatch.queued",
411
+ "dispatch.started",
412
+ "dispatch.finished",
413
+ "resource.acquired",
414
+ "resource.released",
415
+ "resource.reaped",
416
+ "process.started",
417
+ "process.exited",
418
+ "gate.recorded",
419
+ "handoff.recorded",
420
+ "permission.requested",
421
+ "permission.resolved",
422
+ "incident.opened",
423
+ "incident.acked",
424
+ "run.result"
425
+ ]);
426
+ var isAuditType = (t) => AUDIT_TYPES.has(t);
427
+ var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
428
+ var DEFAULT_PRIVACY = {
429
+ store_prompts: true,
430
+ store_reasoning: true,
431
+ redact: []
432
+ };
433
+ var BUILTIN_REDACT = [
434
+ /\b(sk|pk|rk|ghp|gho|ghu|ghs|xoxb|xoxp|AKIA)[A-Za-z0-9_-]{16,}\b/g,
435
+ /\bBearer\s+[A-Za-z0-9._-]{20,}/g
436
+ ];
437
+ function compileRedactions(patterns) {
438
+ const out = [...BUILTIN_REDACT];
439
+ for (const p of patterns) {
440
+ try {
441
+ out.push(new RegExp(p, "g"));
442
+ } catch {}
443
+ }
444
+ return out;
445
+ }
446
+ function redactValue(v, res) {
447
+ if (!res.length)
448
+ return v;
449
+ if (typeof v === "string")
450
+ return redactString(v, res);
451
+ if (Array.isArray(v)) {
452
+ let changed = false;
453
+ const out = v.map((x) => {
454
+ const r = redactValue(x, res);
455
+ if (r !== x)
456
+ changed = true;
457
+ return r;
458
+ });
459
+ return changed ? out : v;
460
+ }
461
+ if (v && typeof v === "object") {
462
+ let changed = false;
463
+ const out = {};
464
+ for (const [k, x] of Object.entries(v)) {
465
+ const r = redactValue(x, res);
466
+ if (r !== x)
467
+ changed = true;
468
+ out[k] = r;
469
+ }
470
+ return changed ? out : v;
471
+ }
472
+ return v;
473
+ }
474
+ function redactString(s, res) {
475
+ let out = s;
476
+ for (const re of res) {
477
+ re.lastIndex = 0;
478
+ if (re.test(out)) {
479
+ re.lastIndex = 0;
480
+ out = out.replace(re, "[redacted]");
481
+ }
482
+ }
483
+ return out;
484
+ }
485
+ var AUDIT_COLUMNS = [
486
+ "seq",
487
+ "ts",
488
+ "type",
489
+ "projectId",
490
+ "sessionId",
491
+ "actorKind",
492
+ "actorId",
493
+ "summary",
494
+ "payload"
495
+ ];
496
+ function auditRow(e) {
497
+ const p = e.payload ?? {};
498
+ const summary = typeof p.summary === "string" ? p.summary : typeof p.reason === "string" ? p.reason : typeof p.command === "string" ? p.command : typeof p.task === "string" ? String(p.task) : "";
499
+ return {
500
+ seq: e.seq ?? 0,
501
+ ts: e.ts,
502
+ type: e.type,
503
+ projectId: e.projectId,
504
+ sessionId: e.sessionId,
505
+ actorKind: e.actor?.kind ?? null,
506
+ actorId: e.actor?.id ?? null,
507
+ summary: summary.slice(0, 400),
508
+ payload: e.payload ?? null
509
+ };
510
+ }
511
+ var csvCell = (v) => {
512
+ const s = v == null ? "" : typeof v === "string" ? v : JSON.stringify(v);
513
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
514
+ };
515
+ function formatAudit(rows, format) {
516
+ if (format === "json")
517
+ return JSON.stringify(rows);
518
+ if (format === "csv") {
519
+ const lines = [
520
+ AUDIT_COLUMNS.join(","),
521
+ ...rows.map((r) => AUDIT_COLUMNS.map((c) => csvCell(r[c])).join(","))
522
+ ];
523
+ return `${lines.join(`
524
+ `)}
525
+ `;
526
+ }
527
+ return `${rows.map((r) => JSON.stringify(r)).join(`
528
+ `)}${rows.length ? `
529
+ ` : ""}`;
530
+ }
531
+ function sinceToIso(since, now = Date.now()) {
532
+ if (!since)
533
+ return null;
534
+ const m = /^(\d+)([dhm])$/.exec(since.trim());
535
+ if (m) {
536
+ const n = Number(m[1]);
537
+ const ms = m[2] === "d" ? 86400000 : m[2] === "h" ? 3600000 : 60000;
538
+ return new Date(now - n * ms).toISOString();
539
+ }
540
+ const t = Date.parse(since);
541
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
542
+ }
543
+ // packages/core/src/budget.ts
544
+ function budgetStatus(spent, cfg) {
545
+ const part = (s, l) => ({
546
+ spent: s,
547
+ limit: l,
548
+ pct: l && l > 0 ? s / l : 0
549
+ });
550
+ const daily = part(spent.today, cfg.daily);
551
+ const weekly = part(spent.week, cfg.weekly);
552
+ const candidates = [
553
+ ["daily", daily],
554
+ ["weekly", weekly]
555
+ ];
556
+ let kind = null;
557
+ let top = { spent: 0, limit: null, pct: 0 };
558
+ for (const [k, v] of candidates)
559
+ if (v.limit && v.pct >= top.pct)
560
+ ({ kind, top } = { kind: k, top: v });
561
+ const level = !kind ? "ok" : top.pct >= 1 ? "exceeded" : top.pct >= cfg.warn_at ? "warn" : "ok";
562
+ return { level, kind, spent: top.spent, limit: top.limit, pct: top.pct, daily, weekly };
563
+ }
564
+ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
565
+ function budgetMessage(s, project) {
566
+ const usd = (n) => `$${n.toFixed(2)}`;
567
+ if (s.level === "ok" || !s.limit)
568
+ return `${project}: within budget`;
569
+ return `${project} has spent ${usd(s.spent)} of its ${usd(s.limit)} ${s.kind} budget (${Math.round(s.pct * 100)}%)`;
570
+ }
571
+ var RUN_PROFILES = {
572
+ full: {
573
+ name: "full",
574
+ description: "every tool, rules still apply",
575
+ disallowedTools: [],
576
+ allowedTools: []
577
+ },
578
+ "no-edits": {
579
+ name: "no-edits",
580
+ description: "may run commands, may not edit files (review, triage, test runs)",
581
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
582
+ allowedTools: []
583
+ },
584
+ "read-only": {
585
+ name: "read-only",
586
+ description: "read and search only \u2014 no edits, no shell",
587
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"],
588
+ allowedTools: ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]
589
+ }
590
+ };
591
+ function runProfile(name) {
592
+ if (!name)
593
+ return null;
594
+ return RUN_PROFILES[name] ?? null;
595
+ }
325
596
  // packages/core/src/config.ts
326
597
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
327
598
  import { join as join2 } from "path";
599
+ var DEFAULT_GATE_TIMEOUT_S = 900;
600
+ var AUTO_MODES = ["session-end", "stop", "off"];
601
+ function parseGateDefs(gates) {
602
+ const out = {};
603
+ if (!isRecord(gates))
604
+ return out;
605
+ for (const [name, v] of Object.entries(gates)) {
606
+ if (!isRecord(v))
607
+ continue;
608
+ const builtin = v.builtin === "review" ? "review" : null;
609
+ const cmd = typeof v.cmd === "string" ? v.cmd.trim() : "";
610
+ if (!cmd && !builtin)
611
+ continue;
612
+ if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name))
613
+ continue;
614
+ const t = Number(v.timeout);
615
+ out[name] = {
616
+ cmd: builtin ? "" : cmd,
617
+ timeout: Number.isFinite(t) && t > 0 ? Math.min(t, 86400) : builtin ? 600 : DEFAULT_GATE_TIMEOUT_S,
618
+ cwd: isRepoRelative(v.cwd) ? v.cwd.trim() : null,
619
+ builtin,
620
+ model: typeof v.model === "string" && v.model.trim() ? v.model.trim() : null
621
+ };
622
+ }
623
+ return out;
624
+ }
328
625
  var DEFAULT_CONFIG = {
329
- daemon: { port: 7777 },
626
+ daemon: { port: 7777, auth: "loopback-optional" },
330
627
  tasks: { source: null, labels: [], team: null },
331
- gates: { required: [] },
628
+ gates: { required: [], auto: "session-end", defs: {} },
629
+ budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
630
+ events: { retain_days: 30 },
631
+ audit: { retain_days: 0 },
632
+ privacy: DEFAULT_PRIVACY,
633
+ dispatch: {
634
+ max_parallel: 2,
635
+ permission_mode: null,
636
+ model: null,
637
+ max_turns: null,
638
+ require_pr: true,
639
+ profile: null
640
+ },
641
+ worktree: { setup: null, copy: [], open: null },
332
642
  rules: {
333
643
  shared_tree: "ask",
334
644
  destructive_git: "ask",
@@ -359,18 +669,82 @@ function parseToml(text, source) {
359
669
  return {};
360
670
  }
361
671
  }
672
+ function isRepoRelative(f) {
673
+ if (typeof f !== "string")
674
+ return false;
675
+ const t = f.trim();
676
+ if (!t || t.startsWith("/") || t.startsWith("\\") || /^[a-zA-Z]:/.test(t))
677
+ return false;
678
+ return !t.split(/[/\\]/).some((seg) => seg === "..");
679
+ }
680
+ var days = (v, fallback) => {
681
+ const n = Number(v);
682
+ return Number.isInteger(n) && n >= 0 ? Math.min(n, 3650) : fallback;
683
+ };
362
684
  function validate(c) {
363
685
  const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
364
686
  const port = Number(c.daemon?.port);
365
687
  const source = c.tasks?.source;
688
+ const setup = c.worktree?.setup;
689
+ const opener = c.worktree?.open;
690
+ const rawGates = c.gates;
691
+ const d = c.dispatch ?? {};
692
+ const mp = Number(d.max_parallel);
693
+ const mt = Number(d.max_turns);
694
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
695
+ const b = c.budget ?? {};
696
+ const usd = (v) => {
697
+ const n = Number(v);
698
+ return Number.isFinite(n) && n > 0 ? n : null;
699
+ };
700
+ const warnAt = Number(b.warn_at);
701
+ const auto = rawGates?.auto;
366
702
  return {
367
703
  ...c,
368
- daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
704
+ daemon: {
705
+ port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777,
706
+ auth: c.daemon?.auth === "required" ? "required" : "loopback-optional"
707
+ },
369
708
  tasks: {
370
709
  source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null,
371
710
  labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
372
711
  team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
373
712
  },
713
+ gates: {
714
+ required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
715
+ auto: AUTO_MODES.includes(auto) ? auto : "session-end",
716
+ defs: parseGateDefs(rawGates)
717
+ },
718
+ budget: {
719
+ daily: usd(b.daily),
720
+ weekly: usd(b.weekly),
721
+ warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
722
+ on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
723
+ },
724
+ events: {
725
+ retain_days: days(c.events?.retain_days, 30)
726
+ },
727
+ audit: {
728
+ retain_days: days(c.audit?.retain_days, 0)
729
+ },
730
+ privacy: {
731
+ store_prompts: c.privacy?.store_prompts !== false,
732
+ store_reasoning: c.privacy?.store_reasoning !== false,
733
+ redact: Array.isArray(c.privacy?.redact) ? c.privacy.redact.filter((r) => typeof r === "string" && r.length > 0) : []
734
+ },
735
+ dispatch: {
736
+ max_parallel: Number.isInteger(mp) && mp > 0 ? Math.min(mp, 16) : 2,
737
+ permission_mode: str(d.permission_mode),
738
+ model: str(d.model),
739
+ max_turns: Number.isInteger(mt) && mt > 0 ? mt : null,
740
+ require_pr: d.require_pr === undefined ? true : d.require_pr === true,
741
+ profile: ["full", "no-edits", "read-only"].includes(String(d.profile)) ? String(d.profile) : null
742
+ },
743
+ worktree: {
744
+ setup: typeof setup === "string" && setup.trim() ? setup.trim() : null,
745
+ copy: Array.isArray(c.worktree?.copy) ? c.worktree.copy.filter((f) => isRepoRelative(f)) : [],
746
+ open: typeof opener === "string" && opener.trim() ? opener.trim() : null
747
+ },
374
748
  rules: {
375
749
  ...c.rules,
376
750
  shared_tree: mode(c.rules?.shared_tree, "ask"),
@@ -385,18 +759,149 @@ function validate(c) {
385
759
  }
386
760
  };
387
761
  }
388
- function loadConfig(opts = {}) {
762
+ function leafPaths(v, prefix = "") {
763
+ if (!isRecord(v))
764
+ return prefix ? [prefix] : [];
765
+ const keys = Object.keys(v);
766
+ if (keys.length === 0)
767
+ return prefix ? [prefix] : [];
768
+ return keys.flatMap((k) => leafPaths(v[k], prefix ? `${prefix}.${k}` : k));
769
+ }
770
+ function getPath(v, path) {
771
+ let cur = v;
772
+ for (const seg of path.split(".")) {
773
+ if (!isRecord(cur))
774
+ return;
775
+ cur = cur[seg];
776
+ }
777
+ return cur;
778
+ }
779
+ function setPath(obj, path, value) {
780
+ const segs = path.split(".");
781
+ let cur = obj;
782
+ for (const seg of segs.slice(0, -1)) {
783
+ if (!isRecord(cur[seg]))
784
+ cur[seg] = {};
785
+ cur = cur[seg];
786
+ }
787
+ cur[segs[segs.length - 1]] = value;
788
+ }
789
+ var isLockedBy = (path, lock) => path === lock || path.startsWith(`${lock}.`);
790
+ function readLayer(path) {
791
+ return existsSync2(path) ? parseToml(readFileSync2(path, "utf8"), path) : null;
792
+ }
793
+ function loadConfigDetailed(opts = {}) {
389
794
  const home = opts.home ?? process.env.SWARM_HOME ?? join2(process.env.HOME ?? "", ".swarm");
795
+ const policyPath = opts.policy ?? process.env.SWARM_POLICY ?? join2(home, "policy.toml");
796
+ const policyRaw = readLayer(policyPath);
797
+ const locked = Array.isArray(policyRaw?.locked) ? policyRaw.locked.filter((k) => typeof k === "string" && /^[a-z0-9_.-]+$/i.test(k)) : [];
798
+ const policy = { ...policyRaw ?? {} };
799
+ delete policy.locked;
800
+ const layers = [
801
+ ["policy", policyRaw ? policy : null],
802
+ ["global", readLayer(join2(home, "config.toml"))],
803
+ ["repo", opts.repoRoot ? readLayer(join2(opts.repoRoot, ".swarm.toml")) : null]
804
+ ];
805
+ const provenance = {};
806
+ for (const p of leafPaths(DEFAULT_CONFIG))
807
+ provenance[p] = "default";
808
+ const overridden = [];
390
809
  let cfg = DEFAULT_CONFIG;
391
- const globalPath = join2(home, "config.toml");
392
- if (existsSync2(globalPath))
393
- cfg = merge(cfg, parseToml(readFileSync2(globalPath, "utf8"), globalPath));
394
- if (opts.repoRoot) {
395
- const repoPath = join2(opts.repoRoot, ".swarm.toml");
396
- if (existsSync2(repoPath))
397
- cfg = merge(cfg, parseToml(readFileSync2(repoPath, "utf8"), repoPath));
810
+ for (const [layer, raw] of layers) {
811
+ if (!raw)
812
+ continue;
813
+ for (const p of leafPaths(raw)) {
814
+ const lock = layer !== "policy" && locked.find((l) => isLockedBy(p, l));
815
+ if (lock)
816
+ overridden.push({ key: p, layer, attempted: getPath(raw, p) });
817
+ else
818
+ provenance[p] = layer;
819
+ }
820
+ cfg = merge(cfg, raw);
821
+ }
822
+ if (overridden.length) {
823
+ const out = structuredClone(cfg);
824
+ for (const { key } of overridden) {
825
+ const fromPolicy = getPath(policy, key);
826
+ setPath(out, key, fromPolicy === undefined ? getPath(DEFAULT_CONFIG, key) : fromPolicy);
827
+ provenance[key] = fromPolicy === undefined ? "default" : "policy";
828
+ }
829
+ cfg = out;
830
+ }
831
+ return {
832
+ config: validate(cfg),
833
+ provenance,
834
+ overridden,
835
+ policy: { path: policyRaw ? policyPath : null, locked }
836
+ };
837
+ }
838
+ function loadConfig(opts = {}) {
839
+ return loadConfigDetailed(opts).config;
840
+ }
841
+ // packages/core/src/dispatch.ts
842
+ function planDispatch(tasks, wanted, opts) {
843
+ const byId = new Map(tasks.map((t) => [t.id, t]));
844
+ const queued = new Set(opts.alreadyQueued ?? []);
845
+ const rejected = [];
846
+ const picked = [];
847
+ const ids = wanted === "ready" ? tasks.filter((t) => t.ready).map((t) => t.id) : wanted;
848
+ for (const id of ids) {
849
+ const t = byId.get(id);
850
+ if (!t)
851
+ rejected.push({ id, reason: "not in the task source" });
852
+ else if (queued.has(id))
853
+ rejected.push({ id, reason: "already queued" });
854
+ else if (t.claimedBy)
855
+ rejected.push({ id, reason: `held by ${t.claimedBy}` });
856
+ else if (t.status === "done")
857
+ rejected.push({ id, reason: "already done" });
858
+ else if (!t.ready)
859
+ rejected.push({
860
+ id,
861
+ reason: t.status === "active" ? "in progress" : "blocked by dependencies"
862
+ });
863
+ else if (picked.some((p) => p.id === id))
864
+ rejected.push({ id, reason: "listed twice" });
865
+ else
866
+ picked.push(t);
398
867
  }
399
- return validate(cfg);
868
+ const limit = opts.max && opts.max > 0 ? picked.slice(0, opts.max) : picked;
869
+ for (const t of picked.slice(limit.length))
870
+ rejected.push({ id: t.id, reason: `beyond --max ${opts.max}` });
871
+ const slots = Math.max(0, opts.maxParallel - opts.running);
872
+ return { start: limit.slice(0, slots), queued: limit.slice(slots), rejected };
873
+ }
874
+ function taskPrompt(task, ctx = {
875
+ requiredGates: [],
876
+ executableGates: [],
877
+ openPr: true
878
+ }) {
879
+ const manual = ctx.requiredGates.filter((g) => !ctx.executableGates.includes(g));
880
+ const exec = ctx.requiredGates.filter((g) => ctx.executableGates.includes(g));
881
+ const steps = [
882
+ "Work only inside this worktree; never touch the main checkout or another worktree.",
883
+ "Commit as you go with clear messages. Do not edit the task list or flip the task's status \u2014 Swarm derives it.",
884
+ exec.length ? `Run the executable gates with swarm_gate_run (${exec.join(", ")}) and fix what fails.` : null,
885
+ manual.length ? `Record the remaining required gates with swarm_gate_record and an honest rubric (${manual.join(", ")}).` : null,
886
+ "Call swarm_handoff with what was done, what remains, the files touched and how to verify.",
887
+ ctx.openPr ? "Then push and open the pull request with swarm_pr_open." : "Push the branch.",
888
+ "If you are blocked on a decision only a human can make, say so in the handoff and stop."
889
+ ].filter(Boolean);
890
+ return `Task ${task.id}: ${task.title}
891
+
892
+ ${steps.map((s, i) => `${i + 1}. ${s}`).join(`
893
+ `)}`;
894
+ }
895
+ function dispatchOutcome(facts) {
896
+ if (facts.stopped)
897
+ return "stopped";
898
+ if (facts.exitCode !== 0 || facts.isError)
899
+ return "crashed";
900
+ if (!facts.gatesSatisfied)
901
+ return "gates-failed";
902
+ if (facts.requirePr && !facts.prOpen)
903
+ return "no-pr";
904
+ return "done";
400
905
  }
401
906
  // packages/core/src/rules.ts
402
907
  var LIVE_WINDOW_MS = 10 * 60000;
@@ -706,6 +1211,68 @@ function normalizeGitlab(raw, repo) {
706
1211
  };
707
1212
  });
708
1213
  }
1214
+ function parseNumstat(numstat, nameStatus = "") {
1215
+ const status = new Map;
1216
+ for (const line of nameStatus.split(`
1217
+ `)) {
1218
+ const [st, ...rest] = line.split("\t");
1219
+ if (!st || !rest.length)
1220
+ continue;
1221
+ status.set(rest[rest.length - 1], st[0]);
1222
+ }
1223
+ const out = [];
1224
+ for (const line of numstat.split(`
1225
+ `)) {
1226
+ const [a, d, ...rest] = line.split("\t");
1227
+ if (a === undefined || d === undefined || !rest.length)
1228
+ continue;
1229
+ const raw = rest.join("\t");
1230
+ const path = raw.includes(" => ") ? raw.replace(/\{?([^{}]*) => ([^{}]*)\}?/, "$2") : raw;
1231
+ out.push({
1232
+ path,
1233
+ added: a === "-" ? -1 : Number(a),
1234
+ deleted: d === "-" ? -1 : Number(d),
1235
+ status: status.get(path) ?? "M"
1236
+ });
1237
+ }
1238
+ return out;
1239
+ }
1240
+ function prDraft(i) {
1241
+ const title = ((i.title?.trim()) ? `${i.task}: ${i.title.trim()}` : i.task).slice(0, 120);
1242
+ const b = [];
1243
+ b.push("## Summary");
1244
+ if (i.handoff?.done.trim())
1245
+ b.push(i.handoff.done.trim());
1246
+ else if (i.commits?.length)
1247
+ b.push(i.commits.map((c) => `- ${c}`).join(`
1248
+ `));
1249
+ else
1250
+ b.push(`Work on ${i.task}.`);
1251
+ if (i.handoff?.remaining.trim() && !/^(nothing|none|\u2014|-)\.?$/i.test(i.handoff.remaining.trim()))
1252
+ b.push(`
1253
+ ## Remaining
1254
+ ${i.handoff.remaining.trim()}`);
1255
+ if (i.gates?.length) {
1256
+ b.push(`
1257
+ ## Gates`);
1258
+ b.push(i.gates.map((g) => `- ${g.verdict === "pass" ? "[x]" : "[ ]"} ${g.gate}${g.verdict === "fail" ? " \u2014 failed" : g.verdict ? "" : " \u2014 not run"}`).join(`
1259
+ `));
1260
+ }
1261
+ if (i.handoff?.verify?.trim())
1262
+ b.push(`
1263
+ ## Verify
1264
+ ${i.handoff.verify.trim()}`);
1265
+ if (i.files?.length) {
1266
+ const shown = i.files.slice(0, 30);
1267
+ b.push(`
1268
+ ## Files (${i.files.length})
1269
+ ${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.deleted}` : " (binary)"}`).join(`
1270
+ `)}${i.files.length > shown.length ? `
1271
+ - \u2026 ${i.files.length - shown.length} more` : ""}`);
1272
+ }
1273
+ return { title, body: b.join(`
1274
+ `) };
1275
+ }
709
1276
  // packages/core/src/gates.ts
710
1277
  var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
711
1278
  function validateGateRun(input) {
@@ -749,6 +1316,25 @@ function gatesSatisfied(runs, declared) {
749
1316
  const st = gateStatus(runs, declared);
750
1317
  return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
751
1318
  }
1319
+ function evidenceTail(output, max = 2000) {
1320
+ const t = output.trimEnd();
1321
+ if (t.length <= max)
1322
+ return t;
1323
+ const cut = t.slice(-max);
1324
+ const nl = cut.indexOf(`
1325
+ `);
1326
+ return `\u2026${nl >= 0 && nl < 200 ? cut.slice(nl + 1) : cut}`;
1327
+ }
1328
+ function executedGateInput(task, gate, cmd, outcome) {
1329
+ const how = outcome.timedOut === true ? "timed out" : outcome.exitCode === null ? "could not start" : `exit ${outcome.exitCode}`;
1330
+ return {
1331
+ task,
1332
+ gate,
1333
+ verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
1334
+ rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
1335
+ evidence: evidenceTail(outcome.output) || null
1336
+ };
1337
+ }
752
1338
  // packages/core/src/ledger.ts
753
1339
  var DEFAULT_LEASE_MINUTES = 45;
754
1340
  function isExpired(claim, now) {
@@ -1053,6 +1639,92 @@ function parseMemoryQuery(q) {
1053
1639
  }
1054
1640
  return { match: terms.join(" "), kind, task };
1055
1641
  }
1642
+ // packages/core/src/policy.ts
1643
+ import { createHash } from "crypto";
1644
+ var HOOK_MARK = "swarm-hook";
1645
+ var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
1646
+ var MIN_HOOK_TIMEOUT_S = 5;
1647
+ function hookCoverage(settings) {
1648
+ const hooks = settings && typeof settings === "object" && !Array.isArray(settings) ? settings.hooks : undefined;
1649
+ const missing = [];
1650
+ const short = [];
1651
+ for (const ev of HOOK_EVENTS) {
1652
+ const groups = Array.isArray(hooks?.[ev]) ? hooks?.[ev] : [];
1653
+ const ours = groups.flatMap((g) => {
1654
+ const list = g?.hooks;
1655
+ return Array.isArray(list) ? list.filter((h) => hookIsOurs(h)) : [];
1656
+ });
1657
+ if (!ours.length)
1658
+ missing.push(ev);
1659
+ else if (ours.every((h) => typeof h.timeout === "number" && h.timeout < MIN_HOOK_TIMEOUT_S))
1660
+ short.push(ev);
1661
+ }
1662
+ return { missing, short, complete: !missing.length && !short.length };
1663
+ }
1664
+ var hasLockedRules = (loaded) => loaded.policy.locked.some((k) => k === "rules" || k.startsWith("rules."));
1665
+ function policyFindings(input) {
1666
+ const out = [];
1667
+ const repo = input.repoRoot ?? "";
1668
+ for (const o of input.loaded.overridden)
1669
+ out.push({
1670
+ key: `override:${repo}:${o.layer}:${o.key}`,
1671
+ subject: `${o.layer === "repo" ? ".swarm.toml" : "config.toml"} ${o.key}`,
1672
+ reason: `locked by policy; ${o.layer} config tried to set ${JSON.stringify(o.attempted)}`
1673
+ });
1674
+ const cov = input.coverage;
1675
+ if (cov && !cov.complete) {
1676
+ if (cov.missing.length)
1677
+ out.push({
1678
+ key: `hooks:missing:${cov.missing.join(",")}`,
1679
+ subject: `hooks ${cov.missing.join(", ")}`,
1680
+ reason: "swarm hook entry removed from settings.json \u2014 run: swarm install"
1681
+ });
1682
+ if (cov.short.length)
1683
+ out.push({
1684
+ key: `hooks:short:${cov.short.join(",")}`,
1685
+ subject: `hooks ${cov.short.join(", ")}`,
1686
+ reason: `hook timeout below ${MIN_HOOK_TIMEOUT_S}s \u2014 run: swarm install`
1687
+ });
1688
+ }
1689
+ if (input.guardOff && hasLockedRules(input.loaded))
1690
+ out.push({
1691
+ key: "guard:off",
1692
+ subject: "SWARM_GUARD=off",
1693
+ reason: "policy locks rules; SWARM_GUARD=off is ignored for locked rules"
1694
+ });
1695
+ return out;
1696
+ }
1697
+ var POLICY_CACHE_VERSION = 1;
1698
+ var POLICY_CACHE_FILE = "policy.cache.json";
1699
+ var RULE_KEYS = [
1700
+ "shared_tree",
1701
+ "destructive_git",
1702
+ "pattern_kill",
1703
+ "protected_ports",
1704
+ "no_foreign_worktree",
1705
+ "claim_required_to_write"
1706
+ ];
1707
+ var lockedKey = (locked, key) => locked.some((l) => l === "rules" || key === l || key.startsWith(`${l}.`));
1708
+ function offlineModes(loaded) {
1709
+ const locked = loaded.policy.locked;
1710
+ const out = { ...DEFAULT_MODES, protected: { ports: [] } };
1711
+ for (const k of RULE_KEYS)
1712
+ out[k] = lockedKey(locked, `rules.${k}`) ? loaded.config.rules[k] : "off";
1713
+ if (lockedKey(locked, "rules.protected.ports"))
1714
+ out.protected = { ports: [...loaded.config.rules.protected.ports] };
1715
+ return out;
1716
+ }
1717
+ var digest = (body) => createHash("sha256").update(JSON.stringify(body)).digest("hex");
1718
+ function buildPolicyCache(loaded, sessions, worktrees, now = new Date) {
1719
+ const body = {
1720
+ version: POLICY_CACHE_VERSION,
1721
+ writtenAt: now.toISOString(),
1722
+ modes: offlineModes(loaded),
1723
+ sessions,
1724
+ worktrees
1725
+ };
1726
+ return { ...body, sha256: digest(body) };
1727
+ }
1056
1728
  // packages/core/src/pricing.ts
1057
1729
  var PRICES = {
1058
1730
  "claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
@@ -1151,6 +1823,29 @@ function projectIdentity(opts) {
1151
1823
  const name = parts[parts.length - 1] ?? opts.root;
1152
1824
  return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
1153
1825
  }
1826
+ // packages/core/src/questions.ts
1827
+ function validateQuestion(text, options) {
1828
+ const t = typeof text === "string" ? text.trim() : "";
1829
+ if (t.length < 5)
1830
+ return { ok: false, reason: "a question needs at least a few words" };
1831
+ if (t.length > 4000)
1832
+ return { ok: false, reason: "keep the question under 4000 characters" };
1833
+ const opts = Array.isArray(options) ? options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim()).slice(0, 8) : [];
1834
+ return { ok: true, text: t, options: opts };
1835
+ }
1836
+ function formatAnswers(qs) {
1837
+ const answered = qs.filter((q) => q.answer !== null);
1838
+ if (!answered.length)
1839
+ return null;
1840
+ return answered.map((q) => `[swarm] answer from ${q.answeredBy ?? "a human"} to your question "${q.text.slice(0, 200)}": ${q.answer}`).join(`
1841
+ `);
1842
+ }
1843
+ function formatOpenQuestions(qs) {
1844
+ const open = qs.filter((q) => q.answer === null);
1845
+ if (!open.length)
1846
+ return null;
1847
+ 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`;
1848
+ }
1154
1849
  // packages/core/src/resources.ts
1155
1850
  var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
1156
1851
  function isTrackedPid(pid) {
@@ -1176,6 +1871,114 @@ function acquireRefusalMessage(holder) {
1176
1871
  const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
1177
1872
  return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
1178
1873
  }
1874
+ // packages/core/src/review.ts
1875
+ var REVIEW_RUBRIC = "review: no blocker/major findings \u2014 correctness bugs, data loss, security, broken invariants (never kill by pattern, never touch a worktree you don't hold, repo-agnostic), missing tests for changed behaviour";
1876
+ var REVIEW_PATCH_MAX = 120000;
1877
+ function reviewPrompt(input) {
1878
+ const patch = input.patch.length > REVIEW_PATCH_MAX ? `${input.patch.slice(0, REVIEW_PATCH_MAX)}
1879
+
1880
+ [\u2026 patch truncated at ${REVIEW_PATCH_MAX} chars; read the files for the rest]` : input.patch;
1881
+ return [
1882
+ `You are the review gate for task ${input.task}${input.title ? ` \u2014 ${input.title}` : ""}${input.branch ? ` (branch ${input.branch})` : ""}.`,
1883
+ "You are read-only: you may Read, Grep and Glob files in this worktree to understand context. Do not edit anything.",
1884
+ "",
1885
+ "Judge the diff below against this rubric and nothing else:",
1886
+ `- ${REVIEW_RUBRIC}`,
1887
+ "- A finding is a concrete defect with a file and, when possible, a line \u2014 not style, not preference.",
1888
+ "- Severity: blocker (must not merge), major (should not merge), minor (worth fixing), nit.",
1889
+ '- verdict is "fail" if and only if there is at least one blocker or major finding.',
1890
+ "",
1891
+ "Respond with ONLY a JSON object, no prose, no code fence:",
1892
+ '{"verdict":"pass"|"fail","summary":"one sentence","findings":[{"file":"path","line":123,"severity":"blocker|major|minor|nit","summary":"what is wrong and why"}]}',
1893
+ "",
1894
+ "Files changed:",
1895
+ input.stat.trim() || "(no stat)",
1896
+ "",
1897
+ "Diff:",
1898
+ patch.trim() || "(empty diff)"
1899
+ ].join(`
1900
+ `);
1901
+ }
1902
+ function reviewArgs(prompt, opts = {}) {
1903
+ const args = [
1904
+ "-p",
1905
+ prompt,
1906
+ "--output-format",
1907
+ "json",
1908
+ "--allowedTools",
1909
+ "Read",
1910
+ "Grep",
1911
+ "Glob",
1912
+ "LS",
1913
+ "--disallowedTools",
1914
+ "Edit",
1915
+ "Write",
1916
+ "MultiEdit",
1917
+ "NotebookEdit",
1918
+ "Bash",
1919
+ "WebFetch",
1920
+ "WebSearch",
1921
+ "--permission-mode",
1922
+ "dontAsk"
1923
+ ];
1924
+ if (opts.model)
1925
+ args.push("--model", opts.model);
1926
+ return args;
1927
+ }
1928
+ function parseReviewVerdict(stdout) {
1929
+ let text = stdout.trim();
1930
+ try {
1931
+ const env = JSON.parse(text);
1932
+ if (env && typeof env === "object" && typeof env.result === "string")
1933
+ text = env.result.trim();
1934
+ } catch {}
1935
+ text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
1936
+ const start = text.indexOf("{");
1937
+ const end = text.lastIndexOf("}");
1938
+ if (start < 0 || end <= start)
1939
+ return null;
1940
+ let obj;
1941
+ try {
1942
+ obj = JSON.parse(text.slice(start, end + 1));
1943
+ } catch {
1944
+ return null;
1945
+ }
1946
+ if (!obj || typeof obj !== "object")
1947
+ return null;
1948
+ const o = obj;
1949
+ const findings = Array.isArray(o.findings) ? o.findings.filter((f) => !!f && typeof f === "object").map((f) => ({
1950
+ file: String(f.file ?? "").slice(0, 300),
1951
+ line: Number.isInteger(f.line) ? f.line : null,
1952
+ severity: ["blocker", "major", "minor", "nit"].includes(String(f.severity)) ? String(f.severity) : "minor",
1953
+ summary: String(f.summary ?? "").slice(0, 600)
1954
+ })).filter((f) => f.summary) : [];
1955
+ const serious = findings.some((f) => f.severity === "blocker" || f.severity === "major");
1956
+ const verdict = serious ? "fail" : o.verdict === "fail" ? "fail" : "pass";
1957
+ return { verdict, summary: String(o.summary ?? "").slice(0, 400), findings };
1958
+ }
1959
+ function reviewGateInput(task, gate, outcome) {
1960
+ if (outcome.kind === "error")
1961
+ return {
1962
+ task,
1963
+ gate,
1964
+ verdict: "fail",
1965
+ rubric: REVIEW_RUBRIC,
1966
+ evidence: `reviewer did not answer: ${outcome.reason}${outcome.output ? `
1967
+ ${outcome.output.slice(-1500)}` : ""}`
1968
+ };
1969
+ const v = outcome.verdict;
1970
+ const lines = v.findings.map((f) => `- [${f.severity}] ${f.file}${f.line ? `:${f.line}` : ""} \u2014 ${f.summary}`);
1971
+ const secs = (outcome.durationMs / 1000).toFixed(0);
1972
+ return {
1973
+ task,
1974
+ gate,
1975
+ verdict: v.verdict,
1976
+ rubric: REVIEW_RUBRIC,
1977
+ evidence: `${v.summary || (v.verdict === "pass" ? "no blocking findings" : "blocking findings")} (${v.findings.length} finding${v.findings.length === 1 ? "" : "s"}, ${secs}s)${lines.length ? `
1978
+ ${lines.join(`
1979
+ `)}` : ""}`
1980
+ };
1981
+ }
1179
1982
  // packages/core/src/tasks.ts
1180
1983
  var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
1181
1984
  var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
@@ -1335,18 +2138,91 @@ function linearIssuesQuery(teamKey, first = 200) {
1335
2138
  inverseRelations { nodes { type issue { identifier } } }
1336
2139
  } } }`;
1337
2140
  }
2141
+ // packages/core/src/worktree.ts
2142
+ import { join as join3 } from "path";
2143
+ function planBootstrap(cfg, repoRoot, worktree) {
2144
+ const seen = new Set;
2145
+ const copies = [];
2146
+ for (const raw of cfg.worktree.copy) {
2147
+ if (!isRepoRelative(raw))
2148
+ continue;
2149
+ const rel = raw.trim().replace(/^\.\//, "");
2150
+ if (seen.has(rel))
2151
+ continue;
2152
+ seen.add(rel);
2153
+ copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
2154
+ }
2155
+ return { copies, setup: cfg.worktree.setup };
2156
+ }
2157
+ var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
2158
+ function summarizeBootstrap(o) {
2159
+ const parts = [];
2160
+ if (o.copied.length)
2161
+ parts.push(`copied ${o.copied.join(", ")}`);
2162
+ if (o.skipped.length)
2163
+ parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
2164
+ if (o.setup)
2165
+ parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
2166
+ return parts.join("; ") || "nothing to do";
2167
+ }
2168
+ function canRemoveWorktree(w, heldByClaim, force) {
2169
+ if (w.main)
2170
+ return { ok: false, reason: "main" };
2171
+ if (heldByClaim)
2172
+ return { ok: false, reason: "held" };
2173
+ if (force)
2174
+ return { ok: true };
2175
+ if (w.dirty > 0)
2176
+ return { ok: false, reason: "dirty" };
2177
+ if (w.ahead > 0)
2178
+ return { ok: false, reason: "unpushed" };
2179
+ return { ok: true };
2180
+ }
2181
+ function removeRefusalMessage(reason, path, task) {
2182
+ switch (reason) {
2183
+ case "main":
2184
+ return `${path} is the main checkout \u2014 it is never removed`;
2185
+ case "held":
2186
+ return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
2187
+ case "dirty":
2188
+ return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
2189
+ case "unpushed":
2190
+ return `${path} has unpushed commits \u2014 push them, or --force to discard`;
2191
+ }
2192
+ }
2193
+ function planGc(worktrees, claims) {
2194
+ const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
2195
+ const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
2196
+ const out = [];
2197
+ for (const w of worktrees) {
2198
+ if (w.main || held.has(w.path))
2199
+ continue;
2200
+ const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
2201
+ if (!why)
2202
+ continue;
2203
+ const can = canRemoveWorktree(w, null, false);
2204
+ out.push({
2205
+ path: w.path,
2206
+ branch: w.branch,
2207
+ why,
2208
+ removable: can.ok,
2209
+ blocker: can.ok ? null : can.reason
2210
+ });
2211
+ }
2212
+ return out;
2213
+ }
1338
2214
  // packages/daemon/src/app.ts
1339
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
2215
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
1340
2216
  import { homedir as homedir4 } from "os";
1341
- import { dirname as dirname2, join as join7 } from "path";
2217
+ import { dirname as dirname3, join as join9 } from "path";
1342
2218
  import { fileURLToPath } from "url";
1343
2219
 
1344
2220
  // node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
1345
2221
  var compose = (middleware, onError, onNotFound) => {
1346
2222
  return (context, next) => {
1347
2223
  let index = -1;
1348
- return dispatch(0);
1349
- async function dispatch(i) {
2224
+ return dispatch2(0);
2225
+ async function dispatch2(i) {
1350
2226
  if (i <= index) {
1351
2227
  throw new Error("next() called multiple times");
1352
2228
  }
@@ -1362,7 +2238,7 @@ var compose = (middleware, onError, onNotFound) => {
1362
2238
  }
1363
2239
  if (handler) {
1364
2240
  try {
1365
- res = await handler(context, () => dispatch(i + 1));
2241
+ res = await handler(context, () => dispatch2(i + 1));
1366
2242
  } catch (err) {
1367
2243
  if (err instanceof Error && onError) {
1368
2244
  context.error = err;
@@ -1547,7 +2423,7 @@ var tryDecode = (str, decoder) => {
1547
2423
  }
1548
2424
  };
1549
2425
  var tryDecodeURI = (str) => tryDecode(str, decodeURI);
1550
- var getPath = (request) => {
2426
+ var getPath2 = (request) => {
1551
2427
  const url = request.url;
1552
2428
  const start = url.indexOf("/", url.indexOf(":") + 4);
1553
2429
  let i = start;
@@ -1566,7 +2442,7 @@ var getPath = (request) => {
1566
2442
  return url.slice(start, i);
1567
2443
  };
1568
2444
  var getPathNoStrict = (request) => {
1569
- const result = getPath(request);
2445
+ const result = getPath2(request);
1570
2446
  return result.length > 1 && result.at(-1) === "/" ? result.slice(0, -1) : result;
1571
2447
  };
1572
2448
  var mergePath = (base, sub, ...rest) => {
@@ -2088,7 +2964,7 @@ var Hono = class _Hono {
2088
2964
  };
2089
2965
  const { strict, ...optionsWithoutStrict } = options;
2090
2966
  Object.assign(this, optionsWithoutStrict);
2091
- this.getPath = strict ?? true ? options.getPath ?? getPath : getPathNoStrict;
2967
+ this.getPath = strict ?? true ? options.getPath ?? getPath2 : getPathNoStrict;
2092
2968
  }
2093
2969
  #clone() {
2094
2970
  const clone = new _Hono({
@@ -2958,75 +3834,328 @@ var streamSSE = (c, cb, onError) => {
2958
3834
  return c.newResponse(stream.responseReadable);
2959
3835
  };
2960
3836
 
2961
- // packages/daemon/src/forge.ts
2962
- import { existsSync as existsSync3 } from "fs";
2963
- import { homedir as homedir2 } from "os";
2964
- import { join as join3 } from "path";
2965
- var EXTRA_BIN_DIRS = [
2966
- "/opt/homebrew/bin",
2967
- "/usr/local/bin",
2968
- "/home/linuxbrew/.linuxbrew/bin",
2969
- join3(homedir2(), ".local", "bin"),
2970
- join3(homedir2(), "bin")
2971
- ];
2972
- function findBin(name) {
2973
- if (!name)
2974
- return null;
2975
- const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
2976
- if (onPath)
2977
- return onPath;
2978
- for (const d of EXTRA_BIN_DIRS) {
2979
- const p = join3(d, name);
2980
- if (existsSync3(p))
2981
- return p;
2982
- }
2983
- return null;
2984
- }
2985
- var GH_FIELDS = "number,title,headRefName,url,author,isDraft,mergeable,reviewDecision,statusCheckRollup,createdAt";
2986
-
2987
- class ForgeService {
3837
+ // packages/daemon/src/dispatcher.ts
3838
+ class Dispatcher {
2988
3839
  store;
2989
- cache = new Map;
2990
- inflight = new Set;
2991
- constructor(store) {
3840
+ runner;
3841
+ forge;
3842
+ entries = new Map;
3843
+ opts = new Map;
3844
+ constructor(store, runner, forge2) {
2992
3845
  this.store = store;
3846
+ this.runner = runner;
3847
+ this.forge = forge2;
3848
+ runner.onEnd((run2) => void this.onRunEnd(run2));
2993
3849
  }
2994
- prs() {
2995
- this.refresh();
2996
- const all = [...this.cache.values()].flatMap((c) => c.prs);
2997
- return all.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
2998
- }
2999
- async refresh(maxAgeMs = 120000) {
3000
- const projects = this.store.projects();
3001
- await Promise.all(projects.map(async (p) => {
3002
- const hit = this.cache.get(p.id);
3003
- if (hit && Date.now() - hit.at < maxAgeMs)
3004
- return;
3005
- if (this.inflight.has(p.id))
3006
- return;
3007
- this.inflight.add(p.id);
3008
- try {
3009
- const prs = await this.poll(p.id, p.root);
3010
- this.cache.set(p.id, { at: Date.now(), prs });
3011
- } catch {
3012
- this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
3013
- } finally {
3014
- this.inflight.delete(p.id);
3015
- }
3016
- }));
3850
+ project(projectId) {
3851
+ let m = this.entries.get(projectId);
3852
+ if (!m) {
3853
+ m = new Map;
3854
+ this.entries.set(projectId, m);
3855
+ }
3856
+ return m;
3017
3857
  }
3018
- remote(root) {
3019
- const r = Bun.spawnSync(["git", "-C", root, "remote", "get-url", "origin"]);
3020
- if (r.exitCode !== 0)
3021
- return null;
3022
- return parseRemote(new TextDecoder().decode(r.stdout).trim());
3858
+ status(projectId) {
3859
+ return [...this.entries.get(projectId)?.values() ?? []];
3023
3860
  }
3024
- async run(cmd, cwd) {
3025
- const bin = findBin(cmd[0]);
3026
- if (!bin)
3027
- return null;
3028
- const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd, stdout: "pipe", stderr: "ignore" });
3029
- const out = await new Response(proc.stdout).text();
3861
+ async dispatch(projectId, wanted, o = {}) {
3862
+ const board = this.store.tasks(projectId);
3863
+ if (!board)
3864
+ return {
3865
+ ok: false,
3866
+ error: "this repo has no task source ([tasks] source in .swarm.toml)"
3867
+ };
3868
+ if (board.error)
3869
+ return { ok: false, error: `task source: ${board.error}` };
3870
+ const cfg = this.store.config(projectId).dispatch;
3871
+ const opts = {
3872
+ owner: o.owner ?? "dispatch",
3873
+ ...o,
3874
+ maxParallel: o.maxParallel ?? cfg.max_parallel
3875
+ };
3876
+ this.opts.set(projectId, opts);
3877
+ const m = this.project(projectId);
3878
+ const running = [...m.values()].filter((e) => e.state === "running").length;
3879
+ const plan = planDispatch(board.tasks, wanted, {
3880
+ maxParallel: opts.maxParallel,
3881
+ running,
3882
+ max: o.max,
3883
+ alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
3884
+ });
3885
+ const now = new Date().toISOString();
3886
+ for (const t of [...plan.start, ...plan.queued]) {
3887
+ m.set(t.id, {
3888
+ task: t.id,
3889
+ title: t.title,
3890
+ state: "queued",
3891
+ runId: null,
3892
+ sessionId: null,
3893
+ queuedAt: now,
3894
+ startedAt: null,
3895
+ endedAt: null,
3896
+ outcome: null,
3897
+ detail: null,
3898
+ costUsd: null
3899
+ });
3900
+ }
3901
+ if (plan.start.length || plan.queued.length)
3902
+ this.store.append({
3903
+ ts: now,
3904
+ type: "dispatch.queued",
3905
+ projectId,
3906
+ sessionId: null,
3907
+ payload: {
3908
+ tasks: [...plan.start, ...plan.queued].map((t) => t.id),
3909
+ maxParallel: opts.maxParallel,
3910
+ summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
3911
+ }
3912
+ });
3913
+ const started = [];
3914
+ const failed = [];
3915
+ for (const t of plan.start) {
3916
+ const r = await this.startOne(projectId, t);
3917
+ if (r.ok)
3918
+ started.push(t.id);
3919
+ else
3920
+ failed.push({ id: t.id, reason: r.reason });
3921
+ }
3922
+ await this.fill(projectId);
3923
+ return {
3924
+ ok: true,
3925
+ started,
3926
+ queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
3927
+ rejected: [...plan.rejected, ...failed]
3928
+ };
3929
+ }
3930
+ async startOne(projectId, t) {
3931
+ const m = this.project(projectId);
3932
+ const e = m.get(t.id);
3933
+ const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
3934
+ const cfg = this.store.config(projectId);
3935
+ const gates2 = cfg.gates;
3936
+ const prompt = taskPrompt(t, {
3937
+ requiredGates: gates2.required,
3938
+ executableGates: gates2.required.filter((g) => gates2.defs[g]),
3939
+ openPr: cfg.dispatch.require_pr
3940
+ });
3941
+ const r = await this.runner.start({
3942
+ projectId,
3943
+ task: t.id,
3944
+ prompt,
3945
+ owner: opts.owner,
3946
+ permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
3947
+ model: opts.model ?? cfg.dispatch.model ?? undefined,
3948
+ maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
3949
+ profile: opts.profile ?? cfg.dispatch.profile ?? undefined
3950
+ });
3951
+ if (!r.ok) {
3952
+ if (e) {
3953
+ e.state = "finished";
3954
+ e.endedAt = new Date().toISOString();
3955
+ e.outcome = "crashed";
3956
+ e.detail = r.reason;
3957
+ }
3958
+ this.store.append({
3959
+ ts: new Date().toISOString(),
3960
+ type: "dispatch.finished",
3961
+ projectId,
3962
+ sessionId: null,
3963
+ payload: {
3964
+ task: t.id,
3965
+ outcome: "crashed",
3966
+ detail: r.reason,
3967
+ summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
3968
+ }
3969
+ });
3970
+ return { ok: false, reason: r.reason };
3971
+ }
3972
+ if (e) {
3973
+ e.state = "running";
3974
+ e.runId = r.run.id;
3975
+ e.sessionId = r.run.sessionId;
3976
+ e.startedAt = r.run.startedAt;
3977
+ }
3978
+ this.store.append({
3979
+ ts: r.run.startedAt,
3980
+ type: "dispatch.started",
3981
+ projectId,
3982
+ sessionId: r.run.sessionId,
3983
+ payload: {
3984
+ task: t.id,
3985
+ runId: r.run.id,
3986
+ worktree: r.run.worktree,
3987
+ summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
3988
+ }
3989
+ });
3990
+ return { ok: true };
3991
+ }
3992
+ async fill(projectId) {
3993
+ const m = this.project(projectId);
3994
+ const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
3995
+ for (const e of m.values()) {
3996
+ const running = [...m.values()].filter((x) => x.state === "running").length;
3997
+ if (running >= cap)
3998
+ return;
3999
+ if (e.state !== "queued")
4000
+ continue;
4001
+ await this.startOne(projectId, { id: e.task, title: e.title });
4002
+ }
4003
+ }
4004
+ async onRunEnd(run2) {
4005
+ const m = this.entries.get(run2.projectId);
4006
+ const e = m?.get(run2.task);
4007
+ if (!e || e.runId !== run2.id)
4008
+ return;
4009
+ const cfg = this.store.config(run2.projectId);
4010
+ const required = cfg.gates.required;
4011
+ let runs = this.store.gateRuns(run2.projectId, run2.task);
4012
+ const status = this.store.gateStatusFor(runs, required);
4013
+ const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
4014
+ if (missing.length && !run2.stopped) {
4015
+ await this.store.runGates(run2.projectId, run2.task, missing, {
4016
+ sessionId: run2.sessionId,
4017
+ owner: "dispatch"
4018
+ });
4019
+ runs = this.store.gateRuns(run2.projectId, run2.task);
4020
+ }
4021
+ const satisfied = gatesSatisfied(runs, required);
4022
+ await this.forge.refresh(0).catch(() => {});
4023
+ const branch = `task/${run2.task}`;
4024
+ const pr = this.forge.prs().find((p) => p.projectId === run2.projectId && p.branch === branch);
4025
+ const outcome = dispatchOutcome({
4026
+ exitCode: run2.exitCode,
4027
+ isError: run2.result?.isError ?? false,
4028
+ gatesSatisfied: satisfied,
4029
+ prOpen: Boolean(pr),
4030
+ requirePr: cfg.dispatch.require_pr,
4031
+ stopped: run2.stopped ?? false
4032
+ });
4033
+ const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
4034
+ const detail = [
4035
+ `exit ${run2.exitCode}${run2.result?.isError ? " (error)" : ""}`,
4036
+ required.length ? `gates: ${verdicts}` : null,
4037
+ pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
4038
+ ].filter(Boolean).join(" \xB7 ");
4039
+ e.state = "finished";
4040
+ e.endedAt = run2.endedAt;
4041
+ e.outcome = outcome;
4042
+ e.detail = detail;
4043
+ e.costUsd = run2.result?.costUsd ?? null;
4044
+ const ts = run2.endedAt ?? new Date().toISOString();
4045
+ this.store.append({
4046
+ ts,
4047
+ type: "dispatch.finished",
4048
+ projectId: run2.projectId,
4049
+ sessionId: run2.sessionId,
4050
+ payload: {
4051
+ task: run2.task,
4052
+ runId: run2.id,
4053
+ outcome,
4054
+ detail,
4055
+ costUsd: e.costUsd,
4056
+ summary: `dispatch ${run2.task}: ${outcome} \u2014 ${detail}`
4057
+ }
4058
+ });
4059
+ if (outcome !== "done" && outcome !== "stopped")
4060
+ this.store.append({
4061
+ ts,
4062
+ type: "incident.opened",
4063
+ projectId: run2.projectId,
4064
+ sessionId: run2.sessionId,
4065
+ payload: {
4066
+ rule: "dispatch_failed",
4067
+ action: outcome,
4068
+ command: run2.task,
4069
+ reason: `dispatched run on ${run2.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
4070
+ }
4071
+ });
4072
+ this.store.touch();
4073
+ await this.fill(run2.projectId);
4074
+ }
4075
+ clear(projectId, task) {
4076
+ const m = this.project(projectId);
4077
+ let n = 0;
4078
+ for (const [id, e] of m) {
4079
+ if (task && id !== task)
4080
+ continue;
4081
+ if (e.state === "queued" || e.state === "finished" && !task) {
4082
+ m.delete(id);
4083
+ n++;
4084
+ }
4085
+ }
4086
+ return n;
4087
+ }
4088
+ }
4089
+
4090
+ // packages/daemon/src/forge.ts
4091
+ import { existsSync as existsSync3 } from "fs";
4092
+ import { homedir as homedir2 } from "os";
4093
+ import { join as join4 } from "path";
4094
+ var EXTRA_BIN_DIRS = [
4095
+ "/opt/homebrew/bin",
4096
+ "/usr/local/bin",
4097
+ "/home/linuxbrew/.linuxbrew/bin",
4098
+ join4(homedir2(), ".local", "bin"),
4099
+ join4(homedir2(), "bin")
4100
+ ];
4101
+ function findBin(name) {
4102
+ if (!name)
4103
+ return null;
4104
+ const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
4105
+ if (onPath)
4106
+ return onPath;
4107
+ for (const d of EXTRA_BIN_DIRS) {
4108
+ const p = join4(d, name);
4109
+ if (existsSync3(p))
4110
+ return p;
4111
+ }
4112
+ return null;
4113
+ }
4114
+ var GH_FIELDS = "number,title,headRefName,url,author,isDraft,mergeable,reviewDecision,statusCheckRollup,createdAt";
4115
+
4116
+ class ForgeService {
4117
+ store;
4118
+ cache = new Map;
4119
+ inflight = new Set;
4120
+ constructor(store) {
4121
+ this.store = store;
4122
+ }
4123
+ prs() {
4124
+ this.refresh();
4125
+ const all = [...this.cache.values()].flatMap((c) => c.prs);
4126
+ return all.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
4127
+ }
4128
+ async refresh(maxAgeMs = 120000) {
4129
+ const projects = this.store.projects();
4130
+ await Promise.all(projects.map(async (p) => {
4131
+ const hit = this.cache.get(p.id);
4132
+ if (hit && Date.now() - hit.at < maxAgeMs)
4133
+ return;
4134
+ if (this.inflight.has(p.id))
4135
+ return;
4136
+ this.inflight.add(p.id);
4137
+ try {
4138
+ const prs = await this.poll(p.id, p.root);
4139
+ this.cache.set(p.id, { at: Date.now(), prs });
4140
+ } catch {
4141
+ this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
4142
+ } finally {
4143
+ this.inflight.delete(p.id);
4144
+ }
4145
+ }));
4146
+ }
4147
+ remote(root) {
4148
+ const r = Bun.spawnSync(["git", "-C", root, "remote", "get-url", "origin"]);
4149
+ if (r.exitCode !== 0)
4150
+ return null;
4151
+ return parseRemote(new TextDecoder().decode(r.stdout).trim());
4152
+ }
4153
+ async run(cmd, cwd) {
4154
+ const bin = findBin(cmd[0]);
4155
+ if (!bin)
4156
+ return null;
4157
+ const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd, stdout: "pipe", stderr: "ignore" });
4158
+ const out = await new Response(proc.stdout).text();
3030
4159
  return await proc.exited === 0 ? out : null;
3031
4160
  }
3032
4161
  async poll(projectId, root) {
@@ -3045,6 +4174,69 @@ class ForgeService {
3045
4174
  }
3046
4175
  return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3047
4176
  }
4177
+ async openPR(projectId, worktree2, draft) {
4178
+ const p = this.store.projects().find((x) => x.id === projectId);
4179
+ if (!p)
4180
+ return { ok: false, error: "unknown project" };
4181
+ if (worktree2.main)
4182
+ return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
4183
+ if (!worktree2.branch)
4184
+ return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
4185
+ if (worktree2.dirty > 0)
4186
+ return {
4187
+ ok: false,
4188
+ error: `${worktree2.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
4189
+ };
4190
+ const remote = this.remote(p.root);
4191
+ if (!remote)
4192
+ return { ok: false, error: "no GitHub/GitLab remote on origin" };
4193
+ const cli = remote.forge === "github" ? "gh" : "glab";
4194
+ const bin = findBin(cli);
4195
+ if (!bin)
4196
+ return { ok: false, error: `${cli} is not installed` };
4197
+ const sh = async (cmd2, cwd) => {
4198
+ const proc = Bun.spawn(cmd2, { cwd, stdout: "pipe", stderr: "pipe" });
4199
+ const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
4200
+ return { ok: await proc.exited === 0, out: out.trim() };
4201
+ };
4202
+ const push = await sh(["git", "push", "-u", "origin", worktree2.branch], worktree2.path);
4203
+ if (!push.ok)
4204
+ return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
4205
+ const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree2.branch);
4206
+ if (existing)
4207
+ return { ok: true, url: existing.url, number: existing.number };
4208
+ const cmd = remote.forge === "github" ? [
4209
+ bin,
4210
+ "pr",
4211
+ "create",
4212
+ "--head",
4213
+ worktree2.branch,
4214
+ "--title",
4215
+ draft.title,
4216
+ "--body",
4217
+ draft.body,
4218
+ ...draft.isDraft ? ["--draft"] : []
4219
+ ] : [
4220
+ bin,
4221
+ "mr",
4222
+ "create",
4223
+ "--source-branch",
4224
+ worktree2.branch,
4225
+ "--title",
4226
+ draft.title,
4227
+ "--description",
4228
+ draft.body,
4229
+ "--yes",
4230
+ ...draft.isDraft ? ["--draft"] : []
4231
+ ];
4232
+ const r = await sh(cmd, worktree2.path);
4233
+ if (!r.ok)
4234
+ return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
4235
+ const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
4236
+ const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
4237
+ this.cache.delete(projectId);
4238
+ return { ok: true, url, number: Number.isFinite(num) ? num : null };
4239
+ }
3048
4240
  async merge(projectId, number) {
3049
4241
  const p = this.store.projects().find((x) => x.id === projectId);
3050
4242
  if (!p)
@@ -3065,9 +4257,216 @@ class ForgeService {
3065
4257
  }
3066
4258
  }
3067
4259
 
4260
+ // packages/daemon/src/git.ts
4261
+ import { realpathSync } from "fs";
4262
+ import { join as join5 } from "path";
4263
+ function git(cwd, args) {
4264
+ try {
4265
+ const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
4266
+ return r.exitCode === 0 ? r.stdout.toString() : null;
4267
+ } catch {
4268
+ return null;
4269
+ }
4270
+ }
4271
+ function gitCommonDir(cwd) {
4272
+ const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
4273
+ if (!out)
4274
+ return null;
4275
+ try {
4276
+ return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
4277
+ } catch {
4278
+ return null;
4279
+ }
4280
+ }
4281
+ function gitToplevel(cwd) {
4282
+ const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
4283
+ if (!out)
4284
+ return null;
4285
+ try {
4286
+ return realpathSync(out);
4287
+ } catch {
4288
+ return null;
4289
+ }
4290
+ }
4291
+ function parseWorktreeList(out) {
4292
+ const wts = [];
4293
+ let cur = null;
4294
+ const flush = () => {
4295
+ if (cur?.path) {
4296
+ wts.push({
4297
+ path: cur.path,
4298
+ branch: cur.branch ?? null,
4299
+ head: (cur.head ?? "").slice(0, 7),
4300
+ main: wts.length === 0,
4301
+ dirty: -1,
4302
+ ahead: -1,
4303
+ behind: -1,
4304
+ merged: false
4305
+ });
4306
+ }
4307
+ cur = null;
4308
+ };
4309
+ for (const line of out.split(`
4310
+ `)) {
4311
+ if (line.startsWith("worktree ")) {
4312
+ flush();
4313
+ cur = { path: line.slice(9) };
4314
+ } else if (line.startsWith("HEAD ") && cur)
4315
+ cur.head = line.slice(5);
4316
+ else if (line.startsWith("branch ") && cur)
4317
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
4318
+ else if (line === "")
4319
+ flush();
4320
+ }
4321
+ flush();
4322
+ return wts;
4323
+ }
4324
+ function applyStatus(w, st, ah) {
4325
+ w.dirty = st === null ? -1 : st.split(`
4326
+ `).filter(Boolean).length;
4327
+ const a = ah?.trim();
4328
+ w.ahead = a === undefined || a === "" ? -1 : Number(a);
4329
+ }
4330
+ function applyDrift(w, behind, ancestor, firstParents) {
4331
+ const b = behind?.trim();
4332
+ w.behind = b === undefined || b === "" ? -1 : Number(b);
4333
+ const onLine = firstParents?.split(`
4334
+ `).some((sha) => sha.startsWith(w.head)) ?? true;
4335
+ w.merged = ancestor && !onLine;
4336
+ }
4337
+ var FIRST_PARENT_DEPTH = "5000";
4338
+ var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
4339
+ async function gitAsync(cwd, args) {
4340
+ try {
4341
+ const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
4342
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
4343
+ return code === 0 ? out : null;
4344
+ } catch {
4345
+ return null;
4346
+ }
4347
+ }
4348
+ async function listWorktreesAsync(root) {
4349
+ const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
4350
+ if (!out)
4351
+ return [];
4352
+ const wts = parseWorktreeList(out);
4353
+ const base = baseOf(wts);
4354
+ const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
4355
+ await Promise.all(wts.map(async (w) => {
4356
+ const drift = base && !w.main;
4357
+ const [st, ah, be, mg] = await Promise.all([
4358
+ gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
4359
+ gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
4360
+ drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
4361
+ drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
4362
+ ]);
4363
+ applyStatus(w, st, ah);
4364
+ if (drift)
4365
+ applyDrift(w, be, mg !== null, line);
4366
+ }));
4367
+ return wts;
4368
+ }
4369
+ var branchCache = new Map;
4370
+ function currentBranch(cwd) {
4371
+ const hit = branchCache.get(cwd);
4372
+ const now = Date.now();
4373
+ if (hit && now - hit.t < 5000)
4374
+ return hit.v;
4375
+ const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
4376
+ branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
4377
+ return branchCache.get(cwd)?.v ?? null;
4378
+ }
4379
+ function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
4380
+ const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
4381
+ const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
4382
+ if (git(repoRoot, args) === null)
4383
+ return null;
4384
+ try {
4385
+ return realpathSync(path);
4386
+ } catch {
4387
+ return path;
4388
+ }
4389
+ }
4390
+ function worktreeRemove(repoRoot, path, force) {
4391
+ const args = ["worktree", "remove", path];
4392
+ if (force)
4393
+ args.push("--force");
4394
+ return git(repoRoot, args) !== null;
4395
+ }
4396
+ function heldWork(path) {
4397
+ const status = git(path, ["status", "--porcelain"]);
4398
+ const dirty = status !== null && status.trim().length > 0;
4399
+ const count = (args) => {
4400
+ const out = git(path, ["rev-list", "--count", ...args])?.trim();
4401
+ return out !== undefined && out !== "" ? Number(out) : 0;
4402
+ };
4403
+ let unpushed;
4404
+ if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
4405
+ unpushed = count(["@{upstream}..HEAD"]) > 0;
4406
+ } else {
4407
+ const baselines = ["--remotes"];
4408
+ for (const b of ["main", "master"]) {
4409
+ if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
4410
+ baselines.push(b);
4411
+ }
4412
+ unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
4413
+ }
4414
+ return { dirty, unpushed };
4415
+ }
4416
+ async function worktreeDiff(root, path) {
4417
+ const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
4418
+ const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
4419
+ const isMain = wts[0]?.path === path;
4420
+ const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
4421
+ const from = mb || "HEAD";
4422
+ const [numstat, names, log, status] = await Promise.all([
4423
+ gitAsync(path, ["diff", "--numstat", from]),
4424
+ gitAsync(path, ["diff", "--name-status", from]),
4425
+ mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
4426
+ gitAsync(path, ["status", "--porcelain"])
4427
+ ]);
4428
+ const files = parseNumstat(numstat ?? "", names ?? "");
4429
+ for (const line of (status ?? "").split(`
4430
+ `)) {
4431
+ if (line.startsWith("?? "))
4432
+ files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
4433
+ }
4434
+ return {
4435
+ base: mb ?? null,
4436
+ baseRef,
4437
+ files,
4438
+ commits: (log ?? "").split(`
4439
+ `).filter(Boolean),
4440
+ dirty: (status ?? "").trim().length > 0
4441
+ };
4442
+ }
4443
+ async function worktreePatch(path, base, file) {
4444
+ const from = base ?? "HEAD";
4445
+ if (file) {
4446
+ const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
4447
+ if (!tracked) {
4448
+ const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
4449
+ stdout: "pipe",
4450
+ stderr: "ignore"
4451
+ });
4452
+ const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
4453
+ return out;
4454
+ }
4455
+ return await gitAsync(path, ["diff", from, "--", file]) ?? "";
4456
+ }
4457
+ return await gitAsync(path, ["diff", from]) ?? "";
4458
+ }
4459
+ function realpathOr(p) {
4460
+ try {
4461
+ return realpathSync(p);
4462
+ } catch {
4463
+ return p;
4464
+ }
4465
+ }
4466
+
3068
4467
  // packages/daemon/src/runner.ts
3069
4468
  import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
3070
- import { join as join4 } from "path";
4469
+ import { join as join6 } from "path";
3071
4470
  var PERMISSION_MODES = [
3072
4471
  "acceptEdits",
3073
4472
  "auto",
@@ -3081,6 +4480,11 @@ class Runner {
3081
4480
  store;
3082
4481
  home;
3083
4482
  live = new Map;
4483
+ endListeners = new Set;
4484
+ onEnd(fn) {
4485
+ this.endListeners.add(fn);
4486
+ return () => this.endListeners.delete(fn);
4487
+ }
3084
4488
  constructor(store, home) {
3085
4489
  this.store = store;
3086
4490
  this.home = home;
@@ -3111,18 +4515,19 @@ class Runner {
3111
4515
  reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
3112
4516
  };
3113
4517
  const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
3114
- let worktree = held?.worktree ?? "";
3115
- if (!worktree) {
4518
+ let worktree2 = held?.worktree ?? "";
4519
+ if (!worktree2) {
3116
4520
  const c = this.store.claim(input.projectId, input.task, input.owner);
3117
4521
  if (!c.ok)
3118
4522
  return { ok: false, reason: c.error };
3119
- worktree = c.worktree;
4523
+ worktree2 = c.worktree;
3120
4524
  }
4525
+ await this.store.awaitBootstrap(worktree2);
3121
4526
  const sessionId = crypto.randomUUID();
3122
4527
  const id = sessionId.slice(0, 8);
3123
- const logDir = join4(this.home, "logs", project.id);
4528
+ const logDir = join6(this.home, "logs", project.id);
3124
4529
  mkdirSync2(logDir, { recursive: true });
3125
- const log = join4(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
4530
+ const log = join6(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
3126
4531
  const logFd = openSync(log, "a");
3127
4532
  const args = [
3128
4533
  bin,
@@ -3141,13 +4546,22 @@ class Runner {
3141
4546
  args.push("--model", input.model);
3142
4547
  if (input.permissionMode)
3143
4548
  args.push("--permission-mode", input.permissionMode);
3144
- if (input.allowedTools?.length)
3145
- args.push("--allowedTools", ...input.allowedTools);
4549
+ const profile = runProfile(input.profile);
4550
+ if (input.profile && !profile)
4551
+ return {
4552
+ ok: false,
4553
+ reason: `unknown profile ${input.profile} \u2014 one of ${Object.keys(RUN_PROFILES).join(", ")}`
4554
+ };
4555
+ const allowed = [...input.allowedTools ?? [], ...profile?.allowedTools ?? []];
4556
+ if (allowed.length)
4557
+ args.push("--allowedTools", ...allowed);
4558
+ if (profile?.disallowedTools.length)
4559
+ args.push("--disallowedTools", ...profile.disallowedTools);
3146
4560
  if (input.maxTurns)
3147
4561
  args.push("--max-turns", String(input.maxTurns));
3148
- this.store.preregisterSpawnedSession(sessionId, project.id, worktree, input.task);
4562
+ this.store.preregisterSpawnedSession(sessionId, project.id, worktree2, input.task);
3149
4563
  const proc = Bun.spawn(args, {
3150
- cwd: worktree,
4564
+ cwd: worktree2,
3151
4565
  env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
3152
4566
  stdin: "pipe",
3153
4567
  stdout: "pipe",
@@ -3158,11 +4572,12 @@ class Runner {
3158
4572
  sessionId,
3159
4573
  projectId: project.id,
3160
4574
  task: input.task,
3161
- worktree,
4575
+ worktree: worktree2,
3162
4576
  pid: proc.pid,
3163
4577
  owner: input.owner,
3164
4578
  model: input.model ?? null,
3165
4579
  permissionMode: input.permissionMode ?? null,
4580
+ profile: input.profile ?? null,
3166
4581
  prompt: input.prompt,
3167
4582
  log,
3168
4583
  startedAt: new Date().toISOString(),
@@ -3178,7 +4593,7 @@ class Runner {
3178
4593
  sessionId,
3179
4594
  kind: "proc",
3180
4595
  name: `run:${input.task}`,
3181
- cwd: worktree,
4596
+ cwd: worktree2,
3182
4597
  cmd: `claude -p (run ${id})`,
3183
4598
  owner: input.owner,
3184
4599
  log
@@ -3243,6 +4658,13 @@ class Runner {
3243
4658
  });
3244
4659
  this.store.endSpawnedSession(entry.run.sessionId);
3245
4660
  this.live.delete(id);
4661
+ for (const fn of this.endListeners) {
4662
+ try {
4663
+ fn(entry.run);
4664
+ } catch (e) {
4665
+ console.error("swarm run: onEnd listener failed:", e.message);
4666
+ }
4667
+ }
3246
4668
  }
3247
4669
  onLine(run2, line) {
3248
4670
  if (!line.startsWith("{"))
@@ -3366,6 +4788,7 @@ class Runner {
3366
4788
  if (!run2)
3367
4789
  return { ok: false, reason: "no live run" };
3368
4790
  const entry = this.live.get(run2.id);
4791
+ run2.stopped = true;
3369
4792
  try {
3370
4793
  const stdin = entry?.proc.stdin;
3371
4794
  if (stdin && typeof stdin !== "number")
@@ -3382,157 +4805,67 @@ class Runner {
3382
4805
  import { Database } from "bun:sqlite";
3383
4806
  import {
3384
4807
  closeSync,
3385
- existsSync as existsSync4,
3386
- mkdirSync as mkdirSync3,
3387
- openSync as openSync2,
4808
+ existsSync as existsSync5,
4809
+ mkdirSync as mkdirSync4,
4810
+ openSync as openSync3,
3388
4811
  readdirSync,
3389
4812
  readFileSync as readFileSync3,
3390
4813
  readSync,
3391
4814
  realpathSync as realpathSync2,
3392
4815
  renameSync,
3393
4816
  statSync,
4817
+ unlinkSync,
3394
4818
  writeFileSync as writeFileSync2
3395
4819
  } from "fs";
3396
- import { homedir as homedir3 } from "os";
3397
- import { basename, dirname, join as join6 } from "path";
4820
+ import { homedir as homedir3, tmpdir, userInfo } from "os";
4821
+ import { basename, dirname as dirname2, join as join8 } from "path";
3398
4822
 
3399
- // packages/daemon/src/git.ts
3400
- import { realpathSync } from "fs";
3401
- import { join as join5 } from "path";
3402
- function git(cwd, args) {
3403
- try {
3404
- const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3405
- return r.exitCode === 0 ? r.stdout.toString() : null;
3406
- } catch {
3407
- return null;
3408
- }
3409
- }
3410
- function gitCommonDir(cwd) {
3411
- const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
3412
- if (!out)
3413
- return null;
3414
- try {
3415
- return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
3416
- } catch {
3417
- return null;
3418
- }
3419
- }
3420
- function gitToplevel(cwd) {
3421
- const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
3422
- if (!out)
3423
- return null;
3424
- try {
3425
- return realpathSync(out);
3426
- } catch {
3427
- return null;
3428
- }
3429
- }
3430
- function parseWorktreeList(out) {
3431
- const wts = [];
3432
- let cur = null;
3433
- const flush = () => {
3434
- if (cur?.path) {
3435
- wts.push({
3436
- path: cur.path,
3437
- branch: cur.branch ?? null,
3438
- head: (cur.head ?? "").slice(0, 7),
3439
- main: wts.length === 0,
3440
- dirty: -1,
3441
- ahead: -1
3442
- });
4823
+ // packages/daemon/src/bootstrap.ts
4824
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
4825
+ import { dirname, join as join7 } from "path";
4826
+ function runBootstrap(plan, opts) {
4827
+ const logDir = join7(opts.home, "logs", opts.projectId);
4828
+ mkdirSync3(logDir, { recursive: true });
4829
+ const log = join7(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
4830
+ const copied = [];
4831
+ const skipped = [];
4832
+ for (const c of plan.copies) {
4833
+ if (!existsSync4(c.from)) {
4834
+ skipped.push(c.rel);
4835
+ continue;
3443
4836
  }
3444
- cur = null;
3445
- };
3446
- for (const line of out.split(`
3447
- `)) {
3448
- if (line.startsWith("worktree ")) {
3449
- flush();
3450
- cur = { path: line.slice(9) };
3451
- } else if (line.startsWith("HEAD ") && cur)
3452
- cur.head = line.slice(5);
3453
- else if (line.startsWith("branch ") && cur)
3454
- cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3455
- else if (line === "")
3456
- flush();
3457
- }
3458
- flush();
3459
- return wts;
3460
- }
3461
- function applyStatus(w, st, ah) {
3462
- w.dirty = st === null ? -1 : st.split(`
3463
- `).filter(Boolean).length;
3464
- const a = ah?.trim();
3465
- w.ahead = a === undefined || a === "" ? -1 : Number(a);
3466
- }
3467
- async function gitAsync(cwd, args) {
3468
- try {
3469
- const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3470
- const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3471
- return code === 0 ? out : null;
3472
- } catch {
3473
- return null;
3474
- }
3475
- }
3476
- async function listWorktreesAsync(root) {
3477
- const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
3478
- if (!out)
3479
- return [];
3480
- const wts = parseWorktreeList(out);
3481
- await Promise.all(wts.map(async (w) => {
3482
- const [st, ah] = await Promise.all([
3483
- gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
3484
- gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"])
3485
- ]);
3486
- applyStatus(w, st, ah);
3487
- }));
3488
- return wts;
3489
- }
3490
- var branchCache = new Map;
3491
- function currentBranch(cwd) {
3492
- const hit = branchCache.get(cwd);
3493
- const now = Date.now();
3494
- if (hit && now - hit.t < 5000)
3495
- return hit.v;
3496
- const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
3497
- branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
3498
- return branchCache.get(cwd)?.v ?? null;
3499
- }
3500
- function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
3501
- const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
3502
- const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
3503
- if (git(repoRoot, args) === null)
3504
- return null;
3505
- try {
3506
- return realpathSync(path);
3507
- } catch {
3508
- return path;
3509
- }
3510
- }
3511
- function worktreeRemove(repoRoot, path, force) {
3512
- const args = ["worktree", "remove", path];
3513
- if (force)
3514
- args.push("--force");
3515
- return git(repoRoot, args) !== null;
3516
- }
3517
- function heldWork(path) {
3518
- const status = git(path, ["status", "--porcelain"]);
3519
- const dirty = status !== null && status.trim().length > 0;
3520
- const count = (args) => {
3521
- const out = git(path, ["rev-list", "--count", ...args])?.trim();
3522
- return out !== undefined && out !== "" ? Number(out) : 0;
3523
- };
3524
- let unpushed;
3525
- if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
3526
- unpushed = count(["@{upstream}..HEAD"]) > 0;
3527
- } else {
3528
- const baselines = ["--remotes"];
3529
- for (const b of ["main", "master"]) {
3530
- if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
3531
- baselines.push(b);
4837
+ try {
4838
+ mkdirSync3(dirname(c.to), { recursive: true });
4839
+ cpSync(c.from, c.to, { recursive: true, force: true });
4840
+ copied.push(c.rel);
4841
+ } catch (e) {
4842
+ skipped.push(`${c.rel} (${e.message})`);
3532
4843
  }
3533
- unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
3534
4844
  }
3535
- return { dirty, unpushed };
4845
+ const done = (async () => {
4846
+ if (!plan.setup)
4847
+ return { copied, skipped, setup: null };
4848
+ const command = plan.setup;
4849
+ const started = Date.now();
4850
+ let exitCode = -1;
4851
+ try {
4852
+ const fd = openSync2(log, "a");
4853
+ const proc = Bun.spawn(["sh", "-c", command], {
4854
+ cwd: opts.worktree,
4855
+ stdin: "ignore",
4856
+ stdout: fd,
4857
+ stderr: fd,
4858
+ env: { ...process.env, SWARM_WORKTREE: opts.worktree, SWARM_TASK: opts.task }
4859
+ });
4860
+ exitCode = await proc.exited;
4861
+ } catch (e) {
4862
+ exitCode = -1;
4863
+ await Bun.write(log, `swarm: could not start setup: ${e.message}
4864
+ `);
4865
+ }
4866
+ return { copied, skipped, setup: { command, exitCode, durationMs: Date.now() - started } };
4867
+ })();
4868
+ return { log, done };
3536
4869
  }
3537
4870
 
3538
4871
  // packages/daemon/src/task-sources.ts
@@ -3658,6 +4991,12 @@ CREATE TABLE IF NOT EXISTS handoffs (
3658
4991
  files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
3659
4992
  );
3660
4993
  CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
4994
+ CREATE TABLE IF NOT EXISTS messages (
4995
+ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, session_id TEXT, task TEXT, kind TEXT,
4996
+ text TEXT, options TEXT, asked_by TEXT, created_at TEXT,
4997
+ answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
4998
+ );
4999
+ CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
3661
5000
  CREATE TABLE IF NOT EXISTS claims (
3662
5001
  project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
3663
5002
  acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
@@ -3678,15 +5017,18 @@ class Store {
3678
5017
  gen = 0;
3679
5018
  memo = new Map;
3680
5019
  constructor(home = swarmHome()) {
3681
- mkdirSync3(home, { recursive: true });
5020
+ mkdirSync4(home, { recursive: true });
3682
5021
  this.home = home;
3683
- this.db = new Database(join6(home, "swarm.db"));
5022
+ this.db = new Database(join8(home, "swarm.db"));
3684
5023
  this.loadPricing();
3685
5024
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
3686
5025
  this.db.exec(SCHEMA);
3687
5026
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
3688
5027
  this.ensureColumn("projects", "sort_order", "INTEGER");
3689
- this.migrateProjectsJson(join6(home, "projects.json"));
5028
+ this.ensureColumn("projects", "icon", "TEXT");
5029
+ this.ensureColumn("projects", "color", "TEXT");
5030
+ this.migrate();
5031
+ this.migrateProjectsJson(join8(home, "projects.json"));
3690
5032
  this.reconcileMovedProjects();
3691
5033
  this.slimExistingEvents();
3692
5034
  this.retypeNotificationIncidents();
@@ -3734,9 +5076,9 @@ class Store {
3734
5076
  reconcileMovedProjects() {
3735
5077
  const all = this.projects();
3736
5078
  for (const stale of all) {
3737
- if (existsSync4(stale.root))
5079
+ if (existsSync5(stale.root))
3738
5080
  continue;
3739
- const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync4(p.root));
5081
+ const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync5(p.root));
3740
5082
  if (live.length !== 1)
3741
5083
  continue;
3742
5084
  this.mergeProject(stale.id, live[0].id);
@@ -3768,8 +5110,59 @@ class Store {
3768
5110
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);
3769
5111
  }
3770
5112
  }
5113
+ static SCHEMA_VERSION = 1;
5114
+ schemaVersion() {
5115
+ return Number(this.meta("schema_version") ?? 0);
5116
+ }
5117
+ migrate() {
5118
+ const steps = [
5119
+ (db) => {
5120
+ for (const t of [
5121
+ "events",
5122
+ "claims",
5123
+ "resources",
5124
+ "processes",
5125
+ "handoffs",
5126
+ "gates",
5127
+ "incident_acks",
5128
+ "sessions"
5129
+ ]) {
5130
+ this.ensureColumn(t, "actor_kind", "TEXT");
5131
+ this.ensureColumn(t, "actor_id", "TEXT");
5132
+ }
5133
+ const user = osUser();
5134
+ const fill = (table, ownerCol, sessionCol, key) => {
5135
+ const rows = db.query(`SELECT rowid AS rid, ${ownerCol ?? "NULL"} AS owner, ${sessionCol ?? "NULL"} AS sid FROM ${table} WHERE actor_kind IS NULL`).all();
5136
+ const upd = db.query(`UPDATE ${table} SET actor_kind = ?, actor_id = ? WHERE rowid = ?`);
5137
+ for (const r of rows) {
5138
+ const a = actorFrom(r.owner, r.sid, { user });
5139
+ upd.run(a.kind, a.id, r.rid);
5140
+ }
5141
+ return `${key}:${rows.length}`;
5142
+ };
5143
+ fill("claims", "owner", null, "claims");
5144
+ fill("resources", "owner", "session_id", "resources");
5145
+ fill("processes", "owner", "session_id", "processes");
5146
+ fill("handoffs", "by", "session_id", "handoffs");
5147
+ fill("gates", "NULL", "session_id", "gates");
5148
+ fill("incident_acks", "'dashboard'", null, "acks");
5149
+ fill("sessions", "NULL", "id", "sessions");
5150
+ fill("events", "COALESCE(json_extract(payload, '$.owner'), json_extract(payload, '$.by'))", "session_id", "events");
5151
+ }
5152
+ ];
5153
+ for (let v = this.schemaVersion();v < steps.length; v++) {
5154
+ const step = steps[v];
5155
+ this.db.transaction(() => {
5156
+ step(this.db);
5157
+ this.setMeta("schema_version", String(v + 1));
5158
+ })();
5159
+ }
5160
+ }
5161
+ actorFor(owner, sessionId, runId) {
5162
+ return actorFrom(owner, sessionId, { user: osUser(), runId });
5163
+ }
3771
5164
  migrateProjectsJson(file) {
3772
- if (!existsSync4(file))
5165
+ if (!existsSync5(file))
3773
5166
  return;
3774
5167
  try {
3775
5168
  const list = JSON.parse(readFileSync3(file, "utf8"));
@@ -3784,11 +5177,12 @@ class Store {
3784
5177
  const hit = this.topCache.get(cwd);
3785
5178
  if (hit && Date.now() - hit.t < 1e4)
3786
5179
  return hit.v;
3787
- const v = cwd && existsSync4(cwd) ? gitToplevel(cwd) : null;
5180
+ const v = cwd && existsSync5(cwd) ? gitToplevel(cwd) : null;
3788
5181
  this.topCache.set(cwd, { v, t: Date.now() });
3789
5182
  return v;
3790
5183
  }
3791
- rulesCache = new Map;
5184
+ policyCache = new Map;
5185
+ policySeen = new Set;
3792
5186
  preregisterSpawnedSession(id, projectId, cwd, task) {
3793
5187
  const now = new Date().toISOString();
3794
5188
  this.db.query(`INSERT INTO sessions (id, project_id, kind, cwd, started_at, last_seen_at, last, last_type, state, title)
@@ -3817,8 +5211,8 @@ class Store {
3817
5211
  createdAt: new Date().toISOString()
3818
5212
  };
3819
5213
  const sessionId = this.knownSession(h.sessionId);
3820
- const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3821
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
5214
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at, actor_kind, actor_id)
5215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt, ...actorCols(this.actorFor(handoff.by, sessionId)));
3822
5216
  this.remember(handoffDoc(projectId, Number(ins.lastInsertRowid), handoff, sessionId));
3823
5217
  this.append({
3824
5218
  ts: handoff.createdAt,
@@ -3868,8 +5262,8 @@ class Store {
3868
5262
  if (!h)
3869
5263
  return null;
3870
5264
  this.db.query("DELETE FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(held.projectId, held.task, sessionId);
3871
- const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3872
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt);
5265
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at, actor_kind, actor_id)
5266
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt, ...actorCols(this.actorFor(h.by, sessionId)));
3873
5267
  this.remember(handoffDoc(held.projectId, Number(ins.lastInsertRowid), h, sessionId));
3874
5268
  this.touch();
3875
5269
  return h;
@@ -3989,7 +5383,7 @@ class Store {
3989
5383
  }));
3990
5384
  }
3991
5385
  sessionContext(cwd) {
3992
- if (!cwd || !existsSync4(cwd))
5386
+ if (!cwd || !existsSync5(cwd))
3993
5387
  return null;
3994
5388
  const toplevel = this.toplevel(cwd);
3995
5389
  const project = this.resolveProject(cwd);
@@ -4001,6 +5395,9 @@ class Store {
4001
5395
  const h = this.latestHandoff(held.projectId, held.task);
4002
5396
  if (h)
4003
5397
  lines.push(formatHandoff(h));
5398
+ const qc = this.questionContext(held.task, held.projectId);
5399
+ if (qc)
5400
+ lines.push(qc);
4004
5401
  const required = this.requiredGates(held.projectId);
4005
5402
  if (required.length) {
4006
5403
  const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
@@ -4027,6 +5424,131 @@ class Store {
4027
5424
  lines.push(`[swarm] rules: ${on.join(" ")}`);
4028
5425
  return lines.length ? lines.join(`
4029
5426
  `) : null;
5427
+ }
5428
+ rowToQuestion(r) {
5429
+ return {
5430
+ id: r.id,
5431
+ projectId: r.project_id,
5432
+ sessionId: r.session_id ?? null,
5433
+ task: r.task ?? null,
5434
+ text: r.text,
5435
+ options: JSON.parse(r.options || "[]"),
5436
+ askedBy: r.asked_by ?? null,
5437
+ createdAt: r.created_at,
5438
+ answer: r.answer ?? null,
5439
+ answeredBy: r.answered_by ?? null,
5440
+ answeredAt: r.answered_at ?? null,
5441
+ deliveredAt: r.delivered_at ?? null
5442
+ };
5443
+ }
5444
+ questions(opts = {}) {
5445
+ const where = ["kind = 'question'"];
5446
+ const args = [];
5447
+ if (opts.projectId) {
5448
+ where.push("project_id = ?");
5449
+ args.push(opts.projectId);
5450
+ }
5451
+ if (opts.sessionId) {
5452
+ where.push("session_id = ?");
5453
+ args.push(opts.sessionId);
5454
+ }
5455
+ if (opts.open)
5456
+ where.push("answered_at IS NULL");
5457
+ args.push(opts.limit ?? 100);
5458
+ return this.db.query(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...args).map((r) => this.rowToQuestion(r));
5459
+ }
5460
+ question(id) {
5461
+ const r = this.db.query("SELECT * FROM messages WHERE id = ? AND kind = 'question'").get(id);
5462
+ return r ? this.rowToQuestion(r) : null;
5463
+ }
5464
+ ask(projectId, input) {
5465
+ if (!this.project(projectId))
5466
+ return { ok: false, error: "unknown project" };
5467
+ const v = validateQuestion(input.text, input.options);
5468
+ if (!v.ok)
5469
+ return { ok: false, error: v.reason };
5470
+ const sessionId = this.knownSession(input.sessionId ?? null);
5471
+ const task = (input.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(input.cwd, c.worktree))?.task : null) ?? null;
5472
+ const createdAt = new Date().toISOString();
5473
+ const r = this.db.query(`INSERT INTO messages (project_id, session_id, task, kind, text, options, asked_by, created_at)
5474
+ VALUES (?, ?, ?, 'question', ?, ?, ?, ?)`).run(projectId, sessionId, task, v.text, JSON.stringify(v.options), input.askedBy ?? null, createdAt);
5475
+ const q = this.question(Number(r.lastInsertRowid));
5476
+ this.append({
5477
+ ts: createdAt,
5478
+ type: "question.asked",
5479
+ projectId,
5480
+ sessionId,
5481
+ payload: {
5482
+ id: q.id,
5483
+ task,
5484
+ text: v.text,
5485
+ options: v.options,
5486
+ summary: `question #${q.id}: ${v.text.slice(0, 120)}`
5487
+ }
5488
+ });
5489
+ this.touch();
5490
+ return { ok: true, question: q };
5491
+ }
5492
+ answer(id, text, by) {
5493
+ const q = this.question(id);
5494
+ if (!q)
5495
+ return { ok: false, error: `no question #${id}` };
5496
+ if (q.answer !== null)
5497
+ return {
5498
+ ok: false,
5499
+ error: `#${id} was already answered by ${q.answeredBy ?? "someone"}`
5500
+ };
5501
+ const a = typeof text === "string" ? text.trim() : "";
5502
+ if (!a)
5503
+ return { ok: false, error: "an answer is required" };
5504
+ const at = new Date().toISOString();
5505
+ this.db.query("UPDATE messages SET answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(a, by, at, id);
5506
+ this.append({
5507
+ ts: at,
5508
+ type: "question.answered",
5509
+ projectId: q.projectId,
5510
+ sessionId: q.sessionId,
5511
+ payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
5512
+ });
5513
+ this.touch();
5514
+ return { ok: true, question: this.question(id) };
5515
+ }
5516
+ inbox(sessionId, opts = {}) {
5517
+ if (!sessionId)
5518
+ return [];
5519
+ 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);
5520
+ const qs = rows.map((r) => this.rowToQuestion(r));
5521
+ if (qs.length && !opts.peek)
5522
+ this.db.query(`UPDATE messages SET delivered_at = ? WHERE id IN (${qs.map(() => "?").join(",")})`).run(new Date().toISOString(), ...qs.map((q) => q.id));
5523
+ return qs;
5524
+ }
5525
+ answerContext(sessionId) {
5526
+ return formatAnswers(this.inbox(sessionId));
5527
+ }
5528
+ questionContext(task, projectId) {
5529
+ if (!task)
5530
+ return null;
5531
+ 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);
5532
+ const list = qs.map((r) => this.rowToQuestion(r));
5533
+ const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
5534
+ if (list.some((q) => q.answer !== null))
5535
+ 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);
5536
+ return parts.length ? parts.join(`
5537
+ `) : null;
5538
+ }
5539
+ contextFor(cwd, sessionId) {
5540
+ const parts = [];
5541
+ const base = this.sessionContext(cwd);
5542
+ if (base)
5543
+ parts.push(base);
5544
+ const answers = this.answerContext(sessionId);
5545
+ if (answers)
5546
+ parts.push(answers);
5547
+ const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
5548
+ if (open && !base?.includes(open))
5549
+ parts.push(open);
5550
+ return { text: parts.length ? parts.join(`
5551
+ `) : null, parts };
4030
5552
  }
4031
5553
  rowToGate(r) {
4032
5554
  return {
@@ -4045,8 +5567,319 @@ class Store {
4045
5567
  const rows = task ? this.db.query("SELECT * FROM gates WHERE project_id = ? AND task = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, task, limit) : this.db.query("SELECT * FROM gates WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT ?").all(projectId, limit);
4046
5568
  return rows.map((r) => this.rowToGate(r));
4047
5569
  }
4048
- gateStatusFor(runs, required) {
4049
- return gateStatus(runs, required);
5570
+ gateStatusFor(runs, required) {
5571
+ return gateStatus(runs, required);
5572
+ }
5573
+ config(projectId) {
5574
+ const p = this.project(projectId);
5575
+ return loadConfig({ repoRoot: p?.root ?? null, home: this.home });
5576
+ }
5577
+ gateDefs(projectId) {
5578
+ const p = this.project(projectId);
5579
+ return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates : null;
5580
+ }
5581
+ gateJobs = new Map;
5582
+ gateBatches = new Map;
5583
+ async awaitGates(projectId, task) {
5584
+ const prefix = `${projectId}:${task}:`;
5585
+ await Promise.all([
5586
+ ...[...this.gateJobs].filter(([k]) => k.startsWith(prefix)).map(([, v]) => v),
5587
+ ...this.gateBatches.get(`${projectId}:${task}`) ?? []
5588
+ ]);
5589
+ }
5590
+ runGate(projectId, task, gate, opts = {}) {
5591
+ const p = this.project(projectId);
5592
+ if (!p)
5593
+ return { ok: false, reason: "unknown project" };
5594
+ const cfg = this.gateDefs(projectId);
5595
+ const def = cfg?.defs[gate];
5596
+ if (!def)
5597
+ return {
5598
+ ok: false,
5599
+ reason: `gate ${gate} has no command \u2014 add [gates.${gate}] cmd = "\u2026" to .swarm.toml, or record it with swarm gate record`
5600
+ };
5601
+ const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
5602
+ const worktree2 = claim?.worktree;
5603
+ if (!worktree2 || !existsSync5(worktree2))
5604
+ return {
5605
+ ok: false,
5606
+ reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
5607
+ };
5608
+ const cwd = def.cwd ? join8(worktree2, def.cwd) : worktree2;
5609
+ if (!existsSync5(cwd))
5610
+ return { ok: false, reason: `gate cwd ${cwd} does not exist` };
5611
+ const key = `${projectId}:${task}:${gate}`;
5612
+ if (this.gateJobs.has(key))
5613
+ return { ok: false, reason: `${gate} is already running on ${task}` };
5614
+ const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
5615
+ const logDir = join8(this.home, "logs", projectId);
5616
+ mkdirSync4(logDir, { recursive: true });
5617
+ const log = join8(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
5618
+ if (def.builtin === "review")
5619
+ return this.runReviewGate(projectId, task, gate, def, { worktree: worktree2, cwd, key, log }, opts);
5620
+ writeFileSync2(log, `$ ${def.cmd}
5621
+ # cwd ${cwd} \xB7 ${new Date().toISOString()}
5622
+ `);
5623
+ const fd = openSync3(log, "a");
5624
+ let proc;
5625
+ try {
5626
+ proc = Bun.spawn(["sh", "-c", def.cmd], {
5627
+ cwd,
5628
+ stdin: "ignore",
5629
+ stdout: fd,
5630
+ stderr: fd,
5631
+ env: {
5632
+ ...process.env,
5633
+ SWARM_WORKTREE: worktree2,
5634
+ SWARM_TASK: task,
5635
+ SWARM_GATE: gate,
5636
+ CI: process.env.CI ?? "1"
5637
+ }
5638
+ });
5639
+ } catch (e) {
5640
+ closeSync(fd);
5641
+ const run2 = this.recordGate(projectId, {
5642
+ ...executedGateInput(task, gate, def.cmd, {
5643
+ exitCode: null,
5644
+ durationMs: 0,
5645
+ output: e.message
5646
+ }),
5647
+ sessionId: opts.sessionId ?? null
5648
+ });
5649
+ return { ok: true, pid: 0, log, done: Promise.resolve(run2.ok ? run2.run : null) };
5650
+ }
5651
+ const started = Date.now();
5652
+ const reg = this.registerProcess({
5653
+ pid: proc.pid,
5654
+ projectId,
5655
+ sessionId: opts.sessionId ?? null,
5656
+ kind: "gate",
5657
+ name: `gate:${task}:${gate}`,
5658
+ cwd,
5659
+ cmd: def.cmd,
5660
+ owner: opts.owner ?? "daemon",
5661
+ log
5662
+ });
5663
+ let timedOut = false;
5664
+ const timer = setTimeout(() => {
5665
+ timedOut = true;
5666
+ try {
5667
+ proc.kill("SIGTERM");
5668
+ setTimeout(() => {
5669
+ try {
5670
+ proc.kill("SIGKILL");
5671
+ } catch {}
5672
+ }, 5000).unref();
5673
+ } catch {}
5674
+ }, def.timeout * 1000);
5675
+ const done = proc.exited.then((code) => {
5676
+ clearTimeout(timer);
5677
+ closeSync(fd);
5678
+ let output = "";
5679
+ try {
5680
+ output = readFileSync3(log, "utf8");
5681
+ } catch {}
5682
+ const input = executedGateInput(task, gate, def.cmd, {
5683
+ exitCode: timedOut ? null : code,
5684
+ timedOut,
5685
+ durationMs: Date.now() - started,
5686
+ output
5687
+ });
5688
+ const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
5689
+ if (reg.ok)
5690
+ this.processes(projectId);
5691
+ return run2.ok ? run2.run : null;
5692
+ }).finally(() => {
5693
+ this.gateJobs.delete(key);
5694
+ this.touch();
5695
+ });
5696
+ this.gateJobs.set(key, done);
5697
+ return { ok: true, pid: proc.pid, log, done };
5698
+ }
5699
+ runReviewGate(projectId, task, gate, def, where, opts) {
5700
+ const bin = findBin("claude");
5701
+ if (!bin)
5702
+ return { ok: false, reason: "claude CLI not found \u2014 the review gate needs Claude Code" };
5703
+ const p = this.project(projectId);
5704
+ if (!p)
5705
+ return { ok: false, reason: "unknown project" };
5706
+ const started = Date.now();
5707
+ const record = (input) => {
5708
+ const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
5709
+ return run2.ok ? run2.run : null;
5710
+ };
5711
+ const done = (async () => {
5712
+ let diffText = "";
5713
+ let stat = "";
5714
+ try {
5715
+ const diff = await worktreeDiff(p.root, where.worktree);
5716
+ stat = diff.files.map((f) => `${f.status ?? "M"} ${f.path} (+${f.added} -${f.deleted})`).join(`
5717
+ `);
5718
+ diffText = await worktreePatch(where.worktree, diff.base);
5719
+ } catch (e) {
5720
+ return record(reviewGateInput(task, gate, {
5721
+ kind: "error",
5722
+ reason: `diff failed: ${e.message}`,
5723
+ durationMs: Date.now() - started
5724
+ }));
5725
+ }
5726
+ if (!diffText.trim())
5727
+ return record(reviewGateInput(task, gate, {
5728
+ kind: "verdict",
5729
+ durationMs: Date.now() - started,
5730
+ verdict: { verdict: "pass", summary: "nothing to review \u2014 empty diff", findings: [] }
5731
+ }));
5732
+ const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
5733
+ const w = this.findWorktree(projectId, where.worktree);
5734
+ const prompt = reviewPrompt({
5735
+ task,
5736
+ title: taskRow?.title ?? null,
5737
+ branch: w?.branch ?? null,
5738
+ stat,
5739
+ patch: diffText
5740
+ });
5741
+ writeFileSync2(where.log, `$ claude -p <review prompt, ${prompt.length} chars> --output-format json (read-only)
5742
+ # cwd ${where.cwd} \xB7 ${new Date().toISOString()}
5743
+ `);
5744
+ let proc;
5745
+ try {
5746
+ proc = Bun.spawn([bin, ...reviewArgs(prompt, { model: def.model })], {
5747
+ cwd: where.cwd,
5748
+ stdin: "ignore",
5749
+ stdout: "pipe",
5750
+ stderr: "pipe",
5751
+ env: {
5752
+ ...process.env,
5753
+ SWARM_WORKTREE: where.worktree,
5754
+ SWARM_TASK: task,
5755
+ SWARM_GATE: gate,
5756
+ CLAUDE_CODE_DISABLE_AUTOUPDATE: "1"
5757
+ }
5758
+ });
5759
+ } catch (e) {
5760
+ return record(reviewGateInput(task, gate, {
5761
+ kind: "error",
5762
+ reason: e.message,
5763
+ durationMs: Date.now() - started
5764
+ }));
5765
+ }
5766
+ const reg = this.registerProcess({
5767
+ pid: proc.pid,
5768
+ projectId,
5769
+ sessionId: opts.sessionId ?? null,
5770
+ kind: "gate",
5771
+ name: `gate:${task}:${gate}`,
5772
+ cwd: where.cwd,
5773
+ cmd: "claude -p (review)",
5774
+ owner: opts.owner ?? "daemon",
5775
+ log: where.log
5776
+ });
5777
+ let timedOut = false;
5778
+ const timer = setTimeout(() => {
5779
+ timedOut = true;
5780
+ try {
5781
+ proc.kill("SIGTERM");
5782
+ setTimeout(() => {
5783
+ try {
5784
+ proc.kill("SIGKILL");
5785
+ } catch {}
5786
+ }, 5000).unref();
5787
+ } catch {}
5788
+ }, def.timeout * 1000);
5789
+ const [out, err] = await Promise.all([
5790
+ new Response(proc.stdout).text(),
5791
+ new Response(proc.stderr).text()
5792
+ ]);
5793
+ const code = await proc.exited;
5794
+ clearTimeout(timer);
5795
+ try {
5796
+ writeFileSync2(where.log, `${readFileSync3(where.log, "utf8")}${out}
5797
+ ${err}
5798
+ # exit ${timedOut ? "timeout" : code} \xB7 ${((Date.now() - started) / 1000).toFixed(0)}s
5799
+ `);
5800
+ } catch {}
5801
+ if (reg.ok)
5802
+ this.processes(projectId);
5803
+ const durationMs = Date.now() - started;
5804
+ if (timedOut)
5805
+ return record(reviewGateInput(task, gate, {
5806
+ kind: "error",
5807
+ reason: `timed out after ${def.timeout}s`,
5808
+ durationMs,
5809
+ output: err
5810
+ }));
5811
+ const verdict = parseReviewVerdict(out);
5812
+ if (!verdict)
5813
+ return record(reviewGateInput(task, gate, {
5814
+ kind: "error",
5815
+ reason: code === 0 ? "no JSON verdict in the reply" : `claude exited ${code}`,
5816
+ durationMs,
5817
+ output: err || out
5818
+ }));
5819
+ return record(reviewGateInput(task, gate, { kind: "verdict", verdict, durationMs }));
5820
+ })().finally(() => {
5821
+ this.gateJobs.delete(where.key);
5822
+ this.touch();
5823
+ });
5824
+ this.gateJobs.set(where.key, done);
5825
+ return { ok: true, pid: 0, log: where.log, done };
5826
+ }
5827
+ async runGates(projectId, task, gates2, opts = {}) {
5828
+ const cfg = this.gateDefs(projectId);
5829
+ const names = gates2?.length ? gates2 : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
5830
+ const key = `${projectId}:${task}`;
5831
+ const batch = (async () => {
5832
+ const started = [];
5833
+ const skipped = [];
5834
+ const runs = [];
5835
+ for (const g of names) {
5836
+ const r = this.runGate(projectId, task, g, opts);
5837
+ if (!r.ok) {
5838
+ skipped.push({ gate: g, reason: r.reason });
5839
+ continue;
5840
+ }
5841
+ started.push(g);
5842
+ const run2 = await r.done;
5843
+ if (run2)
5844
+ runs.push(run2);
5845
+ }
5846
+ return { started, skipped, runs };
5847
+ })();
5848
+ const set = this.gateBatches.get(key) ?? new Set;
5849
+ set.add(batch);
5850
+ this.gateBatches.set(key, set);
5851
+ try {
5852
+ return await batch;
5853
+ } finally {
5854
+ set.delete(batch);
5855
+ if (!set.size)
5856
+ this.gateBatches.delete(key);
5857
+ }
5858
+ }
5859
+ autoGateAt = new Map;
5860
+ autoGate(event, sessionId, cwd) {
5861
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
5862
+ if (!held)
5863
+ return;
5864
+ const cfg = this.gateDefs(held.projectId);
5865
+ if (!cfg || cfg.auto === "off")
5866
+ return;
5867
+ if (cfg.auto === "session-end" && event !== "SessionEnd")
5868
+ return;
5869
+ if (!cfg.required.some((g) => cfg.defs[g]))
5870
+ return;
5871
+ const key = `${held.projectId}:${held.task}`;
5872
+ const now = Date.now();
5873
+ if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
5874
+ return;
5875
+ this.autoGateAt.set(key, now);
5876
+ this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => {
5877
+ if (!r.runs.length)
5878
+ return;
5879
+ const line = r.runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
5880
+ 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);
5881
+ this.touch();
5882
+ });
4050
5883
  }
4051
5884
  requiredGates(projectId) {
4052
5885
  const p = this.project(projectId);
@@ -4060,8 +5893,8 @@ class Store {
4060
5893
  return v;
4061
5894
  const createdAt = new Date().toISOString();
4062
5895
  const sessionId = this.knownSession(input.sessionId);
4063
- const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
4064
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
5896
+ const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at, actor_kind, actor_id)
5897
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt, ...actorCols(this.actorFor(input.sessionId ? null : "daemon", sessionId)));
4065
5898
  const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
4066
5899
  this.remember(gateDoc(projectId, run2.id, run2, sessionId));
4067
5900
  this.append({
@@ -4113,8 +5946,8 @@ class Store {
4113
5946
  hit = { tasks: e.tasks };
4114
5947
  error = e.error;
4115
5948
  } else {
4116
- const path = join6(p.root, source);
4117
- if (!existsSync4(path))
5949
+ const path = join8(p.root, source);
5950
+ if (!existsSync5(path))
4118
5951
  return { source, required: this.requiredGates(projectId), tasks: [] };
4119
5952
  const mtime = statSync(path).mtimeMs;
4120
5953
  let md = this.taskCache.get(projectId);
@@ -4147,15 +5980,81 @@ class Store {
4147
5980
  return { source, required, tasks: board, error };
4148
5981
  }
4149
5982
  rulesFor(repoRoot) {
5983
+ return this.policyFor(repoRoot).config.rules;
5984
+ }
5985
+ policyFor(repoRoot) {
4150
5986
  const key = repoRoot ?? "";
4151
- const hit = this.rulesCache.get(key);
5987
+ const hit = this.policyCache.get(key);
4152
5988
  if (hit && Date.now() - hit.at < 30000)
4153
- return hit.rules;
4154
- const rules2 = loadConfig({ repoRoot, home: this.home }).rules;
4155
- this.rulesCache.set(key, { at: Date.now(), rules: rules2 });
4156
- return rules2;
5989
+ return hit.loaded;
5990
+ const loaded = loadConfigDetailed({ repoRoot, home: this.home });
5991
+ this.policyCache.set(key, { at: Date.now(), loaded });
5992
+ this.writePolicyCache(loaded);
5993
+ return loaded;
5994
+ }
5995
+ writePolicyCache(loaded) {
5996
+ const file = join8(this.home, POLICY_CACHE_FILE);
5997
+ try {
5998
+ if (!hasLockedRules(loaded)) {
5999
+ if (existsSync5(file))
6000
+ unlinkSync(file);
6001
+ return;
6002
+ }
6003
+ const cache = buildPolicyCache(loaded, this.liveSessions(), this.heldWorktrees());
6004
+ writeFileSync2(file, JSON.stringify(cache), { mode: 384 });
6005
+ } catch (e) {
6006
+ console.error(`swarm: policy cache: ${e.message}`);
6007
+ }
6008
+ }
6009
+ guardDisabled(repoRoot) {
6010
+ return process.env.SWARM_GUARD === "off" && !hasLockedRules(this.policyFor(repoRoot));
6011
+ }
6012
+ claudeSettings() {
6013
+ const p = process.env.CLAUDE_SETTINGS ?? join8(homedir3(), ".claude", "settings.json");
6014
+ try {
6015
+ return existsSync5(p) ? JSON.parse(readFileSync3(p, "utf8")) : null;
6016
+ } catch {
6017
+ return null;
6018
+ }
6019
+ }
6020
+ checkPolicy(cwd, sessionId) {
6021
+ const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
6022
+ const repoRoot = project?.root ?? null;
6023
+ const loaded = this.policyFor(repoRoot);
6024
+ const settings = this.claudeSettings();
6025
+ const findings = policyFindings({
6026
+ loaded,
6027
+ coverage: settings === null ? null : hookCoverage(settings),
6028
+ guardOff: process.env.SWARM_GUARD === "off",
6029
+ repoRoot
6030
+ });
6031
+ for (const f of findings) {
6032
+ if (this.policySeen.has(f.key))
6033
+ continue;
6034
+ this.policySeen.add(f.key);
6035
+ this.append({
6036
+ ts: new Date().toISOString(),
6037
+ type: "incident.opened",
6038
+ projectId: project?.id ?? "p_unknown",
6039
+ sessionId,
6040
+ payload: { rule: "policy", action: "tampered", command: f.subject, reason: f.reason }
6041
+ });
6042
+ }
6043
+ return findings;
4157
6044
  }
4158
6045
  evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
6046
+ if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync5(cwd)) {
6047
+ const project = this.resolveProject(cwd);
6048
+ const b = this.budgetFor(project.id);
6049
+ if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
6050
+ const d = {
6051
+ action: "ask",
6052
+ rule: "budget",
6053
+ reason: `${budgetMessage(b.status, project.name)} \u2014 [budget] on_exceed = "ask": confirm each change, or raise the ceiling in .swarm.toml`
6054
+ };
6055
+ return { decision: d, display: input.command ?? input.file_path ?? tool };
6056
+ }
6057
+ }
4159
6058
  const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
4160
6059
  const cmd = tool === "Bash" ? input.command : undefined;
4161
6060
  const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
@@ -4235,7 +6134,7 @@ class Store {
4235
6134
  return this.openIncident(d, cwd, id, cmd);
4236
6135
  }
4237
6136
  openIncident(d, cwd, sessionId, command) {
4238
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
6137
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
4239
6138
  this.append({
4240
6139
  ts: new Date().toISOString(),
4241
6140
  type: "incident.opened",
@@ -4286,7 +6185,7 @@ class Store {
4286
6185
  }
4287
6186
  }
4288
6187
  const report = dryRunRules(calls, modes, {
4289
- toplevel: (cwd) => cwd && existsSync4(cwd) ? this.toplevel(cwd) : null,
6188
+ toplevel: (cwd) => cwd && existsSync5(cwd) ? this.toplevel(cwd) : null,
4290
6189
  claims: this.heldWorktrees()
4291
6190
  });
4292
6191
  return { ...report, modes };
@@ -4301,8 +6200,8 @@ class Store {
4301
6200
  loadPricing() {
4302
6201
  this.prices = { ...PRICES };
4303
6202
  for (const f of ["pricing.litellm.json", "pricing.json"]) {
4304
- const p = join6(this.home, f);
4305
- if (!existsSync4(p))
6203
+ const p = join8(this.home, f);
6204
+ if (!existsSync5(p))
4306
6205
  continue;
4307
6206
  try {
4308
6207
  const j = JSON.parse(readFileSync3(p, "utf8"));
@@ -4317,7 +6216,7 @@ class Store {
4317
6216
  throw new Error(`pricing fetch ${r.status}`);
4318
6217
  const j = await r.json();
4319
6218
  const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
4320
- writeFileSync2(join6(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
6219
+ writeFileSync2(join8(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
4321
6220
  this.loadPricing();
4322
6221
  this.reprice();
4323
6222
  }
@@ -4344,6 +6243,8 @@ class Store {
4344
6243
  name: r.name,
4345
6244
  discovered: Boolean(r.discovered),
4346
6245
  order: typeof r.sort_order === "number" ? r.sort_order : null,
6246
+ icon: r.icon ?? null,
6247
+ color: r.color ?? null,
4347
6248
  createdAt: r.created_at
4348
6249
  }));
4349
6250
  }
@@ -4358,6 +6259,8 @@ class Store {
4358
6259
  name: r.name,
4359
6260
  discovered: Boolean(r.discovered),
4360
6261
  order: typeof r.sort_order === "number" ? r.sort_order : null,
6262
+ icon: r.icon ?? null,
6263
+ color: r.color ?? null,
4361
6264
  createdAt: r.created_at
4362
6265
  };
4363
6266
  }
@@ -4395,6 +6298,8 @@ class Store {
4395
6298
  ...ident,
4396
6299
  discovered: !explicit,
4397
6300
  order: null,
6301
+ icon: null,
6302
+ color: null,
4398
6303
  createdAt: new Date().toISOString()
4399
6304
  };
4400
6305
  if (name)
@@ -4415,8 +6320,24 @@ class Store {
4415
6320
  return;
4416
6321
  if (patch.pinned !== undefined)
4417
6322
  this.db.query("UPDATE projects SET discovered = ? WHERE id = ?").run(patch.pinned ? 0 : 1, id);
4418
- if (patch.name)
4419
- this.db.query("UPDATE projects SET name = ? WHERE id = ?").run(patch.name, id);
6323
+ if (patch.name?.trim())
6324
+ this.db.query("UPDATE projects SET name = ? WHERE id = ?").run(patch.name.trim(), id);
6325
+ if (patch.icon !== undefined) {
6326
+ const icon = (patch.icon ?? "").trim();
6327
+ const isImage = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(icon);
6328
+ if (!isImage && [...icon].length > 4)
6329
+ return;
6330
+ if (isImage && icon.length > 24000)
6331
+ return;
6332
+ this.db.query("UPDATE projects SET icon = ? WHERE id = ?").run(icon || null, id);
6333
+ }
6334
+ if (patch.color !== undefined) {
6335
+ const color = (patch.color ?? "").trim();
6336
+ if (color && !/^c[1-7]$/.test(color))
6337
+ return;
6338
+ this.db.query("UPDATE projects SET color = ? WHERE id = ?").run(color || null, id);
6339
+ }
6340
+ this.touch();
4420
6341
  return this.project(id);
4421
6342
  }
4422
6343
  reorderProjects(ids) {
@@ -4435,10 +6356,29 @@ class Store {
4435
6356
  this.touch();
4436
6357
  return this.db.query("DELETE FROM projects WHERE id = ?").run(id).changes > 0;
4437
6358
  }
4438
- append(e) {
6359
+ redactions() {
6360
+ const cfg = this.policyFor(null).config.privacy;
6361
+ const key = cfg.redact.join("\x00");
6362
+ if (this.redactCache?.key !== key)
6363
+ this.redactCache = { key, res: compileRedactions(cfg.redact) };
6364
+ return this.redactCache.res;
6365
+ }
6366
+ redactCache = null;
6367
+ append(e0) {
6368
+ const privacy = this.policyFor(null).config.privacy;
6369
+ let e = e0;
6370
+ if (!privacy.store_prompts && e.type === "prompt.submitted" && e.payload && typeof e.payload === "object") {
6371
+ const { prompt: _p, ...rest } = e.payload;
6372
+ e = { ...e, payload: { ...rest, prompt: "[not stored]" } };
6373
+ }
6374
+ const res = this.redactions();
6375
+ if (res.length)
6376
+ e = { ...e, payload: redactValue(e.payload, res), raw: redactValue(e.raw, res) };
4439
6377
  const slim = slimForStorage(e);
4440
- const r = this.db.query("INSERT INTO events (ts, type, project_id, session_id, payload, raw) VALUES (?, ?, ?, ?, ?, ?)").run(e.ts, e.type, e.projectId, e.sessionId, JSON.stringify(slim.payload ?? null), slim.raw === undefined ? null : JSON.stringify(slim.raw));
4441
- const stored = { ...e, seq: Number(r.lastInsertRowid) };
6378
+ const p = e.payload ?? {};
6379
+ const actor2 = e.actor ?? this.actorFor(typeof p.owner === "string" ? p.owner : typeof p.by === "string" ? p.by : null, e.sessionId);
6380
+ const r = this.db.query("INSERT INTO events (ts, type, project_id, session_id, payload, raw, actor_kind, actor_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(e.ts, e.type, e.projectId, e.sessionId, JSON.stringify(slim.payload ?? null), slim.raw === undefined ? null : JSON.stringify(slim.raw), actor2.kind, actor2.id);
6381
+ const stored = { ...e, actor: actor2, seq: Number(r.lastInsertRowid) };
4442
6382
  if (stored.type === "incident.opened")
4443
6383
  this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
4444
6384
  this.projectSession(stored);
@@ -4448,9 +6388,34 @@ class Store {
4448
6388
  l(wire);
4449
6389
  return stored;
4450
6390
  }
4451
- prune(days = 30) {
4452
- const cutoff = new Date(Date.now() - days * 86400000).toISOString();
4453
- const n = this.db.query("DELETE FROM events WHERE ts < ? AND type != 'incident.opened'").run(cutoff).changes;
6391
+ audit(opts = {}) {
6392
+ const where = [`type IN (${AUDIT_TYPES_SQL})`];
6393
+ const args = [];
6394
+ if (opts.since) {
6395
+ where.push("ts >= ?");
6396
+ args.push(opts.since);
6397
+ }
6398
+ if (opts.projectId) {
6399
+ where.push("project_id = ?");
6400
+ args.push(opts.projectId);
6401
+ }
6402
+ if (opts.type && isAuditType(opts.type)) {
6403
+ where.push("type = ?");
6404
+ args.push(opts.type);
6405
+ }
6406
+ const limit = Math.min(Math.max(opts.limit ?? 1e4, 1), 1e5);
6407
+ const rows = this.db.query(`SELECT * FROM (SELECT ${WIRE_COLS} FROM events WHERE ${where.join(" AND ")} ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(...args, limit);
6408
+ return rows.map((r) => auditRow(wireRowToEvent(r)));
6409
+ }
6410
+ prune(days2) {
6411
+ const cfg = this.policyFor(null).config;
6412
+ const chatter = days2 ?? cfg.events.retain_days;
6413
+ const cutoff = new Date(Date.now() - chatter * 86400000).toISOString();
6414
+ let n = this.db.query(`DELETE FROM events WHERE ts < ? AND type NOT IN (${AUDIT_TYPES_SQL})`).run(cutoff).changes;
6415
+ if (cfg.audit.retain_days > 0) {
6416
+ const acut = new Date(Date.now() - cfg.audit.retain_days * 86400000).toISOString();
6417
+ n += this.db.query(`DELETE FROM events WHERE ts < ? AND type IN (${AUDIT_TYPES_SQL}) AND type != 'incident.opened'`).run(acut).changes;
6418
+ }
4454
6419
  const old = new Date(Date.now() - 7 * 86400000).toISOString();
4455
6420
  this.db.query("UPDATE events SET raw = NULL WHERE ts < ? AND raw IS NOT NULL").run(old);
4456
6421
  if (n > 0)
@@ -4461,11 +6426,13 @@ class Store {
4461
6426
  if (typeof raw2.cwd === "string")
4462
6427
  this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
4463
6428
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
4464
- const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
6429
+ const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
4465
6430
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
4466
6431
  if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
4467
- if (existsSync4(cwd))
6432
+ if (existsSync5(cwd)) {
4468
6433
  this.autoHandoff(e.sessionId, cwd);
6434
+ this.autoGate(event, e.sessionId, cwd);
6435
+ }
4469
6436
  this.rememberSession(e.sessionId);
4470
6437
  }
4471
6438
  if (e.sessionId && typeof raw2.transcript_path === "string") {
@@ -4488,6 +6455,15 @@ class Store {
4488
6455
  "claim.renewed",
4489
6456
  "claim.released",
4490
6457
  "claim.orphaned",
6458
+ "worktree.bootstrapped",
6459
+ "worktree.created",
6460
+ "worktree.removed",
6461
+ "pr.opened",
6462
+ "question.asked",
6463
+ "question.answered",
6464
+ "dispatch.queued",
6465
+ "dispatch.started",
6466
+ "dispatch.finished",
4491
6467
  "gate.recorded",
4492
6468
  "handoff.recorded",
4493
6469
  "incident.opened",
@@ -4499,7 +6475,7 @@ class Store {
4499
6475
  return;
4500
6476
  const p = e.payload;
4501
6477
  const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
4502
- const branch = p.cwd && existsSync4(p.cwd) ? currentBranch(p.cwd) : null;
6478
+ const branch = p.cwd && existsSync5(p.cwd) ? currentBranch(p.cwd) : null;
4503
6479
  if (!row) {
4504
6480
  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);
4505
6481
  }
@@ -4519,7 +6495,7 @@ class Store {
4519
6495
  const size = statSync(path).size;
4520
6496
  if (size <= offset)
4521
6497
  return null;
4522
- const fd = openSync2(path, "r");
6498
+ const fd = openSync3(path, "r");
4523
6499
  const buf = Buffer.alloc(size - offset);
4524
6500
  readSync(fd, buf, 0, buf.length, offset);
4525
6501
  closeSync(fd);
@@ -4537,13 +6513,15 @@ class Store {
4537
6513
  }
4538
6514
  }
4539
6515
  persistTurns(sessionId, agentId, turns) {
6516
+ const privacy = this.policyFor(null).config.privacy;
6517
+ const res = this.redactions();
4540
6518
  const up = this.db.query(`INSERT INTO turns (id, session_id, agent_id, ts, model, effort, sidechain, input, output, cache_write, cache_write_1h, cache_read, thinking, cost_usd, text, tools)
4541
6519
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4542
6520
  ON CONFLICT(id) DO UPDATE SET input=excluded.input, output=excluded.output, cache_write=excluded.cache_write, cache_write_1h=excluded.cache_write_1h,
4543
6521
  cache_read=excluded.cache_read, thinking=excluded.thinking, cost_usd=excluded.cost_usd, text=CASE WHEN excluded.text != '' THEN excluded.text ELSE turns.text END, tools=excluded.tools`);
4544
6522
  const tx = this.db.transaction((ts) => {
4545
6523
  for (const t of ts) {
4546
- up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, costUsd(t.model, t.usage, this.prices), t.text, JSON.stringify(t.tools));
6524
+ up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, costUsd(t.model, t.usage, this.prices), privacy.store_reasoning ? redactValue(t.text, res) : "", JSON.stringify(t.tools));
4547
6525
  }
4548
6526
  });
4549
6527
  if (turns.length)
@@ -4569,12 +6547,12 @@ class Store {
4569
6547
  }
4570
6548
  tailSession(sessionId) {
4571
6549
  const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
4572
- if (!s?.transcript_path || !existsSync4(s.transcript_path))
6550
+ if (!s?.transcript_path || !existsSync5(s.transcript_path))
4573
6551
  return 0;
4574
6552
  let n = this.tailFile(s.transcript_path, sessionId, null);
4575
- const subDir = join6(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
6553
+ const subDir = join8(dirname2(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
4576
6554
  for (const f of this.subagentFiles(subDir)) {
4577
- n += this.tailFile(join6(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
6555
+ n += this.tailFile(join8(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
4578
6556
  }
4579
6557
  return n;
4580
6558
  }
@@ -4606,7 +6584,7 @@ class Store {
4606
6584
  return n;
4607
6585
  }
4608
6586
  codexRoot() {
4609
- return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
6587
+ return process.env.SWARM_CODEX_DIR ?? join8(homedir3(), ".codex", "sessions");
4610
6588
  }
4611
6589
  codexRolloutFiles(sinceMs) {
4612
6590
  const root = this.codexRoot();
@@ -4621,18 +6599,18 @@ class Store {
4621
6599
  for (const y of ls(root)) {
4622
6600
  if (!/^\d{4}$/.test(y))
4623
6601
  continue;
4624
- for (const m of ls(join6(root, y))) {
6602
+ for (const m of ls(join8(root, y))) {
4625
6603
  if (!/^\d\d$/.test(m))
4626
6604
  continue;
4627
- for (const day of ls(join6(root, y, m))) {
6605
+ for (const day of ls(join8(root, y, m))) {
4628
6606
  if (!/^\d\d$/.test(day))
4629
6607
  continue;
4630
6608
  if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
4631
6609
  continue;
4632
- const dir = join6(root, y, m, day);
6610
+ const dir = join8(root, y, m, day);
4633
6611
  for (const f of ls(dir)) {
4634
6612
  if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
4635
- out.push(join6(dir, f));
6613
+ out.push(join8(dir, f));
4636
6614
  }
4637
6615
  }
4638
6616
  }
@@ -4640,7 +6618,7 @@ class Store {
4640
6618
  return out;
4641
6619
  }
4642
6620
  tailCodex(windowMs = 3 * 24 * 60 * 60000) {
4643
- if (!existsSync4(this.codexRoot()))
6621
+ if (!existsSync5(this.codexRoot()))
4644
6622
  return 0;
4645
6623
  let n = 0;
4646
6624
  for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
@@ -4649,12 +6627,12 @@ class Store {
4649
6627
  return n;
4650
6628
  }
4651
6629
  grokRoot() {
4652
- return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
6630
+ return process.env.SWARM_GROK_DIR ?? join8(homedir3(), ".grok", "sessions");
4653
6631
  }
4654
6632
  grokSummary = new Map;
4655
6633
  tailGrok(windowMs = 3 * 24 * 60 * 60000) {
4656
6634
  const root = this.grokRoot();
4657
- if (!existsSync4(root))
6635
+ if (!existsSync5(root))
4658
6636
  return 0;
4659
6637
  const since = Date.now() - windowMs;
4660
6638
  const ls = (p) => {
@@ -4674,10 +6652,10 @@ class Store {
4674
6652
  } catch {
4675
6653
  cwd = enc;
4676
6654
  }
4677
- const cwdDir = join6(root, enc);
6655
+ const cwdDir = join8(root, enc);
4678
6656
  for (const sid of ls(cwdDir)) {
4679
- const path = join6(cwdDir, sid, "updates.jsonl");
4680
- if (!existsSync4(path))
6657
+ const path = join8(cwdDir, sid, "updates.jsonl");
6658
+ if (!existsSync5(path))
4681
6659
  continue;
4682
6660
  try {
4683
6661
  if (statSync(path).mtimeMs < since)
@@ -4685,7 +6663,7 @@ class Store {
4685
6663
  } catch {
4686
6664
  continue;
4687
6665
  }
4688
- const sumPath = join6(cwdDir, sid, "summary.json");
6666
+ const sumPath = join8(cwdDir, sid, "summary.json");
4689
6667
  let title;
4690
6668
  let fresh = false;
4691
6669
  try {
@@ -4736,9 +6714,9 @@ class Store {
4736
6714
  ensureAgentSession(sid, agent, cwd, mtime) {
4737
6715
  if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
4738
6716
  return;
4739
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
6717
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
4740
6718
  const ts = new Date(mtime).toISOString();
4741
- 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);
6719
+ 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);
4742
6720
  }
4743
6721
  claimRows(projectId) {
4744
6722
  return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
@@ -4772,9 +6750,9 @@ class Store {
4772
6750
  worktreePath(projectId, task) {
4773
6751
  const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
4774
6752
  const p = this.project(projectId);
4775
- return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
6753
+ return join8(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
4776
6754
  }
4777
- claim(projectId, task, owner, baseRef = "HEAD") {
6755
+ claim(projectId, task, owner, baseRef = "HEAD", sessionId = null) {
4778
6756
  const p = this.project(projectId);
4779
6757
  if (!p)
4780
6758
  return { ok: false, error: "unknown project" };
@@ -4783,28 +6761,77 @@ class Store {
4783
6761
  if (!decision.ok)
4784
6762
  return { ok: false, error: claimRefusalMessage(decision, task) };
4785
6763
  const branch = `task/${task}`;
4786
- const worktree = this.worktreePath(projectId, task);
4787
- if (existsSync4(worktree))
4788
- return { ok: false, error: `${worktree} already exists; release ${task} first` };
4789
- mkdirSync3(dirname(worktree), { recursive: true });
4790
- const created = worktreeAdd(p.root, worktree, branch, baseRef);
6764
+ const worktree2 = this.worktreePath(projectId, task);
6765
+ if (existsSync5(worktree2))
6766
+ return { ok: false, error: `${worktree2} already exists; release ${task} first` };
6767
+ mkdirSync4(dirname2(worktree2), { recursive: true });
6768
+ const created = worktreeAdd(p.root, worktree2, branch, baseRef);
4791
6769
  if (!created)
4792
6770
  return { ok: false, error: `git worktree add failed for ${task}` };
4793
6771
  this.invalidateWorktrees(projectId);
4794
6772
  const expiresAt = nextExpiry(now);
4795
6773
  const acquiredAt = new Date(now).toISOString();
4796
- this.db.query(`INSERT INTO claims (project_id, task, owner, worktree, branch, acquired_at, expires_at, released_at, state)
4797
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'held')
6774
+ this.db.query(`INSERT INTO claims (project_id, task, owner, worktree, branch, acquired_at, expires_at, released_at, state, actor_kind, actor_id)
6775
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'held', ?, ?)
4798
6776
  ON CONFLICT(project_id, task) DO UPDATE SET owner=excluded.owner, worktree=excluded.worktree, branch=excluded.branch,
4799
- acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released_at=NULL, state='held'`).run(projectId, task, owner, created, branch, acquiredAt, expiresAt);
6777
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released_at=NULL, state='held',
6778
+ actor_kind=excluded.actor_kind, actor_id=excluded.actor_id`).run(projectId, task, owner, created, branch, acquiredAt, expiresAt, ...actorCols(this.actorFor(owner, sessionId)));
4800
6779
  this.append({
4801
6780
  ts: acquiredAt,
4802
6781
  type: "claim.acquired",
4803
6782
  projectId,
4804
- sessionId: null,
6783
+ sessionId,
6784
+ actor: this.actorFor(owner, sessionId),
4805
6785
  payload: { task, owner, worktree: created, branch, summary: `claim ${task} by ${owner}` }
4806
6786
  });
4807
- return { ok: true, task, owner, worktree: created, branch, expiresAt };
6787
+ const bootstrap = this.bootstrapWorktree(projectId, task, p.root, created);
6788
+ return { ok: true, task, owner, worktree: created, branch, expiresAt, bootstrap };
6789
+ }
6790
+ bootstraps = new Map;
6791
+ bootstrapWorktree(projectId, task, repoRoot, worktree2) {
6792
+ const plan = planBootstrap(loadConfig({ repoRoot, home: this.home }), repoRoot, worktree2);
6793
+ if (!needsBootstrap(plan))
6794
+ return null;
6795
+ const job = runBootstrap(plan, { worktree: worktree2, home: this.home, projectId, task });
6796
+ const done = job.done.then((o) => {
6797
+ this.bootstraps.delete(worktree2);
6798
+ const ts = new Date().toISOString();
6799
+ const ok = !o.setup || o.setup.exitCode === 0;
6800
+ this.append({
6801
+ ts,
6802
+ type: "worktree.bootstrapped",
6803
+ projectId,
6804
+ sessionId: null,
6805
+ payload: {
6806
+ task,
6807
+ worktree: worktree2,
6808
+ ok,
6809
+ log: job.log,
6810
+ ...o,
6811
+ summary: `bootstrap ${task}: ${summarizeBootstrap(o)}`
6812
+ }
6813
+ });
6814
+ if (!ok)
6815
+ this.append({
6816
+ ts,
6817
+ type: "incident.opened",
6818
+ projectId,
6819
+ sessionId: null,
6820
+ payload: {
6821
+ rule: "bootstrap_failed",
6822
+ action: "failed",
6823
+ command: o.setup?.command ?? "",
6824
+ reason: `worktree setup for ${task} exited ${o.setup?.exitCode} \u2014 see ${job.log}`
6825
+ }
6826
+ });
6827
+ this.touch();
6828
+ return o;
6829
+ });
6830
+ this.bootstraps.set(worktree2, done);
6831
+ return job.log;
6832
+ }
6833
+ awaitBootstrap(worktree2) {
6834
+ return this.bootstraps.get(worktree2) ?? Promise.resolve();
4808
6835
  }
4809
6836
  autoRenewAt = new Map;
4810
6837
  autoRenewFor(sessionId, cwd) {
@@ -4847,7 +6874,7 @@ class Store {
4847
6874
  for (const c of this.claimRows(p.id)) {
4848
6875
  if (c.state !== "held" || isActive(c, now))
4849
6876
  continue;
4850
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6877
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4851
6878
  const work = exists ? heldWork(c.worktree) : null;
4852
6879
  if (reapAction(c, now, exists, work) !== "keep-orphaned")
4853
6880
  continue;
@@ -4902,18 +6929,18 @@ class Store {
4902
6929
  const row = this.db.query("SELECT * FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
4903
6930
  if (!row)
4904
6931
  return { ok: false, error: `no claim on ${task}` };
4905
- const worktree = row.worktree ?? "";
4906
- if (worktree && existsSync4(worktree)) {
4907
- const work = heldWork(worktree);
6932
+ const worktree2 = row.worktree ?? "";
6933
+ if (worktree2 && existsSync5(worktree2)) {
6934
+ const work = heldWork(worktree2);
4908
6935
  const can = canRelease(work, force);
4909
6936
  if (!can.ok)
4910
6937
  return {
4911
6938
  ok: false,
4912
- error: releaseRefusalMessage(can, worktree),
6939
+ error: releaseRefusalMessage(can, worktree2),
4913
6940
  refused: can.reason
4914
6941
  };
4915
- if (p && !worktreeRemove(p.root, worktree, force))
4916
- return { ok: false, error: `git worktree remove failed for ${worktree}` };
6942
+ if (p && !worktreeRemove(p.root, worktree2, force))
6943
+ return { ok: false, error: `git worktree remove failed for ${worktree2}` };
4917
6944
  this.invalidateWorktrees(projectId);
4918
6945
  }
4919
6946
  const releasedAt = new Date().toISOString();
@@ -4938,7 +6965,7 @@ class Store {
4938
6965
  continue;
4939
6966
  if (isActive({ ...c, state: "held" }, now))
4940
6967
  continue;
4941
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6968
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4942
6969
  const work = exists ? heldWork(c.worktree) : null;
4943
6970
  const action = reapAction({ ...c, state: "held" }, now, exists, work);
4944
6971
  if (action === "not-expired")
@@ -5029,6 +7056,141 @@ class Store {
5029
7056
  this.wtInflight.set(projectId, run2);
5030
7057
  return run2;
5031
7058
  }
7059
+ createWorktree(projectId, name, baseRef = "HEAD", branch) {
7060
+ const p = this.project(projectId);
7061
+ if (!p)
7062
+ return { ok: false, error: "unknown project" };
7063
+ const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
7064
+ if (!slug || slug === "." || slug === "..")
7065
+ return { ok: false, error: "bad worktree name" };
7066
+ const path = this.worktreePath(projectId, slug);
7067
+ if (existsSync5(path))
7068
+ return { ok: false, error: `${path} already exists` };
7069
+ mkdirSync4(dirname2(path), { recursive: true });
7070
+ const br = branch?.trim() || `wt/${slug}`;
7071
+ const created = worktreeAdd(p.root, path, br, baseRef);
7072
+ if (!created)
7073
+ return { ok: false, error: `git worktree add failed for ${name}` };
7074
+ this.invalidateWorktrees(projectId);
7075
+ this.append({
7076
+ ts: new Date().toISOString(),
7077
+ type: "worktree.created",
7078
+ projectId,
7079
+ sessionId: null,
7080
+ payload: { name: slug, worktree: created, branch: br, summary: `worktree ${slug} created` }
7081
+ });
7082
+ const bootstrap = this.bootstrapWorktree(projectId, slug, p.root, created);
7083
+ return { ok: true, name: slug, worktree: created, branch: br, bootstrap };
7084
+ }
7085
+ findWorktree(projectId, ref) {
7086
+ const wts = this.wtCache.get(projectId)?.v ?? [];
7087
+ const abs = ref.startsWith("/") ? ref.replace(/\/+$/, "") : null;
7088
+ return wts.find((w) => w.path === abs) ?? wts.find((w) => !w.main && basename(w.path) === ref) ?? wts.find((w) => w.branch === ref) ?? null;
7089
+ }
7090
+ async removeWorktree(projectId, ref, force = false) {
7091
+ const p = this.project(projectId);
7092
+ if (!p)
7093
+ return { ok: false, error: "unknown project" };
7094
+ await this.refreshWorktrees(projectId);
7095
+ const w = this.findWorktree(projectId, ref);
7096
+ if (!w)
7097
+ return { ok: false, error: `no worktree ${ref} in ${p.name}` };
7098
+ const held = this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path);
7099
+ const can = canRemoveWorktree(w, held?.task ?? null, force);
7100
+ if (!can.ok)
7101
+ return {
7102
+ ok: false,
7103
+ error: removeRefusalMessage(can.reason, w.path, held?.task),
7104
+ refused: can.reason
7105
+ };
7106
+ if (!worktreeRemove(p.root, w.path, force))
7107
+ return { ok: false, error: `git worktree remove failed for ${w.path}` };
7108
+ this.invalidateWorktrees(projectId);
7109
+ this.append({
7110
+ ts: new Date().toISOString(),
7111
+ type: "worktree.removed",
7112
+ projectId,
7113
+ sessionId: null,
7114
+ payload: {
7115
+ worktree: w.path,
7116
+ branch: w.branch,
7117
+ force,
7118
+ summary: `worktree ${basename(w.path)} removed`
7119
+ }
7120
+ });
7121
+ return { ok: true, worktree: w.path };
7122
+ }
7123
+ async gcWorktrees(projectId, apply = false) {
7124
+ await this.refreshWorktrees(projectId);
7125
+ const plan = planGc(this.wtCache.get(projectId)?.v ?? [], this.claims(projectId));
7126
+ const removed = [];
7127
+ if (apply)
7128
+ for (const c of plan) {
7129
+ if (!c.removable)
7130
+ continue;
7131
+ const r = await this.removeWorktree(projectId, c.path, false);
7132
+ if (r.ok)
7133
+ removed.push(c.path);
7134
+ }
7135
+ return { candidates: plan, removed };
7136
+ }
7137
+ openWorktree(projectId, ref) {
7138
+ const p = this.project(projectId);
7139
+ if (!p)
7140
+ return { ok: false, error: "unknown project" };
7141
+ const w = this.findWorktree(projectId, ref);
7142
+ if (!w)
7143
+ return { ok: false, error: `no worktree ${ref}` };
7144
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).worktree.open;
7145
+ const cmd = cfg ? ["sh", "-c", cfg.replace(/\{path\}/g, `'${w.path.replace(/'/g, "'\\''")}'`)] : [
7146
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open",
7147
+ w.path
7148
+ ];
7149
+ try {
7150
+ Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
7151
+ return { ok: true, worktree: w.path, command: cmd.join(" ") };
7152
+ } catch (e) {
7153
+ return { ok: false, error: e.message };
7154
+ }
7155
+ }
7156
+ async prDraftFor(projectId, ref) {
7157
+ const p = this.project(projectId);
7158
+ if (!p)
7159
+ return { ok: false, error: "unknown project" };
7160
+ await this.refreshWorktrees(projectId);
7161
+ const claim = this.claims(projectId).find((c) => c.task === ref && c.state === "held");
7162
+ const w = this.findWorktree(projectId, claim?.worktree ?? ref);
7163
+ if (!w)
7164
+ return { ok: false, error: `no worktree or held task ${ref}` };
7165
+ 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);
7166
+ const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
7167
+ const handoff = this.latestHandoff(projectId, task);
7168
+ const required = this.requiredGates(projectId);
7169
+ const gates2 = required.length ? this.gateStatusFor(this.gateRuns(projectId, task), required).map((g) => ({
7170
+ gate: g.gate,
7171
+ verdict: g.verdict
7172
+ })) : [];
7173
+ const diff = await worktreeDiff(p.root, w.path);
7174
+ const d = prDraft({
7175
+ task,
7176
+ title: taskRow?.title ?? null,
7177
+ handoff,
7178
+ gates: gates2,
7179
+ files: diff.files,
7180
+ commits: diff.commits
7181
+ });
7182
+ return { ok: true, task, worktree: w, ...d, diff };
7183
+ }
7184
+ recordPrOpened(projectId, task, worktree2, url) {
7185
+ this.append({
7186
+ ts: new Date().toISOString(),
7187
+ type: "pr.opened",
7188
+ projectId,
7189
+ sessionId: null,
7190
+ payload: { task, worktree: worktree2, url, summary: `PR opened for ${task}: ${url}` }
7191
+ });
7192
+ this.touch();
7193
+ }
5032
7194
  invalidateWorktrees(projectId) {
5033
7195
  if (projectId)
5034
7196
  this.wtCache.delete(projectId);
@@ -5098,6 +7260,56 @@ class Store {
5098
7260
  };
5099
7261
  });
5100
7262
  }
7263
+ projectSpend(projectId) {
7264
+ const dayStart = new Date;
7265
+ dayStart.setHours(0, 0, 0, 0);
7266
+ const weekStart = new Date(Date.now() - 7 * 86400000).toISOString();
7267
+ 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;
7268
+ return { today: q(dayStart.toISOString()), week: q(weekStart) };
7269
+ }
7270
+ budgetFor(projectId) {
7271
+ const cfg = this.config(projectId).budget;
7272
+ if (!cfg.daily && !cfg.weekly)
7273
+ return null;
7274
+ return { status: budgetStatus(this.projectSpend(projectId), cfg), config: cfg };
7275
+ }
7276
+ budgetNotified = new Map;
7277
+ budgetListeners = new Set;
7278
+ onBudgetStop(fn) {
7279
+ this.budgetListeners.add(fn);
7280
+ }
7281
+ checkBudgets() {
7282
+ const day = new Date().toDateString();
7283
+ const out = [];
7284
+ for (const p of this.projects()) {
7285
+ const b = this.budgetFor(p.id);
7286
+ if (!b || b.status.level === "ok")
7287
+ continue;
7288
+ out.push({ projectId: p.id, status: b.status });
7289
+ const key = `${day}:${b.status.level}`;
7290
+ if (this.budgetNotified.get(p.id) === key)
7291
+ continue;
7292
+ this.budgetNotified.set(p.id, key);
7293
+ const msg = budgetMessage(b.status, p.name);
7294
+ this.append({
7295
+ ts: new Date().toISOString(),
7296
+ type: "incident.opened",
7297
+ projectId: p.id,
7298
+ sessionId: null,
7299
+ payload: {
7300
+ rule: "budget",
7301
+ action: b.status.level === "exceeded" ? b.config.on_exceed : "warn",
7302
+ command: `${b.status.kind} budget`,
7303
+ 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`
7304
+ }
7305
+ });
7306
+ if (b.status.level === "exceeded" && b.config.on_exceed === "stop")
7307
+ for (const fn of this.budgetListeners)
7308
+ fn(p.id, b.status);
7309
+ this.touch();
7310
+ }
7311
+ return out;
7312
+ }
5101
7313
  spend() {
5102
7314
  const dayStart = new Date;
5103
7315
  dayStart.setHours(0, 0, 0, 0);
@@ -5245,19 +7457,19 @@ class Store {
5245
7457
  WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).get(...projectId ? [projectId] : []);
5246
7458
  return r.n;
5247
7459
  }
5248
- ackIncident(seq) {
7460
+ ackIncident(seq, by) {
5249
7461
  const row = this.db.query("SELECT seq FROM events WHERE seq = ? AND type = 'incident.opened'").get(seq);
5250
7462
  if (!row)
5251
7463
  return false;
5252
- this.db.query("INSERT OR IGNORE INTO incident_acks (seq, acked_at) VALUES (?, ?)").run(seq, new Date().toISOString());
7464
+ this.db.query("INSERT OR IGNORE INTO incident_acks (seq, acked_at, actor_kind, actor_id) VALUES (?, ?, ?, ?)").run(seq, new Date().toISOString(), ...actorCols(this.actorFor(by ?? "dashboard")));
5253
7465
  this.touch();
5254
7466
  return true;
5255
7467
  }
5256
- ackAllIncidents(projectId) {
7468
+ ackAllIncidents(projectId, by) {
5257
7469
  const at = new Date().toISOString();
5258
- const r = this.db.query(`INSERT OR IGNORE INTO incident_acks (seq, acked_at)
5259
- SELECT e.seq, ? FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
5260
- WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).run(at, ...projectId ? [projectId] : []);
7470
+ const r = this.db.query(`INSERT OR IGNORE INTO incident_acks (seq, acked_at, actor_kind, actor_id)
7471
+ SELECT e.seq, ?, ?, ? FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
7472
+ WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).run(at, ...actorCols(this.actorFor(by ?? "dashboard")), ...projectId ? [projectId] : []);
5261
7473
  this.touch();
5262
7474
  return Number(r.changes);
5263
7475
  }
@@ -5424,8 +7636,8 @@ class Store {
5424
7636
  endedAt: null
5425
7637
  };
5426
7638
  this.db.query("UPDATE processes SET ended_at = ? WHERE ended_at IS NULL AND project_id = ? AND name = ?").run(p.startedAt, p.projectId, p.name);
5427
- this.db.query(`INSERT INTO processes (pid, start_time, project_id, session_id, kind, name, port, cwd, cmd, owner, log, started_at, ended_at)
5428
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`).run(p.pid, p.startTime, p.projectId, p.sessionId, p.kind, p.name, p.port, p.cwd, p.cmd, p.owner, p.log, p.startedAt);
7639
+ this.db.query(`INSERT INTO processes (pid, start_time, project_id, session_id, kind, name, port, cwd, cmd, owner, log, started_at, ended_at, actor_kind, actor_id)
7640
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(p.pid, p.startTime, p.projectId, p.sessionId, p.kind, p.name, p.port, p.cwd, p.cmd, p.owner, p.log, p.startedAt, ...actorCols(this.actorFor(p.owner, p.sessionId)));
5429
7641
  this.append({
5430
7642
  ts: p.startedAt,
5431
7643
  type: "process.started",
@@ -5489,11 +7701,12 @@ class Store {
5489
7701
  expiresAt,
5490
7702
  released: false
5491
7703
  };
5492
- this.db.query(`INSERT INTO resources (name, project_id, kind, owner, session_id, pid, port, acquired_at, expires_at, released)
5493
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
7704
+ this.db.query(`INSERT INTO resources (name, project_id, kind, owner, session_id, pid, port, acquired_at, expires_at, released, actor_kind, actor_id)
7705
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
5494
7706
  ON CONFLICT(name, project_id) DO UPDATE SET
5495
7707
  kind=excluded.kind, owner=excluded.owner, session_id=excluded.session_id, pid=excluded.pid,
5496
- port=excluded.port, acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released=0`).run(resource.name, key, resource.kind, resource.owner, resource.sessionId, resource.pid, resource.port, resource.acquiredAt, resource.expiresAt);
7708
+ port=excluded.port, acquired_at=excluded.acquired_at, expires_at=excluded.expires_at, released=0,
7709
+ actor_kind=excluded.actor_kind, actor_id=excluded.actor_id`).run(resource.name, key, resource.kind, resource.owner, resource.sessionId, resource.pid, resource.port, resource.acquiredAt, resource.expiresAt, ...actorCols(this.actorFor(resource.owner, resource.sessionId)));
5497
7710
  this.append({
5498
7711
  ts: resource.acquiredAt,
5499
7712
  type: "resource.acquired",
@@ -5540,7 +7753,7 @@ class Store {
5540
7753
  }
5541
7754
  snapshot() {
5542
7755
  const worktrees = {};
5543
- const projects = this.projects();
7756
+ const projects = this.projects().filter((p) => !(p.discovered && isScratchRoot(p.root)));
5544
7757
  for (const p of projects)
5545
7758
  worktrees[p.id] = this.worktrees(p.id);
5546
7759
  return {
@@ -5552,12 +7765,13 @@ class Store {
5552
7765
  processes: this.memoised("processes", 5000, () => this.processes()),
5553
7766
  incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
5554
7767
  openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
7768
+ questions: this.questions({ open: true, limit: 50 }),
5555
7769
  resources: this.resources(),
5556
7770
  seq: this.seq()
5557
7771
  };
5558
7772
  }
5559
7773
  }
5560
- var WIRE_COLS = "seq, ts, type, project_id, session_id, json_remove(payload, '$.toolInput', '$.toolResponse', '$.prompt') AS payload";
7774
+ var WIRE_COLS = "seq, ts, type, project_id, session_id, actor_kind, actor_id, json_remove(payload, '$.toolInput', '$.toolResponse', '$.prompt') AS payload";
5561
7775
  var RAW_TOOL_KEYS = ["tool_input", "tool_response", "toolInput", "toolResponse", "toolResult"];
5562
7776
  var TOOL_INPUT_MAX = 2048;
5563
7777
  var TOOL_RESPONSE_MAX = 4096;
@@ -5603,7 +7817,7 @@ function toWire(e) {
5603
7817
  }
5604
7818
  function wireRowToEvent(r) {
5605
7819
  const p = JSON.parse(r.payload ?? "null");
5606
- return {
7820
+ const e = {
5607
7821
  seq: r.seq,
5608
7822
  ts: r.ts,
5609
7823
  type: r.type,
@@ -5611,6 +7825,10 @@ function wireRowToEvent(r) {
5611
7825
  sessionId: r.session_id ?? null,
5612
7826
  payload: p
5613
7827
  };
7828
+ const a = actorFromColumns(r.actor_kind, r.actor_id, r.session_id);
7829
+ if (a)
7830
+ e.actor = a;
7831
+ return e;
5614
7832
  }
5615
7833
  function rowToEvent(r) {
5616
7834
  const e = {
@@ -5623,17 +7841,32 @@ function rowToEvent(r) {
5623
7841
  };
5624
7842
  if (r.raw)
5625
7843
  e.raw = JSON.parse(r.raw);
7844
+ const a = actorFromColumns(r.actor_kind, r.actor_id, r.session_id);
7845
+ if (a)
7846
+ e.actor = a;
5626
7847
  return e;
5627
7848
  }
7849
+ function osUser() {
7850
+ try {
7851
+ return userInfo().username || process.env.USER || "me";
7852
+ } catch {
7853
+ return process.env.USER || "me";
7854
+ }
7855
+ }
7856
+ var actorCols = (a) => [a.kind, a.id];
7857
+ function isScratchRoot(root) {
7858
+ const tmp = [tmpdir(), "/tmp", "/private/tmp", "/private/var/folders", "/var/folders"];
7859
+ return tmp.some((t) => root === t || root.startsWith(`${t}/`));
7860
+ }
5628
7861
 
5629
7862
  // packages/daemon/src/app.ts
5630
- var VERSION = "0.6.0";
7863
+ var VERSION = "0.8.0";
5631
7864
  var WEB_DIR = (() => {
5632
7865
  if (process.env.SWARM_WEB_DIR)
5633
7866
  return process.env.SWARM_WEB_DIR;
5634
- const here = dirname2(fileURLToPath(import.meta.url));
5635
- const dev = join7(here, "../../web/public");
5636
- return existsSync5(join7(dev, "index.html")) ? dev : join7(here, "../web");
7867
+ const here = dirname3(fileURLToPath(import.meta.url));
7868
+ const dev = join9(here, "../../web/public");
7869
+ return existsSync6(join9(dev, "index.html")) ? dev : join9(here, "../web");
5637
7870
  })();
5638
7871
  var REPLAY_TAIL = 200;
5639
7872
  var wireCache = new WeakMap;
@@ -5645,11 +7878,41 @@ function wireJson(e) {
5645
7878
  }
5646
7879
  return s;
5647
7880
  }
7881
+ function hookRepoRoot(store, raw2) {
7882
+ const cwd = typeof raw2.cwd === "string" ? raw2.cwd : "";
7883
+ return cwd && existsSync6(cwd) ? store.resolveProject(cwd)?.root ?? null : null;
7884
+ }
5648
7885
  function createApp(store = new Store) {
5649
7886
  const app = new Hono2;
5650
7887
  const forge2 = new ForgeService(store);
5651
7888
  const runner = new Runner(store, store.home);
5652
- app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
7889
+ const dispatcher = new Dispatcher(store, runner, forge2);
7890
+ store.onBudgetStop((projectId) => {
7891
+ dispatcher.clear(projectId);
7892
+ for (const run2 of runner.list(projectId))
7893
+ runner.stop(run2.id);
7894
+ });
7895
+ app.use("/v1/*", async (c, next) => {
7896
+ if (c.req.path === "/v1/health")
7897
+ return next();
7898
+ const token = readToken(store.home);
7899
+ const given = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? c.req.query("token");
7900
+ if (token && given && given === token)
7901
+ return next();
7902
+ if (given)
7903
+ return c.json({ error: "unauthorized: wrong daemon token" }, 401);
7904
+ const ip = c.env?.requestIP?.(c.req.raw)?.address;
7905
+ const loopback = !ip || ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
7906
+ if (loopback && store.policyFor(null).config.daemon.auth !== "required")
7907
+ return next();
7908
+ return c.json({ error: "unauthorized: send the daemon token (~/.swarm/token) as Authorization: Bearer" }, 401);
7909
+ });
7910
+ app.get("/v1/health", (c) => c.json({
7911
+ ok: true,
7912
+ version: VERSION,
7913
+ schema: store.schemaVersion(),
7914
+ auth: store.policyFor(null).config.daemon.auth
7915
+ }));
5653
7916
  app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
5654
7917
  app.post("/v1/projects", async (c) => {
5655
7918
  const { path, name } = await c.req.json();
@@ -5668,22 +7931,24 @@ function createApp(store = new Store) {
5668
7931
  return c.json(store.reorderProjects(ids));
5669
7932
  });
5670
7933
  app.patch("/v1/projects/:id", async (c) => {
5671
- const { pinned, name } = await c.req.json().catch(() => ({}));
5672
- const p = store.updateProject(c.req.param("id"), { pinned, name });
5673
- return p ? c.json(p) : c.json({ error: "not found" }, 404);
7934
+ const { pinned, name, icon, color } = await c.req.json().catch(() => ({}));
7935
+ if (!store.project(c.req.param("id")))
7936
+ return c.json({ error: "not found" }, 404);
7937
+ const p = store.updateProject(c.req.param("id"), { pinned, name, icon, color });
7938
+ return p ? c.json(p) : c.json({ error: "icon is at most 4 characters; color is c1\u2026c7" }, 400);
5674
7939
  });
5675
7940
  app.delete("/v1/projects/:id", (c) => store.removeProject(c.req.param("id")) ? c.body(null, 204) : c.json({ error: "not found" }, 404));
5676
7941
  app.get("/v1/fs/ls", (c) => {
5677
7942
  const q = c.req.query("path");
5678
7943
  let dir;
5679
7944
  try {
5680
- dir = realpathSync3(q && existsSync5(q) ? q : homedir4());
7945
+ dir = realpathSync3(q && existsSync6(q) ? q : homedir4());
5681
7946
  } catch {
5682
7947
  dir = homedir4();
5683
7948
  }
5684
7949
  try {
5685
- const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync5(join7(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
5686
- const parent = dirname2(dir);
7950
+ 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));
7951
+ const parent = dirname3(dir);
5687
7952
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
5688
7953
  } catch (e) {
5689
7954
  return c.json({ error: e.message, path: dir }, 400);
@@ -5708,6 +7973,31 @@ function createApp(store = new Store) {
5708
7973
  })
5709
7974
  });
5710
7975
  });
7976
+ app.get("/v1/policy", (c) => {
7977
+ const id = c.req.query("project");
7978
+ const p = id ? store.project(id) : null;
7979
+ if (id && !p)
7980
+ return c.json({ error: "unknown project" }, 404);
7981
+ const { provenance, overridden, policy: policy2 } = store.policyFor(p?.root ?? null);
7982
+ return c.json({ ...policy2, provenance, overridden });
7983
+ });
7984
+ app.get("/v1/audit", (c) => {
7985
+ const since = sinceToIso(c.req.query("since"));
7986
+ if (c.req.query("since") && !since)
7987
+ return c.json({ error: "since: use 30d / 12h / 90m or an ISO date" }, 400);
7988
+ const pid = c.req.query("project") || null;
7989
+ if (pid && !store.project(pid))
7990
+ return c.json({ error: "unknown project" }, 404);
7991
+ const rows = store.audit({
7992
+ since,
7993
+ projectId: pid,
7994
+ type: c.req.query("type") || null,
7995
+ limit: Number(c.req.query("limit")) || undefined
7996
+ });
7997
+ const format = c.req.query("format") === "csv" ? "csv" : c.req.query("format") === "jsonl" ? "jsonl" : "json";
7998
+ const ct = format === "csv" ? "text/csv; charset=utf-8" : format === "jsonl" ? "application/x-ndjson" : "application/json";
7999
+ return c.body(formatAudit(rows, format), 200, { "content-type": ct });
8000
+ });
5711
8001
  app.get("/v1/rules/dryrun", (c) => {
5712
8002
  const projectId = c.req.query("project");
5713
8003
  if (!projectId)
@@ -5721,11 +8011,11 @@ function createApp(store = new Store) {
5721
8011
  });
5722
8012
  app.post("/v1/incidents/ack", async (c) => {
5723
8013
  const body = await c.req.json().catch(() => ({}));
5724
- return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
8014
+ return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined, body.by) });
5725
8015
  });
5726
8016
  app.post("/v1/incidents/:seq/ack", (c) => {
5727
8017
  const seq = Number(c.req.param("seq"));
5728
- if (!Number.isInteger(seq) || !store.ackIncident(seq))
8018
+ if (!Number.isInteger(seq) || !store.ackIncident(seq, c.req.query("by")))
5729
8019
  return c.json({ ok: false, error: "no such incident" }, 404);
5730
8020
  return c.json({ ok: true });
5731
8021
  });
@@ -5793,10 +8083,84 @@ function createApp(store = new Store) {
5793
8083
  model: b.model,
5794
8084
  permissionMode: b.permissionMode,
5795
8085
  allowedTools: b.allowedTools,
5796
- maxTurns: b.maxTurns
8086
+ maxTurns: b.maxTurns,
8087
+ profile: b.profile
5797
8088
  });
5798
8089
  return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
5799
8090
  });
8091
+ app.get("/v1/budget", (c) => {
8092
+ const project = c.req.query("project");
8093
+ if (!project)
8094
+ return c.json({ error: "project required" }, 400);
8095
+ return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
8096
+ });
8097
+ app.get("/v1/context", (c) => {
8098
+ const cwd = c.req.query("cwd");
8099
+ if (!cwd)
8100
+ return c.json({ error: "cwd required" }, 400);
8101
+ return c.json(store.contextFor(cwd, c.req.query("session") || null));
8102
+ });
8103
+ app.get("/v1/questions", (c) => c.json(store.questions({
8104
+ projectId: c.req.query("project") || undefined,
8105
+ sessionId: c.req.query("session") || undefined,
8106
+ open: c.req.query("open") === "1"
8107
+ })));
8108
+ app.post("/v1/questions", async (c) => {
8109
+ const b = await c.req.json().catch(() => ({}));
8110
+ if (!b.projectId)
8111
+ return c.json({ ok: false, error: "projectId required" }, 400);
8112
+ const r = store.ask(b.projectId, {
8113
+ sessionId: b.sessionId ?? null,
8114
+ text: b.text,
8115
+ options: b.options,
8116
+ askedBy: b.askedBy ?? null,
8117
+ cwd: b.cwd ?? null
8118
+ });
8119
+ return c.json(r, r.ok ? 201 : 400);
8120
+ });
8121
+ app.post("/v1/questions/:id/answer", async (c) => {
8122
+ const b = await c.req.json().catch(() => ({}));
8123
+ const r = store.answer(Number(c.req.param("id")), b.text, b.by ?? null);
8124
+ if (r.ok && r.question.sessionId) {
8125
+ const run2 = runner.get(r.question.sessionId);
8126
+ if (run2 && run2.sessionId === r.question.sessionId) {
8127
+ 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}`);
8128
+ if (sent.ok)
8129
+ store.inbox(r.question.sessionId);
8130
+ }
8131
+ }
8132
+ return c.json(r, r.ok ? 200 : 409);
8133
+ });
8134
+ app.get("/v1/inbox", (c) => c.json(store.inbox(c.req.query("session") || null, { peek: c.req.query("peek") === "1" })));
8135
+ app.get("/v1/dispatch", (c) => {
8136
+ const project = c.req.query("project");
8137
+ if (!project)
8138
+ return c.json({ error: "project required" }, 400);
8139
+ return c.json({ entries: dispatcher.status(project), config: store.config(project).dispatch });
8140
+ });
8141
+ app.post("/v1/dispatch", async (c) => {
8142
+ const b = await c.req.json().catch(() => ({}));
8143
+ if (!b.projectId)
8144
+ return c.json({ ok: false, error: "projectId required" }, 400);
8145
+ if (!b.ready && !b.tasks?.length)
8146
+ return c.json({ ok: false, error: "tasks or ready:true required" }, 400);
8147
+ const r = await dispatcher.dispatch(b.projectId, b.ready ? "ready" : b.tasks, {
8148
+ owner: b.owner ?? "dispatch",
8149
+ max: b.max,
8150
+ maxParallel: b.maxParallel,
8151
+ permissionMode: b.permissionMode,
8152
+ model: b.model,
8153
+ maxTurns: b.maxTurns,
8154
+ profile: b.profile
8155
+ });
8156
+ return c.json(r, r.ok ? 202 : 409);
8157
+ });
8158
+ app.delete("/v1/dispatch", async (c) => {
8159
+ const b = await c.req.json().catch(() => ({}));
8160
+ if (!b.projectId)
8161
+ return c.json({ ok: false, error: "projectId required" }, 400);
8162
+ return c.json({ ok: true, cleared: dispatcher.clear(b.projectId, b.task) });
8163
+ });
5800
8164
  app.post("/v1/runs/:id/send", async (c) => {
5801
8165
  const b = await c.req.json().catch(() => ({}));
5802
8166
  if (!b.text?.trim())
@@ -5848,10 +8212,31 @@ function createApp(store = new Store) {
5848
8212
  const required = store.requiredGates(project);
5849
8213
  return c.json({
5850
8214
  required,
8215
+ executable: Object.keys(store.gateDefs(project)?.defs ?? {}),
5851
8216
  runs,
5852
8217
  status: task ? store.gateStatusFor(runs, required) : undefined
5853
8218
  });
5854
8219
  });
8220
+ app.post("/v1/gates/run", async (c) => {
8221
+ const b = await c.req.json().catch(() => ({}));
8222
+ if (!b.projectId || !b.task)
8223
+ return c.json({ ok: false, error: "projectId and task required" }, 400);
8224
+ const opts = { sessionId: b.sessionId ?? null, owner: "cli" };
8225
+ if (b.wait === false) {
8226
+ const projectId = b.projectId;
8227
+ const cfg = store.gateDefs(projectId);
8228
+ const names = b.gates?.length ? b.gates : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
8229
+ store.runGates(projectId, b.task, names, opts);
8230
+ return c.json({ ok: true, started: names, runs: [] }, 202);
8231
+ }
8232
+ const r = await store.runGates(b.projectId, b.task, b.gates, opts);
8233
+ const ok = r.started.length > 0 && r.runs.every((x) => x.verdict === "pass");
8234
+ return c.json({
8235
+ ok,
8236
+ ...r,
8237
+ error: r.started.length ? undefined : r.skipped[0]?.reason ?? "no executable gates"
8238
+ }, r.started.length ? 200 : 409);
8239
+ });
5855
8240
  app.post("/v1/gates", async (c) => {
5856
8241
  const b = await c.req.json().catch(() => ({}));
5857
8242
  if (!b.projectId)
@@ -5878,7 +8263,7 @@ function createApp(store = new Store) {
5878
8263
  const b = await c.req.json();
5879
8264
  if (!b.projectId || !b.task)
5880
8265
  return c.json({ error: "projectId and task required" }, 400);
5881
- const r = store.claim(b.projectId, b.task, b.owner ?? "cli", b.baseRef);
8266
+ const r = store.claim(b.projectId, b.task, b.owner ?? "cli", b.baseRef, b.sessionId ?? null);
5882
8267
  return c.json(r, r.ok ? 201 : 409);
5883
8268
  });
5884
8269
  app.post("/v1/claims/renew", async (c) => {
@@ -5891,6 +8276,81 @@ function createApp(store = new Store) {
5891
8276
  const r = store.release(b.projectId ?? "", b.task ?? "", b.force ?? false);
5892
8277
  return c.json(r, r.ok ? 200 : 409);
5893
8278
  });
8279
+ app.get("/v1/worktrees", async (c) => {
8280
+ const project = c.req.query("project");
8281
+ if (!project)
8282
+ return c.json({ error: "project required" }, 400);
8283
+ return c.json(await store.refreshWorktrees(project));
8284
+ });
8285
+ app.post("/v1/worktrees", async (c) => {
8286
+ const b = await c.req.json();
8287
+ if (!b.projectId || !b.name)
8288
+ return c.json({ error: "projectId and name required" }, 400);
8289
+ const r = store.createWorktree(b.projectId, b.name, b.baseRef, b.branch);
8290
+ return c.json(r, r.ok ? 201 : 409);
8291
+ });
8292
+ app.post("/v1/worktrees/remove", async (c) => {
8293
+ const b = await c.req.json();
8294
+ if (!b.projectId || !b.worktree)
8295
+ return c.json({ error: "projectId and worktree required" }, 400);
8296
+ const r = await store.removeWorktree(b.projectId, b.worktree, b.force ?? false);
8297
+ return c.json(r, r.ok ? 200 : 409);
8298
+ });
8299
+ app.post("/v1/worktrees/open", async (c) => {
8300
+ const b = await c.req.json();
8301
+ if (!b.projectId || !b.worktree)
8302
+ return c.json({ error: "projectId and worktree required" }, 400);
8303
+ await store.refreshWorktrees(b.projectId);
8304
+ const r = store.openWorktree(b.projectId, b.worktree);
8305
+ return c.json(r, r.ok ? 200 : 404);
8306
+ });
8307
+ app.get("/v1/worktrees/diff", async (c) => {
8308
+ const project = c.req.query("project");
8309
+ const ref = c.req.query("worktree");
8310
+ if (!project || !ref)
8311
+ return c.json({ error: "project and worktree required" }, 400);
8312
+ await store.refreshWorktrees(project);
8313
+ const w = store.findWorktree(project, ref);
8314
+ const p = store.project(project);
8315
+ if (!w || !p)
8316
+ return c.json({ error: `no worktree ${ref}` }, 404);
8317
+ const file = c.req.query("file") || undefined;
8318
+ const d = await worktreeDiff(p.root, w.path);
8319
+ if (file || c.req.query("patch") === "1")
8320
+ return c.json({ ...d, worktree: w.path, patch: await worktreePatch(w.path, d.base, file) });
8321
+ return c.json({ ...d, worktree: w.path });
8322
+ });
8323
+ app.get("/v1/prs/draft", async (c) => {
8324
+ const project = c.req.query("project");
8325
+ const ref = c.req.query("worktree") || c.req.query("task");
8326
+ if (!project || !ref)
8327
+ return c.json({ error: "project and worktree|task required" }, 400);
8328
+ const r = await store.prDraftFor(project, ref);
8329
+ return c.json(r, r.ok ? 200 : 404);
8330
+ });
8331
+ app.post("/v1/prs/open", async (c) => {
8332
+ const b = await c.req.json().catch(() => ({}));
8333
+ const ref = b.worktree || b.task;
8334
+ if (!b.projectId || !ref)
8335
+ return c.json({ ok: false, error: "projectId and worktree|task required" }, 400);
8336
+ const d = await store.prDraftFor(b.projectId, ref);
8337
+ if (!d.ok)
8338
+ return c.json(d, 404);
8339
+ const r = await forge2.openPR(b.projectId, d.worktree, {
8340
+ title: b.title?.trim() || d.title,
8341
+ body: b.body ?? d.body,
8342
+ isDraft: b.draft ?? false
8343
+ });
8344
+ if (r.ok)
8345
+ store.recordPrOpened(b.projectId, d.task, d.worktree.path, r.url);
8346
+ return c.json(r, r.ok ? 201 : 409);
8347
+ });
8348
+ app.post("/v1/worktrees/gc", async (c) => {
8349
+ const b = await c.req.json().catch(() => ({}));
8350
+ if (!b.projectId)
8351
+ return c.json({ error: "projectId required" }, 400);
8352
+ return c.json(await store.gcWorktrees(b.projectId, b.apply ?? false));
8353
+ });
5894
8354
  app.post("/v1/claims/reap", async (c) => {
5895
8355
  const b = await c.req.json().catch(() => ({}));
5896
8356
  return c.json({ reaped: store.reap(b.projectId) });
@@ -5950,6 +8410,7 @@ function createApp(store = new Store) {
5950
8410
  const raw2 = await c.req.json().catch(() => ({}));
5951
8411
  store.ingestHook(event, raw2);
5952
8412
  if (event === "SessionStart" && typeof raw2.cwd === "string") {
8413
+ store.checkPolicy(raw2.cwd, typeof raw2.session_id === "string" ? raw2.session_id : null);
5953
8414
  const ctx = store.sessionContext(raw2.cwd);
5954
8415
  if (ctx)
5955
8416
  return c.json({
@@ -5957,18 +8418,26 @@ function createApp(store = new Store) {
5957
8418
  hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
5958
8419
  });
5959
8420
  }
5960
- if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
8421
+ const sid = typeof raw2.session_id === "string" ? raw2.session_id : null;
8422
+ const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
8423
+ if (event === "PreToolUse" && !store.guardDisabled(hookRepoRoot(store, raw2))) {
5961
8424
  const guard = store.guardHook(raw2);
5962
8425
  if (guard) {
5963
8426
  return c.json({
5964
8427
  hookSpecificOutput: {
5965
8428
  hookEventName: "PreToolUse",
5966
8429
  permissionDecision: guard.action,
5967
- permissionDecisionReason: `[swarm] ${guard.reason}`
8430
+ permissionDecisionReason: `[swarm] ${guard.reason}`,
8431
+ ...answers ? { additionalContext: answers } : {}
5968
8432
  }
5969
8433
  });
5970
8434
  }
5971
8435
  }
8436
+ if (answers)
8437
+ return c.json({
8438
+ additionalContext: answers,
8439
+ hookSpecificOutput: { hookEventName: event, additionalContext: answers }
8440
+ });
5972
8441
  return c.json({});
5973
8442
  });
5974
8443
  app.post("/v1/events", async (c) => {
@@ -5997,18 +8466,18 @@ function createApp(store = new Store) {
5997
8466
  });
5998
8467
  });
5999
8468
  });
6000
- app.get("/", (c) => c.html(readFileSync4(join7(WEB_DIR, "index.html"), "utf8")));
8469
+ app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
6001
8470
  const MIME = { js: "text/javascript", css: "text/css" };
6002
8471
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
6003
8472
  const f = c.req.param("file");
6004
- const p = join7(WEB_DIR, f);
6005
- if (!existsSync5(p))
8473
+ const p = join9(WEB_DIR, f);
8474
+ if (!existsSync6(p))
6006
8475
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
6007
8476
  return c.body(readFileSync4(p, "utf8"), 200, {
6008
8477
  "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
6009
8478
  });
6010
8479
  });
6011
- return { app, store, forge: forge2, runner };
8480
+ return { app, store, forge: forge2, runner, dispatcher };
6012
8481
  }
6013
8482
 
6014
8483
  // packages/daemon/src/bin.ts
@@ -6027,6 +8496,7 @@ function serve() {
6027
8496
  }
6028
8497
  var server = serve();
6029
8498
  var port = server.port ?? DEFAULT_PORT2;
8499
+ ensureToken();
6030
8500
  writeDaemonInfo({ port, pid: process.pid, version: VERSION, startedAt: new Date().toISOString() });
6031
8501
  var backfillDays = Number(process.env.SWARM_CODEX_BACKFILL_DAYS ?? 30);
6032
8502
  var backfillMs = backfillDays * 24 * 60 * 60000;
@@ -6044,6 +8514,8 @@ var tailer = setInterval(() => {
6044
8514
  store.reapProcesses();
6045
8515
  if (tick % 12 === 0)
6046
8516
  store.sweepOrphans();
8517
+ if (tick % 6 === 0)
8518
+ store.checkBudgets();
6047
8519
  }, 5000);
6048
8520
  store.refreshAllWorktrees();
6049
8521
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);