@ra3orblade/swarm 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swarmd.js CHANGED
@@ -322,13 +322,96 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
322
322
  payload.prompt = raw.prompt;
323
323
  return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
324
324
  }
325
+ // packages/core/src/budget.ts
326
+ function budgetStatus(spent, cfg) {
327
+ const part = (s, l) => ({
328
+ spent: s,
329
+ limit: l,
330
+ pct: l && l > 0 ? s / l : 0
331
+ });
332
+ const daily = part(spent.today, cfg.daily);
333
+ const weekly = part(spent.week, cfg.weekly);
334
+ const candidates = [
335
+ ["daily", daily],
336
+ ["weekly", weekly]
337
+ ];
338
+ let kind = null;
339
+ let top = { spent: 0, limit: null, pct: 0 };
340
+ for (const [k, v] of candidates)
341
+ if (v.limit && v.pct >= top.pct)
342
+ ({ kind, top } = { kind: k, top: v });
343
+ const level = !kind ? "ok" : top.pct >= 1 ? "exceeded" : top.pct >= cfg.warn_at ? "warn" : "ok";
344
+ return { level, kind, spent: top.spent, limit: top.limit, pct: top.pct, daily, weekly };
345
+ }
346
+ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
347
+ function budgetMessage(s, project) {
348
+ const usd = (n) => `$${n.toFixed(2)}`;
349
+ if (s.level === "ok" || !s.limit)
350
+ return `${project}: within budget`;
351
+ return `${project} has spent ${usd(s.spent)} of its ${usd(s.limit)} ${s.kind} budget (${Math.round(s.pct * 100)}%)`;
352
+ }
353
+ var RUN_PROFILES = {
354
+ full: {
355
+ name: "full",
356
+ description: "every tool, rules still apply",
357
+ disallowedTools: [],
358
+ allowedTools: []
359
+ },
360
+ "no-edits": {
361
+ name: "no-edits",
362
+ description: "may run commands, may not edit files (review, triage, test runs)",
363
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
364
+ allowedTools: []
365
+ },
366
+ "read-only": {
367
+ name: "read-only",
368
+ description: "read and search only \u2014 no edits, no shell",
369
+ disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"],
370
+ allowedTools: ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]
371
+ }
372
+ };
373
+ function runProfile(name) {
374
+ if (!name)
375
+ return null;
376
+ return RUN_PROFILES[name] ?? null;
377
+ }
325
378
  // packages/core/src/config.ts
326
379
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
327
380
  import { join as join2 } from "path";
381
+ var DEFAULT_GATE_TIMEOUT_S = 900;
382
+ var AUTO_MODES = ["session-end", "stop", "off"];
383
+ function parseGateDefs(gates) {
384
+ const out = {};
385
+ if (!isRecord(gates))
386
+ return out;
387
+ for (const [name, v] of Object.entries(gates)) {
388
+ if (!isRecord(v) || typeof v.cmd !== "string" || !v.cmd.trim())
389
+ continue;
390
+ if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name))
391
+ continue;
392
+ const t = Number(v.timeout);
393
+ out[name] = {
394
+ cmd: v.cmd.trim(),
395
+ timeout: Number.isFinite(t) && t > 0 ? Math.min(t, 86400) : DEFAULT_GATE_TIMEOUT_S,
396
+ cwd: isRepoRelative(v.cwd) ? v.cwd.trim() : null
397
+ };
398
+ }
399
+ return out;
400
+ }
328
401
  var DEFAULT_CONFIG = {
329
402
  daemon: { port: 7777 },
330
403
  tasks: { source: null, labels: [], team: null },
331
- gates: { required: [] },
404
+ gates: { required: [], auto: "session-end", defs: {} },
405
+ budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
406
+ dispatch: {
407
+ max_parallel: 2,
408
+ permission_mode: null,
409
+ model: null,
410
+ max_turns: null,
411
+ require_pr: true,
412
+ profile: null
413
+ },
414
+ worktree: { setup: null, copy: [], open: null },
332
415
  rules: {
333
416
  shared_tree: "ask",
334
417
  destructive_git: "ask",
@@ -359,10 +442,32 @@ function parseToml(text, source) {
359
442
  return {};
360
443
  }
361
444
  }
445
+ function isRepoRelative(f) {
446
+ if (typeof f !== "string")
447
+ return false;
448
+ const t = f.trim();
449
+ if (!t || t.startsWith("/") || t.startsWith("\\") || /^[a-zA-Z]:/.test(t))
450
+ return false;
451
+ return !t.split(/[/\\]/).some((seg) => seg === "..");
452
+ }
362
453
  function validate(c) {
363
454
  const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
364
455
  const port = Number(c.daemon?.port);
365
456
  const source = c.tasks?.source;
457
+ const setup = c.worktree?.setup;
458
+ const opener = c.worktree?.open;
459
+ const rawGates = c.gates;
460
+ const d = c.dispatch ?? {};
461
+ const mp = Number(d.max_parallel);
462
+ const mt = Number(d.max_turns);
463
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
464
+ const b = c.budget ?? {};
465
+ const usd = (v) => {
466
+ const n = Number(v);
467
+ return Number.isFinite(n) && n > 0 ? n : null;
468
+ };
469
+ const warnAt = Number(b.warn_at);
470
+ const auto = rawGates?.auto;
366
471
  return {
367
472
  ...c,
368
473
  daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
@@ -371,6 +476,30 @@ function validate(c) {
371
476
  labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
372
477
  team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
373
478
  },
479
+ gates: {
480
+ required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
481
+ auto: AUTO_MODES.includes(auto) ? auto : "session-end",
482
+ defs: parseGateDefs(rawGates)
483
+ },
484
+ budget: {
485
+ daily: usd(b.daily),
486
+ weekly: usd(b.weekly),
487
+ warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
488
+ on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
489
+ },
490
+ dispatch: {
491
+ max_parallel: Number.isInteger(mp) && mp > 0 ? Math.min(mp, 16) : 2,
492
+ permission_mode: str(d.permission_mode),
493
+ model: str(d.model),
494
+ max_turns: Number.isInteger(mt) && mt > 0 ? mt : null,
495
+ require_pr: d.require_pr === undefined ? true : d.require_pr === true,
496
+ profile: ["full", "no-edits", "read-only"].includes(String(d.profile)) ? String(d.profile) : null
497
+ },
498
+ worktree: {
499
+ setup: typeof setup === "string" && setup.trim() ? setup.trim() : null,
500
+ copy: Array.isArray(c.worktree?.copy) ? c.worktree.copy.filter((f) => isRepoRelative(f)) : [],
501
+ open: typeof opener === "string" && opener.trim() ? opener.trim() : null
502
+ },
374
503
  rules: {
375
504
  ...c.rules,
376
505
  shared_tree: mode(c.rules?.shared_tree, "ask"),
@@ -398,6 +527,71 @@ function loadConfig(opts = {}) {
398
527
  }
399
528
  return validate(cfg);
400
529
  }
530
+ // packages/core/src/dispatch.ts
531
+ function planDispatch(tasks, wanted, opts) {
532
+ const byId = new Map(tasks.map((t) => [t.id, t]));
533
+ const queued = new Set(opts.alreadyQueued ?? []);
534
+ const rejected = [];
535
+ const picked = [];
536
+ const ids = wanted === "ready" ? tasks.filter((t) => t.ready).map((t) => t.id) : wanted;
537
+ for (const id of ids) {
538
+ const t = byId.get(id);
539
+ if (!t)
540
+ rejected.push({ id, reason: "not in the task source" });
541
+ else if (queued.has(id))
542
+ rejected.push({ id, reason: "already queued" });
543
+ else if (t.claimedBy)
544
+ rejected.push({ id, reason: `held by ${t.claimedBy}` });
545
+ else if (t.status === "done")
546
+ rejected.push({ id, reason: "already done" });
547
+ else if (!t.ready)
548
+ rejected.push({
549
+ id,
550
+ reason: t.status === "active" ? "in progress" : "blocked by dependencies"
551
+ });
552
+ else if (picked.some((p) => p.id === id))
553
+ rejected.push({ id, reason: "listed twice" });
554
+ else
555
+ picked.push(t);
556
+ }
557
+ const limit = opts.max && opts.max > 0 ? picked.slice(0, opts.max) : picked;
558
+ for (const t of picked.slice(limit.length))
559
+ rejected.push({ id: t.id, reason: `beyond --max ${opts.max}` });
560
+ const slots = Math.max(0, opts.maxParallel - opts.running);
561
+ return { start: limit.slice(0, slots), queued: limit.slice(slots), rejected };
562
+ }
563
+ function taskPrompt(task, ctx = {
564
+ requiredGates: [],
565
+ executableGates: [],
566
+ openPr: true
567
+ }) {
568
+ const manual = ctx.requiredGates.filter((g) => !ctx.executableGates.includes(g));
569
+ const exec = ctx.requiredGates.filter((g) => ctx.executableGates.includes(g));
570
+ const steps = [
571
+ "Work only inside this worktree; never touch the main checkout or another worktree.",
572
+ "Commit as you go with clear messages. Do not edit the task list or flip the task's status \u2014 Swarm derives it.",
573
+ exec.length ? `Run the executable gates with swarm_gate_run (${exec.join(", ")}) and fix what fails.` : null,
574
+ manual.length ? `Record the remaining required gates with swarm_gate_record and an honest rubric (${manual.join(", ")}).` : null,
575
+ "Call swarm_handoff with what was done, what remains, the files touched and how to verify.",
576
+ ctx.openPr ? "Then push and open the pull request with swarm_pr_open." : "Push the branch.",
577
+ "If you are blocked on a decision only a human can make, say so in the handoff and stop."
578
+ ].filter(Boolean);
579
+ return `Task ${task.id}: ${task.title}
580
+
581
+ ${steps.map((s, i) => `${i + 1}. ${s}`).join(`
582
+ `)}`;
583
+ }
584
+ function dispatchOutcome(facts) {
585
+ if (facts.stopped)
586
+ return "stopped";
587
+ if (facts.exitCode !== 0 || facts.isError)
588
+ return "crashed";
589
+ if (!facts.gatesSatisfied)
590
+ return "gates-failed";
591
+ if (facts.requirePr && !facts.prOpen)
592
+ return "no-pr";
593
+ return "done";
594
+ }
401
595
  // packages/core/src/rules.ts
402
596
  var LIVE_WINDOW_MS = 10 * 60000;
403
597
  function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
@@ -706,6 +900,68 @@ function normalizeGitlab(raw, repo) {
706
900
  };
707
901
  });
708
902
  }
903
+ function parseNumstat(numstat, nameStatus = "") {
904
+ const status = new Map;
905
+ for (const line of nameStatus.split(`
906
+ `)) {
907
+ const [st, ...rest] = line.split("\t");
908
+ if (!st || !rest.length)
909
+ continue;
910
+ status.set(rest[rest.length - 1], st[0]);
911
+ }
912
+ const out = [];
913
+ for (const line of numstat.split(`
914
+ `)) {
915
+ const [a, d, ...rest] = line.split("\t");
916
+ if (a === undefined || d === undefined || !rest.length)
917
+ continue;
918
+ const raw = rest.join("\t");
919
+ const path = raw.includes(" => ") ? raw.replace(/\{?([^{}]*) => ([^{}]*)\}?/, "$2") : raw;
920
+ out.push({
921
+ path,
922
+ added: a === "-" ? -1 : Number(a),
923
+ deleted: d === "-" ? -1 : Number(d),
924
+ status: status.get(path) ?? "M"
925
+ });
926
+ }
927
+ return out;
928
+ }
929
+ function prDraft(i) {
930
+ const title = ((i.title?.trim()) ? `${i.task}: ${i.title.trim()}` : i.task).slice(0, 120);
931
+ const b = [];
932
+ b.push("## Summary");
933
+ if (i.handoff?.done.trim())
934
+ b.push(i.handoff.done.trim());
935
+ else if (i.commits?.length)
936
+ b.push(i.commits.map((c) => `- ${c}`).join(`
937
+ `));
938
+ else
939
+ b.push(`Work on ${i.task}.`);
940
+ if (i.handoff?.remaining.trim() && !/^(nothing|none|\u2014|-)\.?$/i.test(i.handoff.remaining.trim()))
941
+ b.push(`
942
+ ## Remaining
943
+ ${i.handoff.remaining.trim()}`);
944
+ if (i.gates?.length) {
945
+ b.push(`
946
+ ## Gates`);
947
+ b.push(i.gates.map((g) => `- ${g.verdict === "pass" ? "[x]" : "[ ]"} ${g.gate}${g.verdict === "fail" ? " \u2014 failed" : g.verdict ? "" : " \u2014 not run"}`).join(`
948
+ `));
949
+ }
950
+ if (i.handoff?.verify?.trim())
951
+ b.push(`
952
+ ## Verify
953
+ ${i.handoff.verify.trim()}`);
954
+ if (i.files?.length) {
955
+ const shown = i.files.slice(0, 30);
956
+ b.push(`
957
+ ## Files (${i.files.length})
958
+ ${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.deleted}` : " (binary)"}`).join(`
959
+ `)}${i.files.length > shown.length ? `
960
+ - \u2026 ${i.files.length - shown.length} more` : ""}`);
961
+ }
962
+ return { title, body: b.join(`
963
+ `) };
964
+ }
709
965
  // packages/core/src/gates.ts
710
966
  var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
