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