@ra3orblade/swarm 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/swarm-mcp.js +24 -1
- package/dist/swarm.js +93 -7
- package/dist/swarmd.js +919 -161
- package/package.json +1 -1
- package/web/app.js +282 -8
- package/web/icons.js +2 -2
- package/web/index.html +29 -0
- package/web/release-notes.js +2 -0
package/dist/swarmd.js
CHANGED
|
@@ -327,7 +327,7 @@ 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
331
|
gates: { required: [] },
|
|
332
332
|
rules: {
|
|
333
333
|
shared_tree: "ask",
|
|
@@ -367,7 +367,9 @@ function validate(c) {
|
|
|
367
367
|
...c,
|
|
368
368
|
daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
|
|
369
369
|
tasks: {
|
|
370
|
-
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
|
|
371
373
|
},
|
|
372
374
|
rules: {
|
|
373
375
|
...c.rules,
|
|
@@ -396,6 +398,250 @@ function loadConfig(opts = {}) {
|
|
|
396
398
|
}
|
|
397
399
|
return validate(cfg);
|
|
398
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
|
+
}
|
|
399
645
|
// packages/core/src/forge.ts
|
|
400
646
|
function parseRemote(url) {
|
|
401
647
|
const m = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/) ?? url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
|
|
@@ -576,6 +822,237 @@ function formatHandoff(h) {
|
|
|
576
822
|
return lines.join(`
|
|
577
823
|
`);
|
|
578
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
|
+
}
|
|
579
1056
|
// packages/core/src/pricing.ts
|
|
580
1057
|
var PRICES = {
|
|
581
1058
|
"claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
@@ -699,152 +1176,6 @@ function acquireRefusalMessage(holder) {
|
|
|
699
1176
|
const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
|
|
700
1177
|
return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
|
|
701
1178
|
}
|
|
702
|
-
// packages/core/src/rules.ts
|
|
703
|
-
var LIVE_WINDOW_MS = 10 * 60000;
|
|
704
|
-
function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
|
|
705
|
-
if (!current.toplevel)
|
|
706
|
-
return null;
|
|
707
|
-
for (const s of sessions) {
|
|
708
|
-
if (s.id === current.id)
|
|
709
|
-
continue;
|
|
710
|
-
if (s.state === "ended")
|
|
711
|
-
continue;
|
|
712
|
-
if (s.toplevel !== current.toplevel)
|
|
713
|
-
continue;
|
|
714
|
-
if (now - new Date(s.lastSeenAt).getTime() > withinMs)
|
|
715
|
-
continue;
|
|
716
|
-
return s;
|
|
717
|
-
}
|
|
718
|
-
return null;
|
|
719
|
-
}
|
|
720
|
-
function isBroadStage(cmd) {
|
|
721
|
-
const c = cmd.trim();
|
|
722
|
-
if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
|
|
723
|
-
return true;
|
|
724
|
-
if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
|
|
725
|
-
return true;
|
|
726
|
-
if (/\bgit\s+add\s*$/.test(c))
|
|
727
|
-
return true;
|
|
728
|
-
return false;
|
|
729
|
-
}
|
|
730
|
-
function isDestructiveGit(cmd) {
|
|
731
|
-
const c = cmd.trim();
|
|
732
|
-
return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
|
|
733
|
-
}
|
|
734
|
-
function isPatternKill(cmd) {
|
|
735
|
-
return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
|
|
736
|
-
}
|
|
737
|
-
function killedPorts(cmd) {
|
|
738
|
-
const ports = new Set;
|
|
739
|
-
const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
|
|
740
|
-
if (!killy)
|
|
741
|
-
return [];
|
|
742
|
-
for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
|
|
743
|
-
ports.add(Number(m[1]));
|
|
744
|
-
for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
|
|
745
|
-
ports.add(Number(m[1]));
|
|
746
|
-
for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
|
|
747
|
-
ports.add(Number(m[1]));
|
|
748
|
-
return [...ports];
|
|
749
|
-
}
|
|
750
|
-
var DEFAULT_MODES = {
|
|
751
|
-
shared_tree: "ask",
|
|
752
|
-
destructive_git: "ask",
|
|
753
|
-
pattern_kill: "ask",
|
|
754
|
-
protected_ports: "ask",
|
|
755
|
-
no_foreign_worktree: "ask",
|
|
756
|
-
claim_required_to_write: "off",
|
|
757
|
-
protected: { ports: [] }
|
|
758
|
-
};
|
|
759
|
-
function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
|
|
760
|
-
const other = () => otherLiveInSameTree(current, sessions, now);
|
|
761
|
-
const hit = (rule, reason) => {
|
|
762
|
-
const mode = modes[rule];
|
|
763
|
-
return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
|
|
764
|
-
};
|
|
765
|
-
if (modes.protected_ports !== "off" && modes.protected.ports.length) {
|
|
766
|
-
const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
|
|
767
|
-
if (target.length) {
|
|
768
|
-
const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
|
|
769
|
-
if (d.action !== "allow")
|
|
770
|
-
return d;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
|
|
774
|
-
const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
|
|
775
|
-
if (d.action !== "allow")
|
|
776
|
-
return d;
|
|
777
|
-
}
|
|
778
|
-
if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
|
|
779
|
-
const o = other();
|
|
780
|
-
if (o) {
|
|
781
|
-
const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
|
|
782
|
-
if (d.action !== "allow")
|
|
783
|
-
return d;
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
|
|
787
|
-
const o = other();
|
|
788
|
-
if (o) {
|
|
789
|
-
const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
|
|
790
|
-
if (d.action !== "allow")
|
|
791
|
-
return d;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
return { action: "allow" };
|
|
795
|
-
}
|
|
796
|
-
function norm(p) {
|
|
797
|
-
const parts = [];
|
|
798
|
-
for (const seg of p.split("/")) {
|
|
799
|
-
if (seg === "" || seg === ".")
|
|
800
|
-
continue;
|
|
801
|
-
if (seg === "..")
|
|
802
|
-
parts.pop();
|
|
803
|
-
else
|
|
804
|
-
parts.push(seg);
|
|
805
|
-
}
|
|
806
|
-
return `/${parts.join("/")}`;
|
|
807
|
-
}
|
|
808
|
-
function isInside(path, dir) {
|
|
809
|
-
if (!path || !dir)
|
|
810
|
-
return false;
|
|
811
|
-
const a = norm(path);
|
|
812
|
-
const d = norm(dir);
|
|
813
|
-
return a === d || a.startsWith(`${d}/`);
|
|
814
|
-
}
|
|
815
|
-
function absolutePath(path, cwd) {
|
|
816
|
-
if (path.startsWith("/"))
|
|
817
|
-
return path;
|
|
818
|
-
if (path.startsWith("~/"))
|
|
819
|
-
return path;
|
|
820
|
-
return `${cwd.replace(/\/+$/, "")}/${path}`;
|
|
821
|
-
}
|
|
822
|
-
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
823
|
-
function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
|
|
824
|
-
const hit = (rule, reason) => {
|
|
825
|
-
const mode = modes[rule];
|
|
826
|
-
return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
|
|
827
|
-
};
|
|
828
|
-
const held = claims.filter((c) => c.worktree);
|
|
829
|
-
const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
|
|
830
|
-
if (modes.no_foreign_worktree !== "off") {
|
|
831
|
-
const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
|
|
832
|
-
if (foreign) {
|
|
833
|
-
const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
|
|
834
|
-
if (d.action !== "allow")
|
|
835
|
-
return d;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
|
|
839
|
-
const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
|
|
840
|
-
if (inShared) {
|
|
841
|
-
const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
|
|
842
|
-
if (d.action !== "allow")
|
|
843
|
-
return d;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
return { action: "allow" };
|
|
847
|
-
}
|
|
848
1179
|
// packages/core/src/tasks.ts
|
|
849
1180
|
var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
|
|
850
1181
|
var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
|
|
@@ -945,6 +1276,65 @@ function taskBoard(tasks, activeClaims) {
|
|
|
945
1276
|
};
|
|
946
1277
|
});
|
|
947
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
|
+
}
|
|
948
1338
|
// packages/daemon/src/app.ts
|
|
949
1339
|
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
|
|
950
1340
|
import { homedir as homedir4 } from "os";
|
|
@@ -3145,6 +3535,83 @@ function heldWork(path) {
|
|
|
3145
3535
|
return { dirty, unpushed };
|
|
3146
3536
|
}
|
|
3147
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
|
+
|
|
3148
3615
|
// packages/daemon/src/store.ts
|
|
3149
3616
|
var SCHEMA = `
|
|
3150
3617
|
CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, root TEXT, common_dir TEXT, name TEXT, discovered INTEGER, created_at TEXT);
|
|
@@ -3171,6 +3638,10 @@ CREATE TABLE IF NOT EXISTS resources (
|
|
|
3171
3638
|
PRIMARY KEY (name, project_id)
|
|
3172
3639
|
);
|
|
3173
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
|
+
);
|
|
3174
3645
|
CREATE TABLE IF NOT EXISTS incident_acks (seq INTEGER PRIMARY KEY, acked_at TEXT);
|
|
3175
3646
|
CREATE TABLE IF NOT EXISTS processes (
|
|
3176
3647
|
pid INTEGER, start_time TEXT, project_id TEXT, session_id TEXT, kind TEXT, name TEXT, port INTEGER,
|
|
@@ -3219,6 +3690,7 @@ class Store {
|
|
|
3219
3690
|
this.reconcileMovedProjects();
|
|
3220
3691
|
this.slimExistingEvents();
|
|
3221
3692
|
this.retypeNotificationIncidents();
|
|
3693
|
+
this.backfillMemory();
|
|
3222
3694
|
}
|
|
3223
3695
|
retypeNotificationIncidents() {
|
|
3224
3696
|
if (this.meta("notifications_retyped") === "1")
|
|
@@ -3345,8 +3817,9 @@ class Store {
|
|
|
3345
3817
|
createdAt: new Date().toISOString()
|
|
3346
3818
|
};
|
|
3347
3819
|
const sessionId = this.knownSession(h.sessionId);
|
|
3348
|
-
this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
|
|
3820
|
+
const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
|
|
3349
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));
|
|
3350
3823
|
this.append({
|
|
3351
3824
|
ts: handoff.createdAt,
|
|
3352
3825
|
type: "handoff.recorded",
|
|
@@ -3383,6 +3856,138 @@ class Store {
|
|
|
3383
3856
|
sessionId: r.session_id ?? null
|
|
3384
3857
|
}));
|
|
3385
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
|
+
}
|
|
3386
3991
|
sessionContext(cwd) {
|
|
3387
3992
|
if (!cwd || !existsSync4(cwd))
|
|
3388
3993
|
return null;
|
|
@@ -3458,6 +4063,7 @@ class Store {
|
|
|
3458
4063
|
const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
|
|
3459
4064
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
|
|
3460
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));
|
|
3461
4067
|
this.append({
|
|
3462
4068
|
ts: createdAt,
|
|
3463
4069
|
type: "gate.recorded",
|
|
@@ -3487,21 +4093,36 @@ class Store {
|
|
|
3487
4093
|
return { ok: true, run: run2 };
|
|
3488
4094
|
}
|
|
3489
4095
|
taskCache = new Map;
|
|
4096
|
+
taskSources = new TaskSources;
|
|
3490
4097
|
tasks(projectId) {
|
|
3491
4098
|
const p = this.project(projectId);
|
|
3492
4099
|
if (!p)
|
|
3493
4100
|
return null;
|
|
3494
|
-
const
|
|
4101
|
+
const cfg = loadConfig({ repoRoot: p.root, home: this.home }).tasks;
|
|
4102
|
+
const source = cfg.source;
|
|
3495
4103
|
if (!source)
|
|
3496
4104
|
return null;
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
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;
|
|
3505
4126
|
}
|
|
3506
4127
|
const now = Date.now();
|
|
3507
4128
|
const active = this.claimRows(projectId).filter((c) => isActive(c, now));
|
|
@@ -3523,7 +4144,7 @@ class Store {
|
|
|
3523
4144
|
gated: gatesSatisfied(tr, required)
|
|
3524
4145
|
};
|
|
3525
4146
|
});
|
|
3526
|
-
return { source, required, tasks: board };
|
|
4147
|
+
return { source, required, tasks: board, error };
|
|
3527
4148
|
}
|
|
3528
4149
|
rulesFor(repoRoot) {
|
|
3529
4150
|
const key = repoRoot ?? "";
|
|
@@ -3625,6 +4246,51 @@ class Store {
|
|
|
3625
4246
|
return d;
|
|
3626
4247
|
}
|
|
3627
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
|
+
}
|
|
3628
4294
|
heldWorktrees() {
|
|
3629
4295
|
if (this.heldWorktreesCache && Date.now() - this.heldWorktreesCache.at < 2000)
|
|
3630
4296
|
return this.heldWorktreesCache.v;
|
|
@@ -3773,6 +4439,8 @@ class Store {
|
|
|
3773
4439
|
const slim = slimForStorage(e);
|
|
3774
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));
|
|
3775
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));
|
|
3776
4444
|
this.projectSession(stored);
|
|
3777
4445
|
this.touch();
|
|
3778
4446
|
const wire = toWire(stored);
|
|
@@ -3795,6 +4463,11 @@ class Store {
|
|
|
3795
4463
|
const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
|
|
3796
4464
|
const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
|
|
3797
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
|
+
}
|
|
3798
4471
|
if (e.sessionId && typeof raw2.transcript_path === "string") {
|
|
3799
4472
|
this.db.query("UPDATE sessions SET transcript_path = ? WHERE id = ? AND transcript_path IS NULL").run(raw2.transcript_path, e.sessionId);
|
|
3800
4473
|
const last = this.lastTail.get(e.sessionId) ?? 0;
|
|
@@ -4448,6 +5121,23 @@ class Store {
|
|
|
4448
5121
|
daily
|
|
4449
5122
|
};
|
|
4450
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
|
+
}
|
|
4451
5141
|
stats(projectId) {
|
|
4452
5142
|
const scope = projectId ? "s.project_id = ?" : "? IS NULL";
|
|
4453
5143
|
const arg = projectId ?? null;
|
|
@@ -4522,7 +5212,7 @@ class Store {
|
|
|
4522
5212
|
args.push(limit);
|
|
4523
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
|
|
4524
5214
|
LEFT JOIN incident_acks a ON a.seq = e.seq WHERE ${where.join(" AND ")} ORDER BY e.seq DESC LIMIT ?`).all(...args);
|
|
4525
|
-
|
|
5215
|
+
const list = rows.map((r) => ({
|
|
4526
5216
|
seq: r.seq,
|
|
4527
5217
|
ts: r.ts,
|
|
4528
5218
|
projectId: r.project_id,
|
|
@@ -4530,6 +5220,25 @@ class Store {
|
|
|
4530
5220
|
acked: r.acked_at,
|
|
4531
5221
|
...JSON.parse(r.payload || "{}")
|
|
4532
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
|
+
});
|
|
4533
5242
|
}
|
|
4534
5243
|
openIncidents(projectId) {
|
|
4535
5244
|
const r = this.db.query(`SELECT COUNT(*) AS n FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
|
|
@@ -4918,7 +5627,7 @@ function rowToEvent(r) {
|
|
|
4918
5627
|
}
|
|
4919
5628
|
|
|
4920
5629
|
// packages/daemon/src/app.ts
|
|
4921
|
-
var VERSION = "0.
|
|
5630
|
+
var VERSION = "0.6.0";
|
|
4922
5631
|
var WEB_DIR = (() => {
|
|
4923
5632
|
if (process.env.SWARM_WEB_DIR)
|
|
4924
5633
|
return process.env.SWARM_WEB_DIR;
|
|
@@ -4986,6 +5695,30 @@ function createApp(store = new Store) {
|
|
|
4986
5695
|
open: c.req.query("open") === "1",
|
|
4987
5696
|
projectId: c.req.query("project") || undefined
|
|
4988
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
|
+
});
|
|
4989
5722
|
app.post("/v1/incidents/ack", async (c) => {
|
|
4990
5723
|
const body = await c.req.json().catch(() => ({}));
|
|
4991
5724
|
return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
|
|
@@ -5177,6 +5910,10 @@ function createApp(store = new Store) {
|
|
|
5177
5910
|
return e ? c.json(e) : c.json({ error: "not found" }, 404);
|
|
5178
5911
|
});
|
|
5179
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
|
+
});
|
|
5180
5917
|
app.post("/v1/pricing/refresh", async (c) => {
|
|
5181
5918
|
try {
|
|
5182
5919
|
await store.refreshPricing();
|
|
@@ -5186,6 +5923,27 @@ function createApp(store = new Store) {
|
|
|
5186
5923
|
}
|
|
5187
5924
|
});
|
|
5188
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
|
+
});
|
|
5189
5947
|
app.post("/v1/sessions/:id/tail", (c) => c.json({ turns: store.tailSession(c.req.param("id")) }));
|
|
5190
5948
|
app.post("/v1/hook/:event", async (c) => {
|
|
5191
5949
|
const event = c.req.param("event");
|