711
967
  function validateGateRun(input) {
@@ -749,6 +1005,25 @@ function gatesSatisfied(runs, declared) {
749
1005
  const st = gateStatus(runs, declared);
750
1006
  return declared.every((g) => st.find((s) => s.gate === g)?.verdict === "pass");
751
1007
  }
1008
+ function evidenceTail(output, max = 2000) {
1009
+ const t = output.trimEnd();
1010
+ if (t.length <= max)
1011
+ return t;
1012
+ const cut = t.slice(-max);
1013
+ const nl = cut.indexOf(`
1014
+ `);
1015
+ return `\u2026${nl >= 0 && nl < 200 ? cut.slice(nl + 1) : cut}`;
1016
+ }
1017
+ function executedGateInput(task, gate, cmd, outcome) {
1018
+ const how = outcome.timedOut === true ? "timed out" : outcome.exitCode === null ? "could not start" : `exit ${outcome.exitCode}`;
1019
+ return {
1020
+ task,
1021
+ gate,
1022
+ verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
1023
+ rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
1024
+ evidence: evidenceTail(outcome.output) || null
1025
+ };
1026
+ }
752
1027
  // packages/core/src/ledger.ts
753
1028
  var DEFAULT_LEASE_MINUTES = 45;
754
1029
  function isExpired(claim, now) {
@@ -1151,6 +1426,29 @@ function projectIdentity(opts) {
1151
1426
  const name = parts[parts.length - 1] ?? opts.root;
1152
1427
  return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
1153
1428
  }
1429
+ // packages/core/src/questions.ts
1430
+ function validateQuestion(text, options) {
1431
+ const t = typeof text === "string" ? text.trim() : "";
1432
+ if (t.length < 5)
1433
+ return { ok: false, reason: "a question needs at least a few words" };
1434
+ if (t.length > 4000)
1435
+ return { ok: false, reason: "keep the question under 4000 characters" };
1436
+ const opts = Array.isArray(options) ? options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim()).slice(0, 8) : [];
1437
+ return { ok: true, text: t, options: opts };
1438
+ }
1439
+ function formatAnswers(qs) {
1440
+ const answered = qs.filter((q) => q.answer !== null);
1441
+ if (!answered.length)
1442
+ return null;
1443
+ return answered.map((q) => `[swarm] answer from ${q.answeredBy ?? "a human"} to your question "${q.text.slice(0, 200)}": ${q.answer}`).join(`
1444
+ `);
1445
+ }
1446
+ function formatOpenQuestions(qs) {
1447
+ const open = qs.filter((q) => q.answer === null);
1448
+ if (!open.length)
1449
+ return null;
1450
+ return `[swarm] waiting on a human for: ${open.map((q) => `#${q.id} "${q.text.slice(0, 120)}"`).join("; ")} \u2014 the answer arrives as context on a later tool call, or via swarm_inbox`;
1451
+ }
1154
1452
  // packages/core/src/resources.ts
1155
1453
  var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
1156
1454
  function isTrackedPid(pid) {
@@ -1335,18 +1633,91 @@ function linearIssuesQuery(teamKey, first = 200) {
1335
1633
  inverseRelations { nodes { type issue { identifier } } }
1336
1634
  } } }`;
1337
1635
  }
1636
+ // packages/core/src/worktree.ts
1637
+ import { join as join3 } from "path";
1638
+ function planBootstrap(cfg, repoRoot, worktree) {
1639
+ const seen = new Set;
1640
+ const copies = [];
1641
+ for (const raw of cfg.worktree.copy) {
1642
+ if (!isRepoRelative(raw))
1643
+ continue;
1644
+ const rel = raw.trim().replace(/^\.\//, "");
1645
+ if (seen.has(rel))
1646
+ continue;
1647
+ seen.add(rel);
1648
+ copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
1649
+ }
1650
+ return { copies, setup: cfg.worktree.setup };
1651
+ }
1652
+ var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
1653
+ function summarizeBootstrap(o) {
1654
+ const parts = [];
1655
+ if (o.copied.length)
1656
+ parts.push(`copied ${o.copied.join(", ")}`);
1657
+ if (o.skipped.length)
1658
+ parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
1659
+ if (o.setup)
1660
+ parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
1661
+ return parts.join("; ") || "nothing to do";
1662
+ }
1663
+ function canRemoveWorktree(w, heldByClaim, force) {
1664
+ if (w.main)
1665
+ return { ok: false, reason: "main" };
1666
+ if (heldByClaim)
1667
+ return { ok: false, reason: "held" };
1668
+ if (force)
1669
+ return { ok: true };
1670
+ if (w.dirty > 0)
1671
+ return { ok: false, reason: "dirty" };
1672
+ if (w.ahead > 0)
1673
+ return { ok: false, reason: "unpushed" };
1674
+ return { ok: true };
1675
+ }
1676
+ function removeRefusalMessage(reason, path, task) {
1677
+ switch (reason) {
1678
+ case "main":
1679
+ return `${path} is the main checkout \u2014 it is never removed`;
1680
+ case "held":
1681
+ return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
1682
+ case "dirty":
1683
+ return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
1684
+ case "unpushed":
1685
+ return `${path} has unpushed commits \u2014 push them, or --force to discard`;
1686
+ }
1687
+ }
1688
+ function planGc(worktrees, claims) {
1689
+ const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
1690
+ const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
1691
+ const out = [];
1692
+ for (const w of worktrees) {
1693
+ if (w.main || held.has(w.path))
1694
+ continue;
1695
+ const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
1696
+ if (!why)
1697
+ continue;
1698
+ const can = canRemoveWorktree(w, null, false);
1699
+ out.push({
1700
+ path: w.path,
1701
+ branch: w.branch,
1702
+ why,
1703
+ removable: can.ok,
1704
+ blocker: can.ok ? null : can.reason
1705
+ });
1706
+ }
1707
+ return out;
1708
+ }
1338
1709
  // packages/daemon/src/app.ts
1339
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
1710
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
1340
1711
  import { homedir as homedir4 } from "os";
1341
- import { dirname as dirname2, join as join7 } from "path";
1712
+ import { dirname as dirname3, join as join9 } from "path";
1342
1713
  import { fileURLToPath } from "url";
1343
1714
 
1344
1715
  // node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
1345
1716
  var compose = (middleware, onError, onNotFound) => {
1346
1717
  return (context, next) => {
1347
1718
  let index = -1;
1348
- return dispatch(0);
1349
- async function dispatch(i) {
1719
+ return dispatch2(0);
1720
+ async function dispatch2(i) {
1350
1721
  if (i <= index) {
1351
1722
  throw new Error("next() called multiple times");
1352
1723
  }
@@ -1362,7 +1733,7 @@ var compose = (middleware, onError, onNotFound) => {
1362
1733
  }
1363
1734
  if (handler) {
1364
1735
  try {
1365
- res = await handler(context, () => dispatch(i + 1));
1736
+ res = await handler(context, () => dispatch2(i + 1));
1366
1737
  } catch (err) {
1367
1738
  if (err instanceof Error && onError) {
1368
1739
  context.error = err;
@@ -2958,16 +3329,269 @@ var streamSSE = (c, cb, onError) => {
2958
3329
  return c.newResponse(stream.responseReadable);
2959
3330
  };
2960
3331
 
3332
+ // packages/daemon/src/dispatcher.ts
3333
+ class Dispatcher {
3334
+ store;
3335
+ runner;
3336
+ forge;
3337
+ entries = new Map;
3338
+ opts = new Map;
3339
+ constructor(store, runner, forge2) {
3340
+ this.store = store;
3341
+ this.runner = runner;
3342
+ this.forge = forge2;
3343
+ runner.onEnd((run2) => void this.onRunEnd(run2));
3344
+ }
3345
+ project(projectId) {
3346
+ let m = this.entries.get(projectId);
3347
+ if (!m) {
3348
+ m = new Map;
3349
+ this.entries.set(projectId, m);
3350
+ }
3351
+ return m;
3352
+ }
3353
+ status(projectId) {
3354
+ return [...this.entries.get(projectId)?.values() ?? []];
3355
+ }
3356
+ async dispatch(projectId, wanted, o = {}) {
3357
+ const board = this.store.tasks(projectId);
3358
+ if (!board)
3359
+ return {
3360
+ ok: false,
3361
+ error: "this repo has no task source ([tasks] source in .swarm.toml)"
3362
+ };
3363
+ if (board.error)
3364
+ return { ok: false, error: `task source: ${board.error}` };
3365
+ const cfg = this.store.config(projectId).dispatch;
3366
+ const opts = {
3367
+ owner: o.owner ?? "dispatch",
3368
+ ...o,
3369
+ maxParallel: o.maxParallel ?? cfg.max_parallel
3370
+ };
3371
+ this.opts.set(projectId, opts);
3372
+ const m = this.project(projectId);
3373
+ const running = [...m.values()].filter((e) => e.state === "running").length;
3374
+ const plan = planDispatch(board.tasks, wanted, {
3375
+ maxParallel: opts.maxParallel,
3376
+ running,
3377
+ max: o.max,
3378
+ alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
3379
+ });
3380
+ const now = new Date().toISOString();
3381
+ for (const t of [...plan.start, ...plan.queued]) {
3382
+ m.set(t.id, {
3383
+ task: t.id,
3384
+ title: t.title,
3385
+ state: "queued",
3386
+ runId: null,
3387
+ sessionId: null,
3388
+ queuedAt: now,
3389
+ startedAt: null,
3390
+ endedAt: null,
3391
+ outcome: null,
3392
+ detail: null,
3393
+ costUsd: null
3394
+ });
3395
+ }
3396
+ if (plan.start.length || plan.queued.length)
3397
+ this.store.append({
3398
+ ts: now,
3399
+ type: "dispatch.queued",
3400
+ projectId,
3401
+ sessionId: null,
3402
+ payload: {
3403
+ tasks: [...plan.start, ...plan.queued].map((t) => t.id),
3404
+ maxParallel: opts.maxParallel,
3405
+ summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
3406
+ }
3407
+ });
3408
+ const started = [];
3409
+ const failed = [];
3410
+ for (const t of plan.start) {
3411
+ const r = await this.startOne(projectId, t);
3412
+ if (r.ok)
3413
+ started.push(t.id);
3414
+ else
3415
+ failed.push({ id: t.id, reason: r.reason });
3416
+ }
3417
+ await this.fill(projectId);
3418
+ return {
3419
+ ok: true,
3420
+ started,
3421
+ queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
3422
+ rejected: [...plan.rejected, ...failed]
3423
+ };
3424
+ }
3425
+ async startOne(projectId, t) {
3426
+ const m = this.project(projectId);
3427
+ const e = m.get(t.id);
3428
+ const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
3429
+ const cfg = this.store.config(projectId);
3430
+ const gates2 = cfg.gates;
3431
+ const prompt = taskPrompt(t, {
3432
+ requiredGates: gates2.required,
3433
+ executableGates: gates2.required.filter((g) => gates2.defs[g]),
3434
+ openPr: cfg.dispatch.require_pr
3435
+ });
3436
+ const r = await this.runner.start({
3437
+ projectId,
3438
+ task: t.id,
3439
+ prompt,
3440
+ owner: opts.owner,
3441
+ permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
3442
+ model: opts.model ?? cfg.dispatch.model ?? undefined,
3443
+ maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
3444
+ profile: opts.profile ?? cfg.dispatch.profile ?? undefined
3445
+ });
3446
+ if (!r.ok) {
3447
+ if (e) {
3448
+ e.state = "finished";
3449
+ e.endedAt = new Date().toISOString();
3450
+ e.outcome = "crashed";
3451
+ e.detail = r.reason;
3452
+ }
3453
+ this.store.append({
3454
+ ts: new Date().toISOString(),
3455
+ type: "dispatch.finished",
3456
+ projectId,
3457
+ sessionId: null,
3458
+ payload: {
3459
+ task: t.id,
3460
+ outcome: "crashed",
3461
+ detail: r.reason,
3462
+ summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
3463
+ }
3464
+ });
3465
+ return { ok: false, reason: r.reason };
3466
+ }
3467
+ if (e) {
3468
+ e.state = "running";
3469
+ e.runId = r.run.id;
3470
+ e.sessionId = r.run.sessionId;
3471
+ e.startedAt = r.run.startedAt;
3472
+ }
3473
+ this.store.append({
3474
+ ts: r.run.startedAt,
3475
+ type: "dispatch.started",
3476
+ projectId,
3477
+ sessionId: r.run.sessionId,
3478
+ payload: {
3479
+ task: t.id,
3480
+ runId: r.run.id,
3481
+ worktree: r.run.worktree,
3482
+ summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
3483
+ }
3484
+ });
3485
+ return { ok: true };
3486
+ }
3487
+ async fill(projectId) {
3488
+ const m = this.project(projectId);
3489
+ const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
3490
+ for (const e of m.values()) {
3491
+ const running = [...m.values()].filter((x) => x.state === "running").length;
3492
+ if (running >= cap)
3493
+ return;
3494
+ if (e.state !== "queued")
3495
+ continue;
3496
+ await this.startOne(projectId, { id: e.task, title: e.title });
3497
+ }
3498
+ }
3499
+ async onRunEnd(run2) {
3500
+ const m = this.entries.get(run2.projectId);
3501
+ const e = m?.get(run2.task);
3502
+ if (!e || e.runId !== run2.id)
3503
+ return;
3504
+ const cfg = this.store.config(run2.projectId);
3505
+ const required = cfg.gates.required;
3506
+ let runs = this.store.gateRuns(run2.projectId, run2.task);
3507
+ const status = this.store.gateStatusFor(runs, required);
3508
+ const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
3509
+ if (missing.length && !run2.stopped) {
3510
+ await this.store.runGates(run2.projectId, run2.task, missing, {
3511
+ sessionId: run2.sessionId,
3512
+ owner: "dispatch"
3513
+ });
3514
+ runs = this.store.gateRuns(run2.projectId, run2.task);
3515
+ }
3516
+ const satisfied = gatesSatisfied(runs, required);
3517
+ await this.forge.refresh(0).catch(() => {});
3518
+ const branch = `task/${run2.task}`;
3519
+ const pr = this.forge.prs().find((p) => p.projectId === run2.projectId && p.branch === branch);
3520
+ const outcome = dispatchOutcome({
3521
+ exitCode: run2.exitCode,
3522
+ isError: run2.result?.isError ?? false,
3523
+ gatesSatisfied: satisfied,
3524
+ prOpen: Boolean(pr),
3525
+ requirePr: cfg.dispatch.require_pr,
3526
+ stopped: run2.stopped ?? false
3527
+ });
3528
+ const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
3529
+ const detail = [
3530
+ `exit ${run2.exitCode}${run2.result?.isError ? " (error)" : ""}`,
3531
+ required.length ? `gates: ${verdicts}` : null,
3532
+ pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
3533
+ ].filter(Boolean).join(" \xB7 ");
3534
+ e.state = "finished";
3535
+ e.endedAt = run2.endedAt;
3536
+ e.outcome = outcome;
3537
+ e.detail = detail;
3538
+ e.costUsd = run2.result?.costUsd ?? null;
3539
+ const ts = run2.endedAt ?? new Date().toISOString();
3540
+ this.store.append({
3541
+ ts,
3542
+ type: "dispatch.finished",
3543
+ projectId: run2.projectId,
3544
+ sessionId: run2.sessionId,
3545
+ payload: {
3546
+ task: run2.task,
3547
+ runId: run2.id,
3548
+ outcome,
3549
+ detail,
3550
+ costUsd: e.costUsd,
3551
+ summary: `dispatch ${run2.task}: ${outcome} \u2014 ${detail}`
3552
+ }
3553
+ });
3554
+ if (outcome !== "done" && outcome !== "stopped")
3555
+ this.store.append({
3556
+ ts,
3557
+ type: "incident.opened",
3558
+ projectId: run2.projectId,
3559
+ sessionId: run2.sessionId,
3560
+ payload: {
3561
+ rule: "dispatch_failed",
3562
+ action: outcome,
3563
+ command: run2.task,
3564
+ reason: `dispatched run on ${run2.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
3565
+ }
3566
+ });
3567
+ this.store.touch();
3568
+ await this.fill(run2.projectId);
3569
+ }
3570
+ clear(projectId, task) {
3571
+ const m = this.project(projectId);
3572
+ let n = 0;
3573
+ for (const [id, e] of m) {
3574
+ if (task && id !== task)
3575
+ continue;
3576
+ if (e.state === "queued" || e.state === "finished" && !task) {
3577
+ m.delete(id);
3578
+ n++;
3579
+ }
3580
+ }
3581
+ return n;
3582
+ }
3583
+ }
3584
+
2961
3585
  // packages/daemon/src/forge.ts
2962
3586
  import { existsSync as existsSync3 } from "fs";
2963
3587
  import { homedir as homedir2 } from "os";
2964
- import { join as join3 } from "path";
3588
+ import { join as join4 } from "path";
2965
3589
  var EXTRA_BIN_DIRS = [
2966
3590
  "/opt/homebrew/bin",
2967
3591
  "/usr/local/bin",
2968
3592
  "/home/linuxbrew/.linuxbrew/bin",
2969
- join3(homedir2(), ".local", "bin"),
2970
- join3(homedir2(), "bin")
3593
+ join4(homedir2(), ".local", "bin"),
3594
+ join4(homedir2(), "bin")
2971
3595
  ];
2972
3596
  function findBin(name) {
2973
3597
  if (!name)
@@ -2976,7 +3600,7 @@ function findBin(name) {
2976
3600
  if (onPath)
2977
3601
  return onPath;
2978
3602
  for (const d of EXTRA_BIN_DIRS) {
2979
- const p = join3(d, name);
3603
+ const p = join4(d, name);
2980
3604
  if (existsSync3(p))
2981
3605
  return p;
2982
3606
  }
@@ -3045,6 +3669,69 @@ class ForgeService {
3045
3669
  }
3046
3670
  return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
3047
3671
  }
3672
+ async openPR(projectId, worktree2, draft) {
3673
+ const p = this.store.projects().find((x) => x.id === projectId);
3674
+ if (!p)
3675
+ return { ok: false, error: "unknown project" };
3676
+ if (worktree2.main)
3677
+ return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
3678
+ if (!worktree2.branch)
3679
+ return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
3680
+ if (worktree2.dirty > 0)
3681
+ return {
3682
+ ok: false,
3683
+ error: `${worktree2.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
3684
+ };
3685
+ const remote = this.remote(p.root);
3686
+ if (!remote)
3687
+ return { ok: false, error: "no GitHub/GitLab remote on origin" };
3688
+ const cli = remote.forge === "github" ? "gh" : "glab";
3689
+ const bin = findBin(cli);
3690
+ if (!bin)
3691
+ return { ok: false, error: `${cli} is not installed` };
3692
+ const sh = async (cmd2, cwd) => {
3693
+ const proc = Bun.spawn(cmd2, { cwd, stdout: "pipe", stderr: "pipe" });
3694
+ const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
3695
+ return { ok: await proc.exited === 0, out: out.trim() };
3696
+ };
3697
+ const push = await sh(["git", "push", "-u", "origin", worktree2.branch], worktree2.path);
3698
+ if (!push.ok)
3699
+ return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
3700
+ const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree2.branch);
3701
+ if (existing)
3702
+ return { ok: true, url: existing.url, number: existing.number };
3703
+ const cmd = remote.forge === "github" ? [
3704
+ bin,
3705
+ "pr",
3706
+ "create",
3707
+ "--head",
3708
+ worktree2.branch,
3709
+ "--title",
3710
+ draft.title,
3711
+ "--body",
3712
+ draft.body,
3713
+ ...draft.isDraft ? ["--draft"] : []
3714
+ ] : [
3715
+ bin,
3716
+ "mr",
3717
+ "create",
3718
+ "--source-branch",
3719
+ worktree2.branch,
3720
+ "--title",
3721
+ draft.title,
3722
+ "--description",
3723
+ draft.body,
3724
+ "--yes",
3725
+ ...draft.isDraft ? ["--draft"] : []
3726
+ ];
3727
+ const r = await sh(cmd, worktree2.path);
3728
+ if (!r.ok)
3729
+ return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
3730
+ const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
3731
+ const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
3732
+ this.cache.delete(projectId);
3733
+ return { ok: true, url, number: Number.isFinite(num) ? num : null };
3734
+ }
3048
3735
  async merge(projectId, number) {
3049
3736
  const p = this.store.projects().find((x) => x.id === projectId);
3050
3737
  if (!p)
@@ -3065,64 +3752,277 @@ class ForgeService {
3065
3752
  }
3066
3753
  }
3067
3754
 
3068
- // packages/daemon/src/runner.ts
3069
- import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
3070
- import { join as join4 } from "path";
3071
- var PERMISSION_MODES = [
3072
- "acceptEdits",
3073
- "auto",
3074
- "bypassPermissions",
3075
- "manual",
3076
- "dontAsk",
3077
- "plan"
3078
- ];
3079
-
3080
- class Runner {
3081
- store;
3082
- home;
3083
- live = new Map;
3084
- constructor(store, home) {
3085
- this.store = store;
3086
- this.home = home;
3755
+ // packages/daemon/src/git.ts
3756
+ import { realpathSync } from "fs";
3757
+ import { join as join5 } from "path";
3758
+ function git(cwd, args) {
3759
+ try {
3760
+ const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3761
+ return r.exitCode === 0 ? r.stdout.toString() : null;
3762
+ } catch {
3763
+ return null;
3087
3764
  }
3088
- list(projectId) {
3089
- return [...this.live.values()].map((x) => x.run).filter((r) => !projectId || r.projectId === projectId);
3765
+ }
3766
+ function gitCommonDir(cwd) {
3767
+ const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
3768
+ if (!out)
3769
+ return null;
3770
+ try {
3771
+ return realpathSync(out.startsWith("/") ? out : join5(cwd, out));
3772
+ } catch {
3773
+ return null;
3090
3774
  }
3091
- get(idOrTask) {
3092
- for (const { run: run2 } of this.live.values())
3093
- if (run2.id === idOrTask || run2.sessionId === idOrTask || run2.task === idOrTask)
3094
- return run2;
3775
+ }
3776
+ function gitToplevel(cwd) {
3777
+ const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
3778
+ if (!out)
3779
+ return null;
3780
+ try {
3781
+ return realpathSync(out);
3782
+ } catch {
3095
3783
  return null;
3096
3784
  }
3097
- async start(input) {
3098
- const bin = findBin("claude");
3099
- if (!bin)
3100
- return { ok: false, reason: "claude CLI not found \u2014 install Claude Code first" };
3101
- const project = this.store.project(input.projectId);
3102
- if (!project)
3103
- return { ok: false, reason: "unknown project" };
3104
- if (!input.prompt.trim())
3105
- return { ok: false, reason: "prompt is required" };
3106
- if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
3107
- return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
3108
- if (this.get(input.task)?.projectId === input.projectId)
3109
- return {
3110
- ok: false,
3111
- reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
3112
- };
3113
- const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
3114
- let worktree = held?.worktree ?? "";
3115
- if (!worktree) {
3116
- const c = this.store.claim(input.projectId, input.task, input.owner);
3117
- if (!c.ok)
3118
- return { ok: false, reason: c.error };
3119
- worktree = c.worktree;
3785
+ }
3786
+ function parseWorktreeList(out) {
3787
+ const wts = [];
3788
+ let cur = null;
3789
+ const flush = () => {
3790
+ if (cur?.path) {
3791
+ wts.push({
3792
+ path: cur.path,
3793
+ branch: cur.branch ?? null,
3794
+ head: (cur.head ?? "").slice(0, 7),
3795
+ main: wts.length === 0,
3796
+ dirty: -1,
3797
+ ahead: -1,
3798
+ behind: -1,
3799
+ merged: false
3800
+ });
3120
3801
  }
3802
+ cur = null;
3803
+ };
3804
+ for (const line of out.split(`
3805
+ `)) {
3806
+ if (line.startsWith("worktree ")) {
3807
+ flush();
3808
+ cur = { path: line.slice(9) };
3809
+ } else if (line.startsWith("HEAD ") && cur)
3810
+ cur.head = line.slice(5);
3811
+ else if (line.startsWith("branch ") && cur)
3812
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3813
+ else if (line === "")
3814
+ flush();
3815
+ }
3816
+ flush();
3817
+ return wts;
3818
+ }
3819
+ function applyStatus(w, st, ah) {
3820
+ w.dirty = st === null ? -1 : st.split(`
3821
+ `).filter(Boolean).length;
3822
+ const a = ah?.trim();
3823
+ w.ahead = a === undefined || a === "" ? -1 : Number(a);
3824
+ }
3825
+ function applyDrift(w, behind, ancestor, firstParents) {
3826
+ const b = behind?.trim();
3827
+ w.behind = b === undefined || b === "" ? -1 : Number(b);
3828
+ const onLine = firstParents?.split(`
3829
+ `).some((sha) => sha.startsWith(w.head)) ?? true;
3830
+ w.merged = ancestor && !onLine;
3831
+ }
3832
+ var FIRST_PARENT_DEPTH = "5000";
3833
+ var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
3834
+ async function gitAsync(cwd, args) {
3835
+ try {
3836
+ const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
3837
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3838
+ return code === 0 ? out : null;
3839
+ } catch {
3840
+ return null;
3841
+ }
3842
+ }
3843
+ async function listWorktreesAsync(root) {
3844
+ const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
3845
+ if (!out)
3846
+ return [];
3847
+ const wts = parseWorktreeList(out);
3848
+ const base = baseOf(wts);
3849
+ const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
3850
+ await Promise.all(wts.map(async (w) => {
3851
+ const drift = base && !w.main;
3852
+ const [st, ah, be, mg] = await Promise.all([
3853
+ gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
3854
+ gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
3855
+ drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
3856
+ drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
3857
+ ]);
3858
+ applyStatus(w, st, ah);
3859
+ if (drift)
3860
+ applyDrift(w, be, mg !== null, line);
3861
+ }));
3862
+ return wts;
3863
+ }
3864
+ var branchCache = new Map;
3865
+ function currentBranch(cwd) {
3866
+ const hit = branchCache.get(cwd);
3867
+ const now = Date.now();
3868
+ if (hit && now - hit.t < 5000)
3869
+ return hit.v;
3870
+ const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
3871
+ branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
3872
+ return branchCache.get(cwd)?.v ?? null;
3873
+ }
3874
+ function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
3875
+ const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
3876
+ const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
3877
+ if (git(repoRoot, args) === null)
3878
+ return null;
3879
+ try {
3880
+ return realpathSync(path);
3881
+ } catch {
3882
+ return path;
3883
+ }
3884
+ }
3885
+ function worktreeRemove(repoRoot, path, force) {
3886
+ const args = ["worktree", "remove", path];
3887
+ if (force)
3888
+ args.push("--force");
3889
+ return git(repoRoot, args) !== null;
3890
+ }
3891
+ function heldWork(path) {
3892
+ const status = git(path, ["status", "--porcelain"]);
3893
+ const dirty = status !== null && status.trim().length > 0;
3894
+ const count = (args) => {
3895
+ const out = git(path, ["rev-list", "--count", ...args])?.trim();
3896
+ return out !== undefined && out !== "" ? Number(out) : 0;
3897
+ };
3898
+ let unpushed;
3899
+ if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
3900
+ unpushed = count(["@{upstream}..HEAD"]) > 0;
3901
+ } else {
3902
+ const baselines = ["--remotes"];
3903
+ for (const b of ["main", "master"]) {
3904
+ if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
3905
+ baselines.push(b);
3906
+ }
3907
+ unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
3908
+ }
3909
+ return { dirty, unpushed };
3910
+ }
3911
+ async function worktreeDiff(root, path) {
3912
+ const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
3913
+ const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
3914
+ const isMain = wts[0]?.path === path;
3915
+ const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
3916
+ const from = mb || "HEAD";
3917
+ const [numstat, names, log, status] = await Promise.all([
3918
+ gitAsync(path, ["diff", "--numstat", from]),
3919
+ gitAsync(path, ["diff", "--name-status", from]),
3920
+ mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
3921
+ gitAsync(path, ["status", "--porcelain"])
3922
+ ]);
3923
+ const files = parseNumstat(numstat ?? "", names ?? "");
3924
+ for (const line of (status ?? "").split(`
3925
+ `)) {
3926
+ if (line.startsWith("?? "))
3927
+ files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
3928
+ }
3929
+ return {
3930
+ base: mb ?? null,
3931
+ baseRef,
3932
+ files,
3933
+ commits: (log ?? "").split(`
3934
+ `).filter(Boolean),
3935
+ dirty: (status ?? "").trim().length > 0
3936
+ };
3937
+ }
3938
+ async function worktreePatch(path, base, file) {
3939
+ const from = base ?? "HEAD";
3940
+ if (file) {
3941
+ const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
3942
+ if (!tracked) {
3943
+ const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
3944
+ stdout: "pipe",
3945
+ stderr: "ignore"
3946
+ });
3947
+ const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
3948
+ return out;
3949
+ }
3950
+ return await gitAsync(path, ["diff", from, "--", file]) ?? "";
3951
+ }
3952
+ return await gitAsync(path, ["diff", from]) ?? "";
3953
+ }
3954
+ function realpathOr(p) {
3955
+ try {
3956
+ return realpathSync(p);
3957
+ } catch {
3958
+ return p;
3959
+ }
3960
+ }
3961
+
3962
+ // packages/daemon/src/runner.ts
3963
+ import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
3964
+ import { join as join6 } from "path";
3965
+ var PERMISSION_MODES = [
3966
+ "acceptEdits",
3967
+ "auto",
3968
+ "bypassPermissions",
3969
+ "manual",
3970
+ "dontAsk",
3971
+ "plan"
3972
+ ];
3973
+
3974
+ class Runner {
3975
+ store;
3976
+ home;
3977
+ live = new Map;
3978
+ endListeners = new Set;
3979
+ onEnd(fn) {
3980
+ this.endListeners.add(fn);
3981
+ return () => this.endListeners.delete(fn);
3982
+ }
3983
+ constructor(store, home) {
3984
+ this.store = store;
3985
+ this.home = home;
3986
+ }
3987
+ list(projectId) {
3988
+ return [...this.live.values()].map((x) => x.run).filter((r) => !projectId || r.projectId === projectId);
3989
+ }
3990
+ get(idOrTask) {
3991
+ for (const { run: run2 } of this.live.values())
3992
+ if (run2.id === idOrTask || run2.sessionId === idOrTask || run2.task === idOrTask)
3993
+ return run2;
3994
+ return null;
3995
+ }
3996
+ async start(input) {
3997
+ const bin = findBin("claude");
3998
+ if (!bin)
3999
+ return { ok: false, reason: "claude CLI not found \u2014 install Claude Code first" };
4000
+ const project = this.store.project(input.projectId);
4001
+ if (!project)
4002
+ return { ok: false, reason: "unknown project" };
4003
+ if (!input.prompt.trim())
4004
+ return { ok: false, reason: "prompt is required" };
4005
+ if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
4006
+ return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
4007
+ if (this.get(input.task)?.projectId === input.projectId)
4008
+ return {
4009
+ ok: false,
4010
+ reason: `a run on ${input.task} is already live \u2014 stop it or send it input`
4011
+ };
4012
+ const held = this.store.claims(input.projectId).find((c) => c.task === input.task && c.state === "held" && c.owner === input.owner);
4013
+ let worktree2 = held?.worktree ?? "";
4014
+ if (!worktree2) {
4015
+ const c = this.store.claim(input.projectId, input.task, input.owner);
4016
+ if (!c.ok)
4017
+ return { ok: false, reason: c.error };
4018
+ worktree2 = c.worktree;
4019
+ }
4020
+ await this.store.awaitBootstrap(worktree2);
3121
4021
  const sessionId = crypto.randomUUID();
3122
4022
  const id = sessionId.slice(0, 8);
3123
- const logDir = join4(this.home, "logs", project.id);
4023
+ const logDir = join6(this.home, "logs", project.id);
3124
4024
  mkdirSync2(logDir, { recursive: true });
3125
- const log = join4(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
4025
+ const log = join6(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
3126
4026
  const logFd = openSync(log, "a");
3127
4027
  const args = [
3128
4028
  bin,
@@ -3141,13 +4041,22 @@ class Runner {
3141
4041
  args.push("--model", input.model);
3142
4042
  if (input.permissionMode)
3143
4043
  args.push("--permission-mode", input.permissionMode);
3144
- if (input.allowedTools?.length)
3145
- args.push("--allowedTools", ...input.allowedTools);
4044
+ const profile = runProfile(input.profile);
4045
+ if (input.profile && !profile)
4046
+ return {
4047
+ ok: false,
4048
+ reason: `unknown profile ${input.profile} \u2014 one of ${Object.keys(RUN_PROFILES).join(", ")}`
4049
+ };
4050
+ const allowed = [...input.allowedTools ?? [], ...profile?.allowedTools ?? []];
4051
+ if (allowed.length)
4052
+ args.push("--allowedTools", ...allowed);
4053
+ if (profile?.disallowedTools.length)
4054
+ args.push("--disallowedTools", ...profile.disallowedTools);
3146
4055
  if (input.maxTurns)
3147
4056
  args.push("--max-turns", String(input.maxTurns));
3148
- this.store.preregisterSpawnedSession(sessionId, project.id, worktree, input.task);
4057
+ this.store.preregisterSpawnedSession(sessionId, project.id, worktree2, input.task);
3149
4058
  const proc = Bun.spawn(args, {
3150
- cwd: worktree,
4059
+ cwd: worktree2,
3151
4060
  env: { ...process.env, SWARM_RUN_ID: id, SWARM_OWNER: input.owner },
3152
4061
  stdin: "pipe",
3153
4062
  stdout: "pipe",
@@ -3158,11 +4067,12 @@ class Runner {
3158
4067
  sessionId,
3159
4068
  projectId: project.id,
3160
4069
  task: input.task,
3161
- worktree,
4070
+ worktree: worktree2,
3162
4071
  pid: proc.pid,
3163
4072
  owner: input.owner,
3164
4073
  model: input.model ?? null,
3165
4074
  permissionMode: input.permissionMode ?? null,
4075
+ profile: input.profile ?? null,
3166
4076
  prompt: input.prompt,
3167
4077
  log,
3168
4078
  startedAt: new Date().toISOString(),
@@ -3178,7 +4088,7 @@ class Runner {
3178
4088
  sessionId,
3179
4089
  kind: "proc",
3180
4090
  name: `run:${input.task}`,
3181
- cwd: worktree,
4091
+ cwd: worktree2,
3182
4092
  cmd: `claude -p (run ${id})`,
3183
4093
  owner: input.owner,
3184
4094
  log
@@ -3243,6 +4153,13 @@ class Runner {
3243
4153
  });
3244
4154
  this.store.endSpawnedSession(entry.run.sessionId);
3245
4155
  this.live.delete(id);
4156
+ for (const fn of this.endListeners) {
4157
+ try {
4158
+ fn(entry.run);
4159
+ } catch (e) {
4160
+ console.error("swarm run: onEnd listener failed:", e.message);
4161
+ }
4162
+ }
3246
4163
  }
3247
4164
  onLine(run2, line) {
3248
4165
  if (!line.startsWith("{"))
@@ -3366,6 +4283,7 @@ class Runner {
3366
4283
  if (!run2)
3367
4284
  return { ok: false, reason: "no live run" };
3368
4285
  const entry = this.live.get(run2.id);
4286
+ run2.stopped = true;
3369
4287
  try {
3370
4288
  const stdin = entry?.proc.stdin;
3371
4289
  if (stdin && typeof stdin !== "number")
@@ -3382,9 +4300,9 @@ class Runner {
3382
4300
  import { Database } from "bun:sqlite";
3383
4301
  import {
3384
4302
  closeSync,
3385
- existsSync as existsSync4,
3386
- mkdirSync as mkdirSync3,
3387
- openSync as openSync2,
4303
+ existsSync as existsSync5,
4304
+ mkdirSync as mkdirSync4,
4305
+ openSync as openSync3,
3388
4306
  readdirSync,
3389
4307
  readFileSync as readFileSync3,
3390
4308
  readSync,
@@ -3394,145 +4312,54 @@ import {
3394
4312
  writeFileSync as writeFileSync2
3395
4313
  } from "fs";
3396
4314
  import { homedir as homedir3 } from "os";
3397
- import { basename, dirname, join as join6 } from "path";
4315
+ import { basename, dirname as dirname2, join as join8 } from "path";
3398
4316
 
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
- });
4317
+ // packages/daemon/src/bootstrap.ts
4318
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
4319
+ import { dirname, join as join7 } from "path";
4320
+ function runBootstrap(plan, opts) {
4321
+ const logDir = join7(opts.home, "logs", opts.projectId);
4322
+ mkdirSync3(logDir, { recursive: true });
4323
+ const log = join7(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
4324
+ const copied = [];
4325
+ const skipped = [];
4326
+ for (const c of plan.copies) {
4327
+ if (!existsSync4(c.from)) {
4328
+ skipped.push(c.rel);
4329
+ continue;
3443
4330
  }
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);
4331
+ try {
4332
+ mkdirSync3(dirname(c.to), { recursive: true });
4333
+ cpSync(c.from, c.to, { recursive: true, force: true });
4334
+ copied.push(c.rel);
4335
+ } catch (e) {
4336
+ skipped.push(`${c.rel} (${e.message})`);
3532
4337
  }
3533
- unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
3534
4338
  }
3535
- return { dirty, unpushed };
4339
+ const done = (async () => {
4340
+ if (!plan.setup)
4341
+ return { copied, skipped, setup: null };
4342
+ const command = plan.setup;
4343
+ const started = Date.now();
4344
+ let exitCode = -1;
4345
+ try {
4346
+ const fd = openSync2(log, "a");
4347
+ const proc = Bun.spawn(["sh", "-c", command], {
4348
+ cwd: opts.worktree,
4349
+ stdin: "ignore",
4350
+ stdout: fd,
4351
+ stderr: fd,
4352
+ env: { ...process.env, SWARM_WORKTREE: opts.worktree, SWARM_TASK: opts.task }
4353
+ });
4354
+ exitCode = await proc.exited;
4355
+ } catch (e) {
4356
+ exitCode = -1;
4357
+ await Bun.write(log, `swarm: could not start setup: ${e.message}
4358
+ `);
4359
+ }
4360
+ return { copied, skipped, setup: { command, exitCode, durationMs: Date.now() - started } };
4361
+ })();
4362
+ return { log, done };
3536
4363
  }
3537
4364
 
3538
4365
  // packages/daemon/src/task-sources.ts
@@ -3658,6 +4485,12 @@ CREATE TABLE IF NOT EXISTS handoffs (
3658
4485
  files TEXT, verify TEXT, by TEXT, session_id TEXT, created_at TEXT
3659
4486
  );
3660
4487
  CREATE INDEX IF NOT EXISTS handoffs_task ON handoffs(project_id, task, created_at);
4488
+ CREATE TABLE IF NOT EXISTS messages (
4489
+ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, session_id TEXT, task TEXT, kind TEXT,
4490
+ text TEXT, options TEXT, asked_by TEXT, created_at TEXT,
4491
+ answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
4492
+ );
4493
+ CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
3661
4494
  CREATE TABLE IF NOT EXISTS claims (
3662
4495
  project_id TEXT, task TEXT, owner TEXT, worktree TEXT, branch TEXT,
3663
4496
  acquired_at TEXT, expires_at TEXT, released_at TEXT, state TEXT,
@@ -3678,15 +4511,15 @@ class Store {
3678
4511
  gen = 0;
3679
4512
  memo = new Map;
3680
4513
  constructor(home = swarmHome()) {
3681
- mkdirSync3(home, { recursive: true });
4514
+ mkdirSync4(home, { recursive: true });
3682
4515
  this.home = home;
3683
- this.db = new Database(join6(home, "swarm.db"));
4516
+ this.db = new Database(join8(home, "swarm.db"));
3684
4517
  this.loadPricing();
3685
4518
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
3686
4519
  this.db.exec(SCHEMA);
3687
4520
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
3688
4521
  this.ensureColumn("projects", "sort_order", "INTEGER");
3689
- this.migrateProjectsJson(join6(home, "projects.json"));
4522
+ this.migrateProjectsJson(join8(home, "projects.json"));
3690
4523
  this.reconcileMovedProjects();
3691
4524
  this.slimExistingEvents();
3692
4525
  this.retypeNotificationIncidents();
@@ -3734,9 +4567,9 @@ class Store {
3734
4567
  reconcileMovedProjects() {
3735
4568
  const all = this.projects();
3736
4569
  for (const stale of all) {
3737
- if (existsSync4(stale.root))
4570
+ if (existsSync5(stale.root))
3738
4571
  continue;
3739
- const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync4(p.root));
4572
+ const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync5(p.root));
3740
4573
  if (live.length !== 1)
3741
4574
  continue;
3742
4575
  this.mergeProject(stale.id, live[0].id);
@@ -3769,7 +4602,7 @@ class Store {
3769
4602
  }
3770
4603
  }
3771
4604
  migrateProjectsJson(file) {
3772
- if (!existsSync4(file))
4605
+ if (!existsSync5(file))
3773
4606
  return;
3774
4607
  try {
3775
4608
  const list = JSON.parse(readFileSync3(file, "utf8"));
@@ -3784,7 +4617,7 @@ class Store {
3784
4617
  const hit = this.topCache.get(cwd);
3785
4618
  if (hit && Date.now() - hit.t < 1e4)
3786
4619
  return hit.v;
3787
- const v = cwd && existsSync4(cwd) ? gitToplevel(cwd) : null;
4620
+ const v = cwd && existsSync5(cwd) ? gitToplevel(cwd) : null;
3788
4621
  this.topCache.set(cwd, { v, t: Date.now() });
3789
4622
  return v;
3790
4623
  }
@@ -3988,45 +4821,173 @@ class Store {
3988
4821
  snippet: r.snippet
3989
4822
  }));
3990
4823
  }
3991
- sessionContext(cwd) {
3992
- if (!cwd || !existsSync4(cwd))
4824
+ sessionContext(cwd) {
4825
+ if (!cwd || !existsSync5(cwd))
4826
+ return null;
4827
+ const toplevel = this.toplevel(cwd);
4828
+ const project = this.resolveProject(cwd);
4829
+ const lines = [];
4830
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
4831
+ if (held) {
4832
+ const left = Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000));
4833
+ lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
4834
+ const h = this.latestHandoff(held.projectId, held.task);
4835
+ if (h)
4836
+ lines.push(formatHandoff(h));
4837
+ const qc = this.questionContext(held.task, held.projectId);
4838
+ if (qc)
4839
+ lines.push(qc);
4840
+ const required = this.requiredGates(held.projectId);
4841
+ if (required.length) {
4842
+ const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
4843
+ lines.push(`[swarm] gates on ${held.task}: ${st.map((g) => `${g.gate} ${g.verdict ?? "not run"}`).join(", ")} \u2014 record with swarm_gate_record (rubric required)`);
4844
+ }
4845
+ } else if (project) {
4846
+ const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
4847
+ if (active.length)
4848
+ lines.push(`[swarm] ${project.name}: held by others \u2014 ${active.map((c) => `${c.task} (${c.owner})`).join(", ")}. Claim a task (swarm_claim) to get your own worktree.`);
4849
+ }
4850
+ const res = this.resources(project?.id).filter((r) => !r.released);
4851
+ if (res.length)
4852
+ lines.push(`[swarm] resources held: ${res.map((r) => `${r.name}${r.port ? `:${r.port}` : ""} (${r.owner})`).join(", ")} \u2014 their ports are protected; don't kill them`);
4853
+ const modes = this.rulesFor(toplevel);
4854
+ const on = [
4855
+ "shared_tree",
4856
+ "destructive_git",
4857
+ "pattern_kill",
4858
+ "protected_ports",
4859
+ "no_foreign_worktree",
4860
+ "claim_required_to_write"
4861
+ ].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
4862
+ if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
4863
+ lines.push(`[swarm] rules: ${on.join(" ")}`);
4864
+ return lines.length ? lines.join(`
4865
+ `) : null;
4866
+ }
4867
+ rowToQuestion(r) {
4868
+ return {
4869
+ id: r.id,
4870
+ projectId: r.project_id,
4871
+ sessionId: r.session_id ?? null,
4872
+ task: r.task ?? null,
4873
+ text: r.text,
4874
+ options: JSON.parse(r.options || "[]"),
4875
+ askedBy: r.asked_by ?? null,
4876
+ createdAt: r.created_at,
4877
+ answer: r.answer ?? null,
4878
+ answeredBy: r.answered_by ?? null,
4879
+ answeredAt: r.answered_at ?? null,
4880
+ deliveredAt: r.delivered_at ?? null
4881
+ };
4882
+ }
4883
+ questions(opts = {}) {
4884
+ const where = ["kind = 'question'"];
4885
+ const args = [];
4886
+ if (opts.projectId) {
4887
+ where.push("project_id = ?");
4888
+ args.push(opts.projectId);
4889
+ }
4890
+ if (opts.sessionId) {
4891
+ where.push("session_id = ?");
4892
+ args.push(opts.sessionId);
4893
+ }
4894
+ if (opts.open)
4895
+ where.push("answered_at IS NULL");
4896
+ args.push(opts.limit ?? 100);
4897
+ return this.db.query(`SELECT * FROM messages WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...args).map((r) => this.rowToQuestion(r));
4898
+ }
4899
+ question(id) {
4900
+ const r = this.db.query("SELECT * FROM messages WHERE id = ? AND kind = 'question'").get(id);
4901
+ return r ? this.rowToQuestion(r) : null;
4902
+ }
4903
+ ask(projectId, input) {
4904
+ if (!this.project(projectId))
4905
+ return { ok: false, error: "unknown project" };
4906
+ const v = validateQuestion(input.text, input.options);
4907
+ if (!v.ok)
4908
+ return { ok: false, error: v.reason };
4909
+ const sessionId = this.knownSession(input.sessionId ?? null);
4910
+ const task = (input.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(input.cwd, c.worktree))?.task : null) ?? null;
4911
+ const createdAt = new Date().toISOString();
4912
+ const r = this.db.query(`INSERT INTO messages (project_id, session_id, task, kind, text, options, asked_by, created_at)
4913
+ VALUES (?, ?, ?, 'question', ?, ?, ?, ?)`).run(projectId, sessionId, task, v.text, JSON.stringify(v.options), input.askedBy ?? null, createdAt);
4914
+ const q = this.question(Number(r.lastInsertRowid));
4915
+ this.append({
4916
+ ts: createdAt,
4917
+ type: "question.asked",
4918
+ projectId,
4919
+ sessionId,
4920
+ payload: {
4921
+ id: q.id,
4922
+ task,
4923
+ text: v.text,
4924
+ options: v.options,
4925
+ summary: `question #${q.id}: ${v.text.slice(0, 120)}`
4926
+ }
4927
+ });
4928
+ this.touch();
4929
+ return { ok: true, question: q };
4930
+ }
4931
+ answer(id, text, by) {
4932
+ const q = this.question(id);
4933
+ if (!q)
4934
+ return { ok: false, error: `no question #${id}` };
4935
+ if (q.answer !== null)
4936
+ return {
4937
+ ok: false,
4938
+ error: `#${id} was already answered by ${q.answeredBy ?? "someone"}`
4939
+ };
4940
+ const a = typeof text === "string" ? text.trim() : "";
4941
+ if (!a)
4942
+ return { ok: false, error: "an answer is required" };
4943
+ const at = new Date().toISOString();
4944
+ this.db.query("UPDATE messages SET answer = ?, answered_by = ?, answered_at = ? WHERE id = ?").run(a, by, at, id);
4945
+ this.append({
4946
+ ts: at,
4947
+ type: "question.answered",
4948
+ projectId: q.projectId,
4949
+ sessionId: q.sessionId,
4950
+ payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
4951
+ });
4952
+ this.touch();
4953
+ return { ok: true, question: this.question(id) };
4954
+ }
4955
+ inbox(sessionId, opts = {}) {
4956
+ if (!sessionId)
4957
+ return [];
4958
+ const rows = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND session_id = ? AND answered_at IS NOT NULL AND delivered_at IS NULL ORDER BY id").all(sessionId);
4959
+ const qs = rows.map((r) => this.rowToQuestion(r));
4960
+ if (qs.length && !opts.peek)
4961
+ this.db.query(`UPDATE messages SET delivered_at = ? WHERE id IN (${qs.map(() => "?").join(",")})`).run(new Date().toISOString(), ...qs.map((q) => q.id));
4962
+ return qs;
4963
+ }
4964
+ answerContext(sessionId) {
4965
+ return formatAnswers(this.inbox(sessionId));
4966
+ }
4967
+ questionContext(task, projectId) {
4968
+ if (!task)
3993
4969
  return null;
3994
- const toplevel = this.toplevel(cwd);
3995
- const project = this.resolveProject(cwd);
3996
- const lines = [];
3997
- const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
3998
- if (held) {
3999
- const left = Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000));
4000
- lines.push(`[swarm] you hold ${held.task} (${left}m left, renews while you work) in ${held.worktree}`);
4001
- const h = this.latestHandoff(held.projectId, held.task);
4002
- if (h)
4003
- lines.push(formatHandoff(h));
4004
- const required = this.requiredGates(held.projectId);
4005
- if (required.length) {
4006
- const st = gateStatus(this.gateRuns(held.projectId, held.task), required);
4007
- lines.push(`[swarm] gates on ${held.task}: ${st.map((g) => `${g.gate} ${g.verdict ?? "not run"}`).join(", ")} \u2014 record with swarm_gate_record (rubric required)`);
4008
- }
4009
- } else if (project) {
4010
- const active = this.claimRows(project.id).filter((c) => isActive(c, Date.now()));
4011
- if (active.length)
4012
- lines.push(`[swarm] ${project.name}: held by others \u2014 ${active.map((c) => `${c.task} (${c.owner})`).join(", ")}. Claim a task (swarm_claim) to get your own worktree.`);
4013
- }
4014
- const res = this.resources(project?.id).filter((r) => !r.released);
4015
- if (res.length)
4016
- lines.push(`[swarm] resources held: ${res.map((r) => `${r.name}${r.port ? `:${r.port}` : ""} (${r.owner})`).join(", ")} \u2014 their ports are protected; don't kill them`);
4017
- const modes = this.rulesFor(toplevel);
4018
- const on = [
4019
- "shared_tree",
4020
- "destructive_git",
4021
- "pattern_kill",
4022
- "protected_ports",
4023
- "no_foreign_worktree",
4024
- "claim_required_to_write"
4025
- ].filter((k) => modes[k] !== "off").map((k) => `${k}=${modes[k]}`);
4026
- if (on.length && (lines.length || on.some((x) => x.endsWith("=deny"))))
4027
- lines.push(`[swarm] rules: ${on.join(" ")}`);
4028
- return lines.length ? lines.join(`
4970
+ const qs = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND project_id = ? AND task = ? AND (answered_at IS NULL OR delivered_at IS NULL) ORDER BY id").all(projectId, task);
4971
+ const list = qs.map((r) => this.rowToQuestion(r));
4972
+ const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
4973
+ if (list.some((q) => q.answer !== null))
4974
+ this.db.query("UPDATE messages SET delivered_at = ? WHERE kind = 'question' AND project_id = ? AND task = ? AND answered_at IS NOT NULL AND delivered_at IS NULL").run(new Date().toISOString(), projectId, task);
4975
+ return parts.length ? parts.join(`
4029
4976
  `) : null;
4977
+ }
4978
+ contextFor(cwd, sessionId) {
4979
+ const parts = [];
4980
+ const base = this.sessionContext(cwd);
4981
+ if (base)
4982
+ parts.push(base);
4983
+ const answers = this.answerContext(sessionId);
4984
+ if (answers)
4985
+ parts.push(answers);
4986
+ const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
4987
+ if (open && !base?.includes(open))
4988
+ parts.push(open);
4989
+ return { text: parts.length ? parts.join(`
4990
+ `) : null, parts };
4030
4991
  }
4031
4992
  rowToGate(r) {
4032
4993
  return {
@@ -4048,6 +5009,187 @@ class Store {
4048
5009
  gateStatusFor(runs, required) {
4049
5010
  return gateStatus(runs, required);
4050
5011
  }
5012
+ config(projectId) {
5013
+ const p = this.project(projectId);
5014
+ return loadConfig({ repoRoot: p?.root ?? null, home: this.home });
5015
+ }
5016
+ gateDefs(projectId) {
5017
+ const p = this.project(projectId);
5018
+ return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates : null;
5019
+ }
5020
+ gateJobs = new Map;
5021
+ gateBatches = new Map;
5022
+ async awaitGates(projectId, task) {
5023
+ const prefix = `${projectId}:${task}:`;
5024
+ await Promise.all([
5025
+ ...[...this.gateJobs].filter(([k]) => k.startsWith(prefix)).map(([, v]) => v),
5026
+ ...this.gateBatches.get(`${projectId}:${task}`) ?? []
5027
+ ]);
5028
+ }
5029
+ runGate(projectId, task, gate, opts = {}) {
5030
+ const p = this.project(projectId);
5031
+ if (!p)
5032
+ return { ok: false, reason: "unknown project" };
5033
+ const cfg = this.gateDefs(projectId);
5034
+ const def = cfg?.defs[gate];
5035
+ if (!def)
5036
+ return {
5037
+ ok: false,
5038
+ reason: `gate ${gate} has no command \u2014 add [gates.${gate}] cmd = "\u2026" to .swarm.toml, or record it with swarm gate record`
5039
+ };
5040
+ const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
5041
+ const worktree2 = claim?.worktree;
5042
+ if (!worktree2 || !existsSync5(worktree2))
5043
+ return {
5044
+ ok: false,
5045
+ reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
5046
+ };
5047
+ const cwd = def.cwd ? join8(worktree2, def.cwd) : worktree2;
5048
+ if (!existsSync5(cwd))
5049
+ return { ok: false, reason: `gate cwd ${cwd} does not exist` };
5050
+ const key = `${projectId}:${task}:${gate}`;
5051
+ if (this.gateJobs.has(key))
5052
+ return { ok: false, reason: `${gate} is already running on ${task}` };
5053
+ const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
5054
+ const logDir = join8(this.home, "logs", projectId);
5055
+ mkdirSync4(logDir, { recursive: true });
5056
+ const log = join8(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
5057
+ writeFileSync2(log, `$ ${def.cmd}
5058
+ # cwd ${cwd} \xB7 ${new Date().toISOString()}
5059
+ `);
5060
+ const fd = openSync3(log, "a");
5061
+ let proc;
5062
+ try {
5063
+ proc = Bun.spawn(["sh", "-c", def.cmd], {
5064
+ cwd,
5065
+ stdin: "ignore",
5066
+ stdout: fd,
5067
+ stderr: fd,
5068
+ env: {
5069
+ ...process.env,
5070
+ SWARM_WORKTREE: worktree2,
5071
+ SWARM_TASK: task,
5072
+ SWARM_GATE: gate,
5073
+ CI: process.env.CI ?? "1"
5074
+ }
5075
+ });
5076
+ } catch (e) {
5077
+ closeSync(fd);
5078
+ const run2 = this.recordGate(projectId, {
5079
+ ...executedGateInput(task, gate, def.cmd, {
5080
+ exitCode: null,
5081
+ durationMs: 0,
5082
+ output: e.message
5083
+ }),
5084
+ sessionId: opts.sessionId ?? null
5085
+ });
5086
+ return { ok: true, pid: 0, log, done: Promise.resolve(run2.ok ? run2.run : null) };
5087
+ }
5088
+ const started = Date.now();
5089
+ const reg = this.registerProcess({
5090
+ pid: proc.pid,
5091
+ projectId,
5092
+ sessionId: opts.sessionId ?? null,
5093
+ kind: "gate",
5094
+ name: `gate:${task}:${gate}`,
5095
+ cwd,
5096
+ cmd: def.cmd,
5097
+ owner: opts.owner ?? "daemon",
5098
+ log
5099
+ });
5100
+ let timedOut = false;
5101
+ const timer = setTimeout(() => {
5102
+ timedOut = true;
5103
+ try {
5104
+ proc.kill("SIGTERM");
5105
+ setTimeout(() => {
5106
+ try {
5107
+ proc.kill("SIGKILL");
5108
+ } catch {}
5109
+ }, 5000).unref();
5110
+ } catch {}
5111
+ }, def.timeout * 1000);
5112
+ const done = proc.exited.then((code) => {
5113
+ clearTimeout(timer);
5114
+ closeSync(fd);
5115
+ let output = "";
5116
+ try {
5117
+ output = readFileSync3(log, "utf8");
5118
+ } catch {}
5119
+ const input = executedGateInput(task, gate, def.cmd, {
5120
+ exitCode: timedOut ? null : code,
5121
+ timedOut,
5122
+ durationMs: Date.now() - started,
5123
+ output
5124
+ });
5125
+ const run2 = this.recordGate(projectId, { ...input, sessionId: opts.sessionId ?? null });
5126
+ if (reg.ok)
5127
+ this.processes(projectId);
5128
+ return run2.ok ? run2.run : null;
5129
+ }).finally(() => {
5130
+ this.gateJobs.delete(key);
5131
+ this.touch();
5132
+ });
5133
+ this.gateJobs.set(key, done);
5134
+ return { ok: true, pid: proc.pid, log, done };
5135
+ }
5136
+ async runGates(projectId, task, gates2, opts = {}) {
5137
+ const cfg = this.gateDefs(projectId);
5138
+ const names = gates2?.length ? gates2 : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
5139
+ const key = `${projectId}:${task}`;
5140
+ const batch = (async () => {
5141
+ const started = [];
5142
+ const skipped = [];
5143
+ const runs = [];
5144
+ for (const g of names) {
5145
+ const r = this.runGate(projectId, task, g, opts);
5146
+ if (!r.ok) {
5147
+ skipped.push({ gate: g, reason: r.reason });
5148
+ continue;
5149
+ }
5150
+ started.push(g);
5151
+ const run2 = await r.done;
5152
+ if (run2)
5153
+ runs.push(run2);
5154
+ }
5155
+ return { started, skipped, runs };
5156
+ })();
5157
+ const set = this.gateBatches.get(key) ?? new Set;
5158
+ set.add(batch);
5159
+ this.gateBatches.set(key, set);
5160
+ try {
5161
+ return await batch;
5162
+ } finally {
5163
+ set.delete(batch);
5164
+ if (!set.size)
5165
+ this.gateBatches.delete(key);
5166
+ }
5167
+ }
5168
+ autoGateAt = new Map;
5169
+ autoGate(event, sessionId, cwd) {
5170
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
5171
+ if (!held)
5172
+ return;
5173
+ const cfg = this.gateDefs(held.projectId);
5174
+ if (!cfg || cfg.auto === "off")
5175
+ return;
5176
+ if (cfg.auto === "session-end" && event !== "SessionEnd")
5177
+ return;
5178
+ if (!cfg.required.some((g) => cfg.defs[g]))
5179
+ return;
5180
+ const key = `${held.projectId}:${held.task}`;
5181
+ const now = Date.now();
5182
+ if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
5183
+ return;
5184
+ this.autoGateAt.set(key, now);
5185
+ this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => {
5186
+ if (!r.runs.length)
5187
+ return;
5188
+ const line = r.runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
5189
+ this.db.query("UPDATE handoffs SET verify = ? WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(`auto-gates: ${line}`, held.projectId, held.task, sessionId);
5190
+ this.touch();
5191
+ });
5192
+ }
4051
5193
  requiredGates(projectId) {
4052
5194
  const p = this.project(projectId);
4053
5195
  return p ? loadConfig({ repoRoot: p.root, home: this.home }).gates.required : [];
@@ -4113,8 +5255,8 @@ class Store {
4113
5255
  hit = { tasks: e.tasks };
4114
5256
  error = e.error;
4115
5257
  } else {
4116
- const path = join6(p.root, source);
4117
- if (!existsSync4(path))
5258
+ const path = join8(p.root, source);
5259
+ if (!existsSync5(path))
4118
5260
  return { source, required: this.requiredGates(projectId), tasks: [] };
4119
5261
  const mtime = statSync(path).mtimeMs;
4120
5262
  let md = this.taskCache.get(projectId);
@@ -4156,6 +5298,18 @@ class Store {
4156
5298
  return rules2;
4157
5299
  }
4158
5300
  evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
5301
+ if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync5(cwd)) {
5302
+ const project = this.resolveProject(cwd);
5303
+ const b = this.budgetFor(project.id);
5304
+ if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
5305
+ const d = {
5306
+ action: "ask",
5307
+ rule: "budget",
5308
+ reason: `${budgetMessage(b.status, project.name)} \u2014 [budget] on_exceed = "ask": confirm each change, or raise the ceiling in .swarm.toml`
5309
+ };
5310
+ return { decision: d, display: input.command ?? input.file_path ?? tool };
5311
+ }
5312
+ }
4159
5313
  const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
4160
5314
  const cmd = tool === "Bash" ? input.command : undefined;
4161
5315
  const current = { id: sessionId, cwd, toplevel: this.toplevel(cwd) };
@@ -4235,7 +5389,7 @@ class Store {
4235
5389
  return this.openIncident(d, cwd, id, cmd);
4236
5390
  }
4237
5391
  openIncident(d, cwd, sessionId, command) {
4238
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
5392
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
4239
5393
  this.append({
4240
5394
  ts: new Date().toISOString(),
4241
5395
  type: "incident.opened",
@@ -4286,7 +5440,7 @@ class Store {
4286
5440
  }
4287
5441
  }
4288
5442
  const report = dryRunRules(calls, modes, {
4289
- toplevel: (cwd) => cwd && existsSync4(cwd) ? this.toplevel(cwd) : null,
5443
+ toplevel: (cwd) => cwd && existsSync5(cwd) ? this.toplevel(cwd) : null,
4290
5444
  claims: this.heldWorktrees()
4291
5445
  });
4292
5446
  return { ...report, modes };
@@ -4301,8 +5455,8 @@ class Store {
4301
5455
  loadPricing() {
4302
5456
  this.prices = { ...PRICES };
4303
5457
  for (const f of ["pricing.litellm.json", "pricing.json"]) {
4304
- const p = join6(this.home, f);
4305
- if (!existsSync4(p))
5458
+ const p = join8(this.home, f);
5459
+ if (!existsSync5(p))
4306
5460
  continue;
4307
5461
  try {
4308
5462
  const j = JSON.parse(readFileSync3(p, "utf8"));
@@ -4317,7 +5471,7 @@ class Store {
4317
5471
  throw new Error(`pricing fetch ${r.status}`);
4318
5472
  const j = await r.json();
4319
5473
  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));
5474
+ writeFileSync2(join8(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
4321
5475
  this.loadPricing();
4322
5476
  this.reprice();
4323
5477
  }
@@ -4461,11 +5615,13 @@ class Store {
4461
5615
  if (typeof raw2.cwd === "string")
4462
5616
  this.autoRenewFor(typeof raw2.session_id === "string" ? raw2.session_id : null, raw2.cwd);
4463
5617
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
4464
- const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
5618
+ const project = existsSync5(cwd) ? this.resolveProject(cwd) : null;
4465
5619
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
4466
5620
  if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
4467
- if (existsSync4(cwd))
5621
+ if (existsSync5(cwd)) {
4468
5622
  this.autoHandoff(e.sessionId, cwd);
5623
+ this.autoGate(event, e.sessionId, cwd);
5624
+ }
4469
5625
  this.rememberSession(e.sessionId);
4470
5626
  }
4471
5627
  if (e.sessionId && typeof raw2.transcript_path === "string") {
@@ -4488,6 +5644,15 @@ class Store {
4488
5644
  "claim.renewed",
4489
5645
  "claim.released",
4490
5646
  "claim.orphaned",
5647
+ "worktree.bootstrapped",
5648
+ "worktree.created",
5649
+ "worktree.removed",
5650
+ "pr.opened",
5651
+ "question.asked",
5652
+ "question.answered",
5653
+ "dispatch.queued",
5654
+ "dispatch.started",
5655
+ "dispatch.finished",
4491
5656
  "gate.recorded",
4492
5657
  "handoff.recorded",
4493
5658
  "incident.opened",
@@ -4499,7 +5664,7 @@ class Store {
4499
5664
  return;
4500
5665
  const p = e.payload;
4501
5666
  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;
5667
+ const branch = p.cwd && existsSync5(p.cwd) ? currentBranch(p.cwd) : null;
4503
5668
  if (!row) {
4504
5669
  this.db.query("INSERT INTO sessions (id, project_id, kind, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, ?, 'active')").run(e.sessionId, e.projectId, p.cwd ?? "", branch, e.ts, e.ts, p.summary ?? e.type, e.type);
4505
5670
  }
@@ -4519,7 +5684,7 @@ class Store {
4519
5684
  const size = statSync(path).size;
4520
5685
  if (size <= offset)
4521
5686
  return null;
4522
- const fd = openSync2(path, "r");
5687
+ const fd = openSync3(path, "r");
4523
5688
  const buf = Buffer.alloc(size - offset);
4524
5689
  readSync(fd, buf, 0, buf.length, offset);
4525
5690
  closeSync(fd);
@@ -4569,12 +5734,12 @@ class Store {
4569
5734
  }
4570
5735
  tailSession(sessionId) {
4571
5736
  const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
4572
- if (!s?.transcript_path || !existsSync4(s.transcript_path))
5737
+ if (!s?.transcript_path || !existsSync5(s.transcript_path))
4573
5738
  return 0;
4574
5739
  let n = this.tailFile(s.transcript_path, sessionId, null);
4575
- const subDir = join6(dirname(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
5740
+ const subDir = join8(dirname2(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
4576
5741
  for (const f of this.subagentFiles(subDir)) {
4577
- n += this.tailFile(join6(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
5742
+ n += this.tailFile(join8(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
4578
5743
  }
4579
5744
  return n;
4580
5745
  }
@@ -4606,7 +5771,7 @@ class Store {
4606
5771
  return n;
4607
5772
  }
4608
5773
  codexRoot() {
4609
- return process.env.SWARM_CODEX_DIR ?? join6(homedir3(), ".codex", "sessions");
5774
+ return process.env.SWARM_CODEX_DIR ?? join8(homedir3(), ".codex", "sessions");
4610
5775
  }
4611
5776
  codexRolloutFiles(sinceMs) {
4612
5777
  const root = this.codexRoot();
@@ -4621,18 +5786,18 @@ class Store {
4621
5786
  for (const y of ls(root)) {
4622
5787
  if (!/^\d{4}$/.test(y))
4623
5788
  continue;
4624
- for (const m of ls(join6(root, y))) {
5789
+ for (const m of ls(join8(root, y))) {
4625
5790
  if (!/^\d\d$/.test(m))
4626
5791
  continue;
4627
- for (const day of ls(join6(root, y, m))) {
5792
+ for (const day of ls(join8(root, y, m))) {
4628
5793
  if (!/^\d\d$/.test(day))
4629
5794
  continue;
4630
5795
  if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
4631
5796
  continue;
4632
- const dir = join6(root, y, m, day);
5797
+ const dir = join8(root, y, m, day);
4633
5798
  for (const f of ls(dir)) {
4634
5799
  if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
4635
- out.push(join6(dir, f));
5800
+ out.push(join8(dir, f));
4636
5801
  }
4637
5802
  }
4638
5803
  }
@@ -4640,7 +5805,7 @@ class Store {
4640
5805
  return out;
4641
5806
  }
4642
5807
  tailCodex(windowMs = 3 * 24 * 60 * 60000) {
4643
- if (!existsSync4(this.codexRoot()))
5808
+ if (!existsSync5(this.codexRoot()))
4644
5809
  return 0;
4645
5810
  let n = 0;
4646
5811
  for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
@@ -4649,12 +5814,12 @@ class Store {
4649
5814
  return n;
4650
5815
  }
4651
5816
  grokRoot() {
4652
- return process.env.SWARM_GROK_DIR ?? join6(homedir3(), ".grok", "sessions");
5817
+ return process.env.SWARM_GROK_DIR ?? join8(homedir3(), ".grok", "sessions");
4653
5818
  }
4654
5819
  grokSummary = new Map;
4655
5820
  tailGrok(windowMs = 3 * 24 * 60 * 60000) {
4656
5821
  const root = this.grokRoot();
4657
- if (!existsSync4(root))
5822
+ if (!existsSync5(root))
4658
5823
  return 0;
4659
5824
  const since = Date.now() - windowMs;
4660
5825
  const ls = (p) => {
@@ -4674,10 +5839,10 @@ class Store {
4674
5839
  } catch {
4675
5840
  cwd = enc;
4676
5841
  }
4677
- const cwdDir = join6(root, enc);
5842
+ const cwdDir = join8(root, enc);
4678
5843
  for (const sid of ls(cwdDir)) {
4679
- const path = join6(cwdDir, sid, "updates.jsonl");
4680
- if (!existsSync4(path))
5844
+ const path = join8(cwdDir, sid, "updates.jsonl");
5845
+ if (!existsSync5(path))
4681
5846
  continue;
4682
5847
  try {
4683
5848
  if (statSync(path).mtimeMs < since)
@@ -4685,7 +5850,7 @@ class Store {
4685
5850
  } catch {
4686
5851
  continue;
4687
5852
  }
4688
- const sumPath = join6(cwdDir, sid, "summary.json");
5853
+ const sumPath = join8(cwdDir, sid, "summary.json");
4689
5854
  let title;
4690
5855
  let fresh = false;
4691
5856
  try {
@@ -4736,9 +5901,9 @@ class Store {
4736
5901
  ensureAgentSession(sid, agent, cwd, mtime) {
4737
5902
  if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
4738
5903
  return;
4739
- const project = cwd && existsSync4(cwd) ? this.resolveProject(cwd) : null;
5904
+ const project = cwd && existsSync5(cwd) ? this.resolveProject(cwd) : null;
4740
5905
  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);
5906
+ this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd && existsSync5(cwd) ? currentBranch(cwd) : null, ts, ts);
4742
5907
  }
4743
5908
  claimRows(projectId) {
4744
5909
  return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
@@ -4772,7 +5937,7 @@ class Store {
4772
5937
  worktreePath(projectId, task) {
4773
5938
  const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
4774
5939
  const p = this.project(projectId);
4775
- return join6(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
5940
+ return join8(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
4776
5941
  }
4777
5942
  claim(projectId, task, owner, baseRef = "HEAD") {
4778
5943
  const p = this.project(projectId);
@@ -4783,11 +5948,11 @@ class Store {
4783
5948
  if (!decision.ok)
4784
5949
  return { ok: false, error: claimRefusalMessage(decision, task) };
4785
5950
  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);
5951
+ const worktree2 = this.worktreePath(projectId, task);
5952
+ if (existsSync5(worktree2))
5953
+ return { ok: false, error: `${worktree2} already exists; release ${task} first` };
5954
+ mkdirSync4(dirname2(worktree2), { recursive: true });
5955
+ const created = worktreeAdd(p.root, worktree2, branch, baseRef);
4791
5956
  if (!created)
4792
5957
  return { ok: false, error: `git worktree add failed for ${task}` };
4793
5958
  this.invalidateWorktrees(projectId);
@@ -4804,7 +5969,54 @@ class Store {
4804
5969
  sessionId: null,
4805
5970
  payload: { task, owner, worktree: created, branch, summary: `claim ${task} by ${owner}` }
4806
5971
  });
4807
- return { ok: true, task, owner, worktree: created, branch, expiresAt };
5972
+ const bootstrap = this.bootstrapWorktree(projectId, task, p.root, created);
5973
+ return { ok: true, task, owner, worktree: created, branch, expiresAt, bootstrap };
5974
+ }
5975
+ bootstraps = new Map;
5976
+ bootstrapWorktree(projectId, task, repoRoot, worktree2) {
5977
+ const plan = planBootstrap(loadConfig({ repoRoot, home: this.home }), repoRoot, worktree2);
5978
+ if (!needsBootstrap(plan))
5979
+ return null;
5980
+ const job = runBootstrap(plan, { worktree: worktree2, home: this.home, projectId, task });
5981
+ const done = job.done.then((o) => {
5982
+ this.bootstraps.delete(worktree2);
5983
+ const ts = new Date().toISOString();
5984
+ const ok = !o.setup || o.setup.exitCode === 0;
5985
+ this.append({
5986
+ ts,
5987
+ type: "worktree.bootstrapped",
5988
+ projectId,
5989
+ sessionId: null,
5990
+ payload: {
5991
+ task,
5992
+ worktree: worktree2,
5993
+ ok,
5994
+ log: job.log,
5995
+ ...o,
5996
+ summary: `bootstrap ${task}: ${summarizeBootstrap(o)}`
5997
+ }
5998
+ });
5999
+ if (!ok)
6000
+ this.append({
6001
+ ts,
6002
+ type: "incident.opened",
6003
+ projectId,
6004
+ sessionId: null,
6005
+ payload: {
6006
+ rule: "bootstrap_failed",
6007
+ action: "failed",
6008
+ command: o.setup?.command ?? "",
6009
+ reason: `worktree setup for ${task} exited ${o.setup?.exitCode} \u2014 see ${job.log}`
6010
+ }
6011
+ });
6012
+ this.touch();
6013
+ return o;
6014
+ });
6015
+ this.bootstraps.set(worktree2, done);
6016
+ return job.log;
6017
+ }
6018
+ awaitBootstrap(worktree2) {
6019
+ return this.bootstraps.get(worktree2) ?? Promise.resolve();
4808
6020
  }
4809
6021
  autoRenewAt = new Map;
4810
6022
  autoRenewFor(sessionId, cwd) {
@@ -4847,7 +6059,7 @@ class Store {
4847
6059
  for (const c of this.claimRows(p.id)) {
4848
6060
  if (c.state !== "held" || isActive(c, now))
4849
6061
  continue;
4850
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6062
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4851
6063
  const work = exists ? heldWork(c.worktree) : null;
4852
6064
  if (reapAction(c, now, exists, work) !== "keep-orphaned")
4853
6065
  continue;
@@ -4902,18 +6114,18 @@ class Store {
4902
6114
  const row = this.db.query("SELECT * FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
4903
6115
  if (!row)
4904
6116
  return { ok: false, error: `no claim on ${task}` };
4905
- const worktree = row.worktree ?? "";
4906
- if (worktree && existsSync4(worktree)) {
4907
- const work = heldWork(worktree);
6117
+ const worktree2 = row.worktree ?? "";
6118
+ if (worktree2 && existsSync5(worktree2)) {
6119
+ const work = heldWork(worktree2);
4908
6120
  const can = canRelease(work, force);
4909
6121
  if (!can.ok)
4910
6122
  return {
4911
6123
  ok: false,
4912
- error: releaseRefusalMessage(can, worktree),
6124
+ error: releaseRefusalMessage(can, worktree2),
4913
6125
  refused: can.reason
4914
6126
  };
4915
- if (p && !worktreeRemove(p.root, worktree, force))
4916
- return { ok: false, error: `git worktree remove failed for ${worktree}` };
6127
+ if (p && !worktreeRemove(p.root, worktree2, force))
6128
+ return { ok: false, error: `git worktree remove failed for ${worktree2}` };
4917
6129
  this.invalidateWorktrees(projectId);
4918
6130
  }
4919
6131
  const releasedAt = new Date().toISOString();
@@ -4938,7 +6150,7 @@ class Store {
4938
6150
  continue;
4939
6151
  if (isActive({ ...c, state: "held" }, now))
4940
6152
  continue;
4941
- const exists = c.worktree ? existsSync4(c.worktree) : false;
6153
+ const exists = c.worktree ? existsSync5(c.worktree) : false;
4942
6154
  const work = exists ? heldWork(c.worktree) : null;
4943
6155
  const action = reapAction({ ...c, state: "held" }, now, exists, work);
4944
6156
  if (action === "not-expired")
@@ -5029,6 +6241,141 @@ class Store {
5029
6241
  this.wtInflight.set(projectId, run2);
5030
6242
  return run2;
5031
6243
  }
6244
+ createWorktree(projectId, name, baseRef = "HEAD", branch) {
6245
+ const p = this.project(projectId);
6246
+ if (!p)
6247
+ return { ok: false, error: "unknown project" };
6248
+ const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
6249
+ if (!slug || slug === "." || slug === "..")
6250
+ return { ok: false, error: "bad worktree name" };
6251
+ const path = this.worktreePath(projectId, slug);
6252
+ if (existsSync5(path))
6253
+ return { ok: false, error: `${path} already exists` };
6254
+ mkdirSync4(dirname2(path), { recursive: true });
6255
+ const br = branch?.trim() || `wt/${slug}`;
6256
+ const created = worktreeAdd(p.root, path, br, baseRef);
6257
+ if (!created)
6258
+ return { ok: false, error: `git worktree add failed for ${name}` };
6259
+ this.invalidateWorktrees(projectId);
6260
+ this.append({
6261
+ ts: new Date().toISOString(),
6262
+ type: "worktree.created",
6263
+ projectId,
6264
+ sessionId: null,
6265
+ payload: { name: slug, worktree: created, branch: br, summary: `worktree ${slug} created` }
6266
+ });
6267
+ const bootstrap = this.bootstrapWorktree(projectId, slug, p.root, created);
6268
+ return { ok: true, name: slug, worktree: created, branch: br, bootstrap };
6269
+ }
6270
+ findWorktree(projectId, ref) {
6271
+ const wts = this.wtCache.get(projectId)?.v ?? [];
6272
+ const abs = ref.startsWith("/") ? ref.replace(/\/+$/, "") : null;
6273
+ return wts.find((w) => w.path === abs) ?? wts.find((w) => !w.main && basename(w.path) === ref) ?? wts.find((w) => w.branch === ref) ?? null;
6274
+ }
6275
+ async removeWorktree(projectId, ref, force = false) {
6276
+ const p = this.project(projectId);
6277
+ if (!p)
6278
+ return { ok: false, error: "unknown project" };
6279
+ await this.refreshWorktrees(projectId);
6280
+ const w = this.findWorktree(projectId, ref);
6281
+ if (!w)
6282
+ return { ok: false, error: `no worktree ${ref} in ${p.name}` };
6283
+ const held = this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path);
6284
+ const can = canRemoveWorktree(w, held?.task ?? null, force);
6285
+ if (!can.ok)
6286
+ return {
6287
+ ok: false,
6288
+ error: removeRefusalMessage(can.reason, w.path, held?.task),
6289
+ refused: can.reason
6290
+ };
6291
+ if (!worktreeRemove(p.root, w.path, force))
6292
+ return { ok: false, error: `git worktree remove failed for ${w.path}` };
6293
+ this.invalidateWorktrees(projectId);
6294
+ this.append({
6295
+ ts: new Date().toISOString(),
6296
+ type: "worktree.removed",
6297
+ projectId,
6298
+ sessionId: null,
6299
+ payload: {
6300
+ worktree: w.path,
6301
+ branch: w.branch,
6302
+ force,
6303
+ summary: `worktree ${basename(w.path)} removed`
6304
+ }
6305
+ });
6306
+ return { ok: true, worktree: w.path };
6307
+ }
6308
+ async gcWorktrees(projectId, apply = false) {
6309
+ await this.refreshWorktrees(projectId);
6310
+ const plan = planGc(this.wtCache.get(projectId)?.v ?? [], this.claims(projectId));
6311
+ const removed = [];
6312
+ if (apply)
6313
+ for (const c of plan) {
6314
+ if (!c.removable)
6315
+ continue;
6316
+ const r = await this.removeWorktree(projectId, c.path, false);
6317
+ if (r.ok)
6318
+ removed.push(c.path);
6319
+ }
6320
+ return { candidates: plan, removed };
6321
+ }
6322
+ openWorktree(projectId, ref) {
6323
+ const p = this.project(projectId);
6324
+ if (!p)
6325
+ return { ok: false, error: "unknown project" };
6326
+ const w = this.findWorktree(projectId, ref);
6327
+ if (!w)
6328
+ return { ok: false, error: `no worktree ${ref}` };
6329
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).worktree.open;
6330
+ const cmd = cfg ? ["sh", "-c", cfg.replace(/\{path\}/g, `'${w.path.replace(/'/g, "'\\''")}'`)] : [
6331
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open",
6332
+ w.path
6333
+ ];
6334
+ try {
6335
+ Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
6336
+ return { ok: true, worktree: w.path, command: cmd.join(" ") };
6337
+ } catch (e) {
6338
+ return { ok: false, error: e.message };
6339
+ }
6340
+ }
6341
+ async prDraftFor(projectId, ref) {
6342
+ const p = this.project(projectId);
6343
+ if (!p)
6344
+ return { ok: false, error: "unknown project" };
6345
+ await this.refreshWorktrees(projectId);
6346
+ const claim = this.claims(projectId).find((c) => c.task === ref && c.state === "held");
6347
+ const w = this.findWorktree(projectId, claim?.worktree ?? ref);
6348
+ if (!w)
6349
+ return { ok: false, error: `no worktree or held task ${ref}` };
6350
+ const task = claim?.task ?? this.claims(projectId).find((c) => c.state === "held" && c.worktree === w.path)?.task ?? (w.branch?.startsWith("task/") ? w.branch.slice(5) : null) ?? basename(w.path);
6351
+ const taskRow = this.tasks(projectId)?.tasks.find((t) => t.id === task) ?? null;
6352
+ const handoff = this.latestHandoff(projectId, task);
6353
+ const required = this.requiredGates(projectId);
6354
+ const gates2 = required.length ? this.gateStatusFor(this.gateRuns(projectId, task), required).map((g) => ({
6355
+ gate: g.gate,
6356
+ verdict: g.verdict
6357
+ })) : [];
6358
+ const diff = await worktreeDiff(p.root, w.path);
6359
+ const d = prDraft({
6360
+ task,
6361
+ title: taskRow?.title ?? null,
6362
+ handoff,
6363
+ gates: gates2,
6364
+ files: diff.files,
6365
+ commits: diff.commits
6366
+ });
6367
+ return { ok: true, task, worktree: w, ...d, diff };
6368
+ }
6369
+ recordPrOpened(projectId, task, worktree2, url) {
6370
+ this.append({
6371
+ ts: new Date().toISOString(),
6372
+ type: "pr.opened",
6373
+ projectId,
6374
+ sessionId: null,
6375
+ payload: { task, worktree: worktree2, url, summary: `PR opened for ${task}: ${url}` }
6376
+ });
6377
+ this.touch();
6378
+ }
5032
6379
  invalidateWorktrees(projectId) {
5033
6380
  if (projectId)
5034
6381
  this.wtCache.delete(projectId);
@@ -5098,6 +6445,56 @@ class Store {
5098
6445
  };
5099
6446
  });
5100
6447
  }
6448
+ projectSpend(projectId) {
6449
+ const dayStart = new Date;
6450
+ dayStart.setHours(0, 0, 0, 0);
6451
+ const weekStart = new Date(Date.now() - 7 * 86400000).toISOString();
6452
+ const q = (since) => this.db.query("SELECT COALESCE(SUM(t.cost_usd), 0) AS cost FROM turns t JOIN sessions s ON s.id = t.session_id WHERE s.project_id = ? AND t.ts >= ?").get(projectId, since).cost;
6453
+ return { today: q(dayStart.toISOString()), week: q(weekStart) };
6454
+ }
6455
+ budgetFor(projectId) {
6456
+ const cfg = this.config(projectId).budget;
6457
+ if (!cfg.daily && !cfg.weekly)
6458
+ return null;
6459
+ return { status: budgetStatus(this.projectSpend(projectId), cfg), config: cfg };
6460
+ }
6461
+ budgetNotified = new Map;
6462
+ budgetListeners = new Set;
6463
+ onBudgetStop(fn) {
6464
+ this.budgetListeners.add(fn);
6465
+ }
6466
+ checkBudgets() {
6467
+ const day = new Date().toDateString();
6468
+ const out = [];
6469
+ for (const p of this.projects()) {
6470
+ const b = this.budgetFor(p.id);
6471
+ if (!b || b.status.level === "ok")
6472
+ continue;
6473
+ out.push({ projectId: p.id, status: b.status });
6474
+ const key = `${day}:${b.status.level}`;
6475
+ if (this.budgetNotified.get(p.id) === key)
6476
+ continue;
6477
+ this.budgetNotified.set(p.id, key);
6478
+ const msg = budgetMessage(b.status, p.name);
6479
+ this.append({
6480
+ ts: new Date().toISOString(),
6481
+ type: "incident.opened",
6482
+ projectId: p.id,
6483
+ sessionId: null,
6484
+ payload: {
6485
+ rule: "budget",
6486
+ action: b.status.level === "exceeded" ? b.config.on_exceed : "warn",
6487
+ command: `${b.status.kind} budget`,
6488
+ reason: b.status.level === "exceeded" ? `${msg}. ${b.config.on_exceed === "stop" ? "Spawned runs were stopped and the dispatch queue cleared." : b.config.on_exceed === "ask" ? "Every Bash/Edit/Write now asks first." : "Raise [budget] in .swarm.toml or wait for the next day."}` : `${msg} \u2014 approaching the ceiling`
6489
+ }
6490
+ });
6491
+ if (b.status.level === "exceeded" && b.config.on_exceed === "stop")
6492
+ for (const fn of this.budgetListeners)
6493
+ fn(p.id, b.status);
6494
+ this.touch();
6495
+ }
6496
+ return out;
6497
+ }
5101
6498
  spend() {
5102
6499
  const dayStart = new Date;
5103
6500
  dayStart.setHours(0, 0, 0, 0);
@@ -5552,6 +6949,7 @@ class Store {
5552
6949
  processes: this.memoised("processes", 5000, () => this.processes()),
5553
6950
  incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
5554
6951
  openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
6952
+ questions: this.questions({ open: true, limit: 50 }),
5555
6953
  resources: this.resources(),
5556
6954
  seq: this.seq()
5557
6955
  };
@@ -5627,13 +7025,13 @@ function rowToEvent(r) {
5627
7025
  }
5628
7026
 
5629
7027
  // packages/daemon/src/app.ts
5630
- var VERSION = "0.6.0";
7028
+ var VERSION = "0.7.0";
5631
7029
  var WEB_DIR = (() => {
5632
7030
  if (process.env.SWARM_WEB_DIR)
5633
7031
  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");
7032
+ const here = dirname3(fileURLToPath(import.meta.url));
7033
+ const dev = join9(here, "../../web/public");
7034
+ return existsSync6(join9(dev, "index.html")) ? dev : join9(here, "../web");
5637
7035
  })();
5638
7036
  var REPLAY_TAIL = 200;
5639
7037
  var wireCache = new WeakMap;
@@ -5649,6 +7047,12 @@ function createApp(store = new Store) {
5649
7047
  const app = new Hono2;
5650
7048
  const forge2 = new ForgeService(store);
5651
7049
  const runner = new Runner(store, store.home);
7050
+ const dispatcher = new Dispatcher(store, runner, forge2);
7051
+ store.onBudgetStop((projectId) => {
7052
+ dispatcher.clear(projectId);
7053
+ for (const run2 of runner.list(projectId))
7054
+ runner.stop(run2.id);
7055
+ });
5652
7056
  app.get("/v1/health", (c) => c.json({ ok: true, version: VERSION }));
5653
7057
  app.get("/v1/projects", (c) => c.json(store.snapshot().projects));
5654
7058
  app.post("/v1/projects", async (c) => {
@@ -5677,13 +7081,13 @@ function createApp(store = new Store) {
5677
7081
  const q = c.req.query("path");
5678
7082
  let dir;
5679
7083
  try {
5680
- dir = realpathSync3(q && existsSync5(q) ? q : homedir4());
7084
+ dir = realpathSync3(q && existsSync6(q) ? q : homedir4());
5681
7085
  } catch {
5682
7086
  dir = homedir4();
5683
7087
  }
5684
7088
  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);
7089
+ const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync6(join9(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
7090
+ const parent = dirname3(dir);
5687
7091
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
5688
7092
  } catch (e) {
5689
7093
  return c.json({ error: e.message, path: dir }, 400);
@@ -5793,10 +7197,84 @@ function createApp(store = new Store) {
5793
7197
  model: b.model,
5794
7198
  permissionMode: b.permissionMode,
5795
7199
  allowedTools: b.allowedTools,
5796
- maxTurns: b.maxTurns
7200
+ maxTurns: b.maxTurns,
7201
+ profile: b.profile
5797
7202
  });
5798
7203
  return r.ok ? c.json(r, 201) : c.json({ ok: false, error: r.reason }, 409);
5799
7204
  });
7205
+ app.get("/v1/budget", (c) => {
7206
+ const project = c.req.query("project");
7207
+ if (!project)
7208
+ return c.json({ error: "project required" }, 400);
7209
+ return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
7210
+ });
7211
+ app.get("/v1/context", (c) => {
7212
+ const cwd = c.req.query("cwd");
7213
+ if (!cwd)
7214
+ return c.json({ error: "cwd required" }, 400);
7215
+ return c.json(store.contextFor(cwd, c.req.query("session") || null));
7216
+ });
7217
+ app.get("/v1/questions", (c) => c.json(store.questions({
7218
+ projectId: c.req.query("project") || undefined,
7219
+ sessionId: c.req.query("session") || undefined,
7220
+ open: c.req.query("open") === "1"
7221
+ })));
7222
+ app.post("/v1/questions", async (c) => {
7223
+ const b = await c.req.json().catch(() => ({}));
7224
+ if (!b.projectId)
7225
+ return c.json({ ok: false, error: "projectId required" }, 400);
7226
+ const r = store.ask(b.projectId, {
7227
+ sessionId: b.sessionId ?? null,
7228
+ text: b.text,
7229
+ options: b.options,
7230
+ askedBy: b.askedBy ?? null,
7231
+ cwd: b.cwd ?? null
7232
+ });
7233
+ return c.json(r, r.ok ? 201 : 400);
7234
+ });
7235
+ app.post("/v1/questions/:id/answer", async (c) => {
7236
+ const b = await c.req.json().catch(() => ({}));
7237
+ const r = store.answer(Number(c.req.param("id")), b.text, b.by ?? null);
7238
+ if (r.ok && r.question.sessionId) {
7239
+ const run2 = runner.get(r.question.sessionId);
7240
+ if (run2 && run2.sessionId === r.question.sessionId) {
7241
+ const sent = runner.send(run2.id, `[swarm] answer from ${b.by ?? "a human"} to your question "${r.question.text.slice(0, 200)}": ${r.question.answer}`);
7242
+ if (sent.ok)
7243
+ store.inbox(r.question.sessionId);
7244
+ }
7245
+ }
7246
+ return c.json(r, r.ok ? 200 : 409);
7247
+ });
7248
+ app.get("/v1/inbox", (c) => c.json(store.inbox(c.req.query("session") || null, { peek: c.req.query("peek") === "1" })));
7249
+ app.get("/v1/dispatch", (c) => {
7250
+ const project = c.req.query("project");
7251
+ if (!project)
7252
+ return c.json({ error: "project required" }, 400);
7253
+ return c.json({ entries: dispatcher.status(project), config: store.config(project).dispatch });
7254
+ });
7255
+ app.post("/v1/dispatch", async (c) => {
7256
+ const b = await c.req.json().catch(() => ({}));
7257
+ if (!b.projectId)
7258
+ return c.json({ ok: false, error: "projectId required" }, 400);
7259
+ if (!b.ready && !b.tasks?.length)
7260
+ return c.json({ ok: false, error: "tasks or ready:true required" }, 400);
7261
+ const r = await dispatcher.dispatch(b.projectId, b.ready ? "ready" : b.tasks, {
7262
+ owner: b.owner ?? "dispatch",
7263
+ max: b.max,
7264
+ maxParallel: b.maxParallel,
7265
+ permissionMode: b.permissionMode,
7266
+ model: b.model,
7267
+ maxTurns: b.maxTurns,
7268
+ profile: b.profile
7269
+ });
7270
+ return c.json(r, r.ok ? 202 : 409);
7271
+ });
7272
+ app.delete("/v1/dispatch", async (c) => {
7273
+ const b = await c.req.json().catch(() => ({}));
7274
+ if (!b.projectId)
7275
+ return c.json({ ok: false, error: "projectId required" }, 400);
7276
+ return c.json({ ok: true, cleared: dispatcher.clear(b.projectId, b.task) });
7277
+ });
5800
7278
  app.post("/v1/runs/:id/send", async (c) => {
5801
7279
  const b = await c.req.json().catch(() => ({}));
5802
7280
  if (!b.text?.trim())
@@ -5848,10 +7326,31 @@ function createApp(store = new Store) {
5848
7326
  const required = store.requiredGates(project);
5849
7327
  return c.json({
5850
7328
  required,
7329
+ executable: Object.keys(store.gateDefs(project)?.defs ?? {}),
5851
7330
  runs,
5852
7331
  status: task ? store.gateStatusFor(runs, required) : undefined
5853
7332
  });
5854
7333
  });
7334
+ app.post("/v1/gates/run", async (c) => {
7335
+ const b = await c.req.json().catch(() => ({}));
7336
+ if (!b.projectId || !b.task)
7337
+ return c.json({ ok: false, error: "projectId and task required" }, 400);
7338
+ const opts = { sessionId: b.sessionId ?? null, owner: "cli" };
7339
+ if (b.wait === false) {
7340
+ const projectId = b.projectId;
7341
+ const cfg = store.gateDefs(projectId);
7342
+ const names = b.gates?.length ? b.gates : (cfg?.required ?? []).filter((g) => cfg?.defs[g]);
7343
+ store.runGates(projectId, b.task, names, opts);
7344
+ return c.json({ ok: true, started: names, runs: [] }, 202);
7345
+ }
7346
+ const r = await store.runGates(b.projectId, b.task, b.gates, opts);
7347
+ const ok = r.started.length > 0 && r.runs.every((x) => x.verdict === "pass");
7348
+ return c.json({
7349
+ ok,
7350
+ ...r,
7351
+ error: r.started.length ? undefined : r.skipped[0]?.reason ?? "no executable gates"
7352
+ }, r.started.length ? 200 : 409);
7353
+ });
5855
7354
  app.post("/v1/gates", async (c) => {
5856
7355
  const b = await c.req.json().catch(() => ({}));
5857
7356
  if (!b.projectId)
@@ -5891,6 +7390,81 @@ function createApp(store = new Store) {
5891
7390
  const r = store.release(b.projectId ?? "", b.task ?? "", b.force ?? false);
5892
7391
  return c.json(r, r.ok ? 200 : 409);
5893
7392
  });
7393
+ app.get("/v1/worktrees", async (c) => {
7394
+ const project = c.req.query("project");
7395
+ if (!project)
7396
+ return c.json({ error: "project required" }, 400);
7397
+ return c.json(await store.refreshWorktrees(project));
7398
+ });
7399
+ app.post("/v1/worktrees", async (c) => {
7400
+ const b = await c.req.json();
7401
+ if (!b.projectId || !b.name)
7402
+ return c.json({ error: "projectId and name required" }, 400);
7403
+ const r = store.createWorktree(b.projectId, b.name, b.baseRef, b.branch);
7404
+ return c.json(r, r.ok ? 201 : 409);
7405
+ });
7406
+ app.post("/v1/worktrees/remove", async (c) => {
7407
+ const b = await c.req.json();
7408
+ if (!b.projectId || !b.worktree)
7409
+ return c.json({ error: "projectId and worktree required" }, 400);
7410
+ const r = await store.removeWorktree(b.projectId, b.worktree, b.force ?? false);
7411
+ return c.json(r, r.ok ? 200 : 409);
7412
+ });
7413
+ app.post("/v1/worktrees/open", async (c) => {
7414
+ const b = await c.req.json();
7415
+ if (!b.projectId || !b.worktree)
7416
+ return c.json({ error: "projectId and worktree required" }, 400);
7417
+ await store.refreshWorktrees(b.projectId);
7418
+ const r = store.openWorktree(b.projectId, b.worktree);
7419
+ return c.json(r, r.ok ? 200 : 404);
7420
+ });
7421
+ app.get("/v1/worktrees/diff", async (c) => {
7422
+ const project = c.req.query("project");
7423
+ const ref = c.req.query("worktree");
7424
+ if (!project || !ref)
7425
+ return c.json({ error: "project and worktree required" }, 400);
7426
+ await store.refreshWorktrees(project);
7427
+ const w = store.findWorktree(project, ref);
7428
+ const p = store.project(project);
7429
+ if (!w || !p)
7430
+ return c.json({ error: `no worktree ${ref}` }, 404);
7431
+ const file = c.req.query("file") || undefined;
7432
+ const d = await worktreeDiff(p.root, w.path);
7433
+ if (file || c.req.query("patch") === "1")
7434
+ return c.json({ ...d, worktree: w.path, patch: await worktreePatch(w.path, d.base, file) });
7435
+ return c.json({ ...d, worktree: w.path });
7436
+ });
7437
+ app.get("/v1/prs/draft", async (c) => {
7438
+ const project = c.req.query("project");
7439
+ const ref = c.req.query("worktree") || c.req.query("task");
7440
+ if (!project || !ref)
7441
+ return c.json({ error: "project and worktree|task required" }, 400);
7442
+ const r = await store.prDraftFor(project, ref);
7443
+ return c.json(r, r.ok ? 200 : 404);
7444
+ });
7445
+ app.post("/v1/prs/open", async (c) => {
7446
+ const b = await c.req.json().catch(() => ({}));
7447
+ const ref = b.worktree || b.task;
7448
+ if (!b.projectId || !ref)
7449
+ return c.json({ ok: false, error: "projectId and worktree|task required" }, 400);
7450
+ const d = await store.prDraftFor(b.projectId, ref);
7451
+ if (!d.ok)
7452
+ return c.json(d, 404);
7453
+ const r = await forge2.openPR(b.projectId, d.worktree, {
7454
+ title: b.title?.trim() || d.title,
7455
+ body: b.body ?? d.body,
7456
+ isDraft: b.draft ?? false
7457
+ });
7458
+ if (r.ok)
7459
+ store.recordPrOpened(b.projectId, d.task, d.worktree.path, r.url);
7460
+ return c.json(r, r.ok ? 201 : 409);
7461
+ });
7462
+ app.post("/v1/worktrees/gc", async (c) => {
7463
+ const b = await c.req.json().catch(() => ({}));
7464
+ if (!b.projectId)
7465
+ return c.json({ error: "projectId required" }, 400);
7466
+ return c.json(await store.gcWorktrees(b.projectId, b.apply ?? false));
7467
+ });
5894
7468
  app.post("/v1/claims/reap", async (c) => {
5895
7469
  const b = await c.req.json().catch(() => ({}));
5896
7470
  return c.json({ reaped: store.reap(b.projectId) });
@@ -5957,6 +7531,8 @@ function createApp(store = new Store) {
5957
7531
  hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx }
5958
7532
  });
5959
7533
  }
7534
+ const sid = typeof raw2.session_id === "string" ? raw2.session_id : null;
7535
+ const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
5960
7536
  if (event === "PreToolUse" && process.env.SWARM_GUARD !== "off") {
5961
7537
  const guard = store.guardHook(raw2);
5962
7538
  if (guard) {
@@ -5964,11 +7540,17 @@ function createApp(store = new Store) {
5964
7540
  hookSpecificOutput: {
5965
7541
  hookEventName: "PreToolUse",
5966
7542
  permissionDecision: guard.action,
5967
- permissionDecisionReason: `[swarm] ${guard.reason}`
7543
+ permissionDecisionReason: `[swarm] ${guard.reason}`,
7544
+ ...answers ? { additionalContext: answers } : {}
5968
7545
  }
5969
7546
  });
5970
7547
  }
5971
7548
  }
7549
+ if (answers)
7550
+ return c.json({
7551
+ additionalContext: answers,
7552
+ hookSpecificOutput: { hookEventName: event, additionalContext: answers }
7553
+ });
5972
7554
  return c.json({});
5973
7555
  });
5974
7556
  app.post("/v1/events", async (c) => {
@@ -5997,18 +7579,18 @@ function createApp(store = new Store) {
5997
7579
  });
5998
7580
  });
5999
7581
  });
6000
- app.get("/", (c) => c.html(readFileSync4(join7(WEB_DIR, "index.html"), "utf8")));
7582
+ app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
6001
7583
  const MIME = { js: "text/javascript", css: "text/css" };
6002
7584
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
6003
7585
  const f = c.req.param("file");
6004
- const p = join7(WEB_DIR, f);
6005
- if (!existsSync5(p))
7586
+ const p = join9(WEB_DIR, f);
7587
+ if (!existsSync6(p))
6006
7588
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
6007
7589
  return c.body(readFileSync4(p, "utf8"), 200, {
6008
7590
  "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
6009
7591
  });
6010
7592
  });
6011
- return { app, store, forge: forge2, runner };
7593
+ return { app, store, forge: forge2, runner, dispatcher };
6012
7594
  }
6013
7595
 
6014
7596
  // packages/daemon/src/bin.ts
@@ -6044,6 +7626,8 @@ var tailer = setInterval(() => {
6044
7626
  store.reapProcesses();
6045
7627
  if (tick % 12 === 0)
6046
7628
  store.sweepOrphans();
7629
+ if (tick % 6 === 0)
7630
+ store.checkBudgets();
6047
7631
  }, 5000);
6048
7632
  store.refreshAllWorktrees();
6049
7633
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);