@ra3orblade/swarm 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swarmd.js CHANGED
@@ -68,6 +68,44 @@ function clearDaemonInfo() {
68
68
  function daemonCommand() {
69
69
  return resolveBin("swarmd");
70
70
  }
71
+ // packages/core/src/abtrial.ts
72
+ var ms = (a, b) => b ? Math.max(0, new Date(b).getTime() - new Date(a).getTime()) : null;
73
+ function splitArmTask(id) {
74
+ const i = id.lastIndexOf("#");
75
+ return i < 0 ? { task: id, arm: null } : { task: id.slice(0, i), arm: id.slice(i + 1) };
76
+ }
77
+ var armTask = (task, label) => `${task}#${label}`;
78
+ function scoreTrial(task, arms) {
79
+ const scored = arms.map((a) => {
80
+ const ineligibleFor = a.state === "running" ? "still running" : a.state === "failed" ? "crashed" : a.gatesFailed > 0 ? "failed a gate" : null;
81
+ return {
82
+ ...a,
83
+ wallMs: ms(a.startedAt, a.endedAt),
84
+ churn: a.insertions === null && a.deletions === null ? null : (a.insertions ?? 0) + (a.deletions ?? 0),
85
+ eligible: ineligibleFor === null,
86
+ ineligibleFor,
87
+ winner: false
88
+ };
89
+ });
90
+ const eligible = scored.filter((a) => a.eligible).sort((a, b) => a.costUsd - b.costUsd || (a.wallMs ?? Number.POSITIVE_INFINITY) - (b.wallMs ?? Number.POSITIVE_INFINITY) || a.label.localeCompare(b.label));
91
+ const best = eligible[0] ?? null;
92
+ if (best)
93
+ best.winner = true;
94
+ const finished = scored.filter((a) => a.state !== "running").length;
95
+ const dearest = eligible.at(-1);
96
+ return {
97
+ task,
98
+ arms: scored.sort((a, b) => Number(b.eligible) - Number(a.eligible) || a.costUsd - b.costUsd || a.label.localeCompare(b.label)),
99
+ winner: best?.label ?? null,
100
+ verdict: best ? "winner" : scored.some((a) => a.state === "running") ? "undecided" : "all-failed",
101
+ totals: {
102
+ arms: scored.length,
103
+ finished,
104
+ costUsd: scored.reduce((a, x) => a + x.costUsd, 0),
105
+ savedUsd: best && dearest ? Math.max(0, dearest.costUsd - best.costUsd) : 0
106
+ }
107
+ };
108
+ }
71
109
  // packages/core/src/actor.ts
72
110
  var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
73
111
  var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
@@ -105,6 +143,150 @@ function actorFromColumns(kind, id, session) {
105
143
  a.session = session;
106
144
  return a;
107
145
  }
146
+ // packages/core/src/adapters/aider/history.ts
147
+ var djb2 = (s) => {
148
+ let h = 5381;
149
+ for (let i = 0;i < s.length; i++)
150
+ h = (h * 33 ^ s.charCodeAt(i)) >>> 0;
151
+ return h.toString(36);
152
+ };
153
+ var toks = (s) => {
154
+ if (!s)
155
+ return 0;
156
+ const n = Number.parseFloat(s.replaceAll(",", ""));
157
+ return Math.round(s.trim().endsWith("k") ? n * 1000 : n);
158
+ };
159
+ var HEADER = /^# aider chat started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/;
160
+ var MODEL = /^> Model: (\S+) with /;
161
+ var TOKENS = /^(?:> )?Tokens: ([\d.,]+k?) sent(?:, ([\d.,]+k?) cache write)?(?:, ([\d.,]+k?) cache hit)?, ([\d.,]+k?) received\./;
162
+ var COST = /Cost: \$([\d.]+(?:e-?\d+)?) message/;
163
+ var EDIT = /^> Applied edit to (.+)/;
164
+ var COMMIT = /^> Commit [0-9a-f]{6,}/;
165
+ function parseAiderHistory(chunk, seed, carry) {
166
+ const segments = [];
167
+ let cur = carry ? {
168
+ sessionId: carry.sessionId,
169
+ startMs: carry.startMs,
170
+ model: carry.model,
171
+ title: carry.title,
172
+ turns: [],
173
+ c: { ...carry }
174
+ } : null;
175
+ const closeTurn = (t, cost) => {
176
+ if (!cur)
177
+ return;
178
+ t.cost = cost;
179
+ cur.turns.push(t);
180
+ cur.c.turns++;
181
+ cur.c.text = "";
182
+ cur.c.tools = [];
183
+ cur.c.pending = null;
184
+ };
185
+ const flushPending = () => {
186
+ if (cur?.c.pending)
187
+ closeTurn(cur.c.pending, null);
188
+ };
189
+ for (const line of chunk.split(`
190
+ `)) {
191
+ const h = line.match(HEADER);
192
+ if (h) {
193
+ flushPending();
194
+ if (cur)
195
+ segments.push(cur);
196
+ const stamp = h[1] ?? "";
197
+ const startMs = Date.parse(stamp.replace(" ", "T"));
198
+ const sessionId = `aider-${djb2(`${seed}|${stamp}`)}`;
199
+ cur = {
200
+ sessionId,
201
+ startMs,
202
+ model: null,
203
+ title: null,
204
+ turns: [],
205
+ c: {
206
+ sessionId,
207
+ startMs,
208
+ model: null,
209
+ title: null,
210
+ turns: 0,
211
+ text: "",
212
+ tools: [],
213
+ pending: null
214
+ }
215
+ };
216
+ continue;
217
+ }
218
+ if (!cur)
219
+ continue;
220
+ const c = cur.c;
221
+ if (c.pending) {
222
+ const cost = line.match(COST);
223
+ if (cost) {
224
+ closeTurn(c.pending, Number.parseFloat(cost[1] ?? "0"));
225
+ continue;
226
+ }
227
+ flushPending();
228
+ }
229
+ const m = line.match(MODEL);
230
+ if (m) {
231
+ cur.model = (m[1] ?? "").split("/").pop() || null;
232
+ c.model = cur.model;
233
+ continue;
234
+ }
235
+ const tk = line.match(TOKENS);
236
+ if (tk) {
237
+ const cacheRead = toks(tk[3]);
238
+ const turn = {
239
+ id: `${c.sessionId}-t${c.turns}`,
240
+ ts: new Date(c.startMs + c.turns * 1000).toISOString(),
241
+ model: c.model ?? "aider",
242
+ usage: {
243
+ input: Math.max(0, toks(tk[1]) - cacheRead),
244
+ output: toks(tk[4]),
245
+ cacheWrite: toks(tk[2]),
246
+ cacheWrite1h: 0,
247
+ cacheRead,
248
+ thinking: 0
249
+ },
250
+ text: c.text,
251
+ tools: c.tools,
252
+ effort: null,
253
+ sidechain: false
254
+ };
255
+ const cost = line.match(COST);
256
+ if (cost)
257
+ closeTurn(turn, Number.parseFloat(cost[1] ?? "0"));
258
+ else
259
+ c.pending = turn;
260
+ continue;
261
+ }
262
+ if (line.startsWith("#### ")) {
263
+ const t = line.slice(5).trim();
264
+ if (t && !cur.title) {
265
+ cur.title = t.slice(0, 80);
266
+ c.title = cur.title;
267
+ }
268
+ continue;
269
+ }
270
+ if (EDIT.test(line)) {
271
+ c.tools.push("edit");
272
+ continue;
273
+ }
274
+ if (COMMIT.test(line)) {
275
+ c.tools.push("commit");
276
+ continue;
277
+ }
278
+ if (line.startsWith(">"))
279
+ continue;
280
+ if (line.trim() && c.text.length < 400)
281
+ c.text = `${c.text}${c.text ? `
282
+ ` : ""}${line}`.slice(0, 400);
283
+ }
284
+ if (cur)
285
+ segments.push(cur);
286
+ const last = cur ? { ...cur.c } : null;
287
+ return { segments, carry: last };
288
+ }
289
+
108
290
  // packages/core/src/adapters/claude-code/transcript.ts
109
291
  function parseTranscriptChunk(chunk) {
110
292
  const out = {
@@ -385,6 +567,62 @@ function parseGrokUpdates(chunk) {
385
567
  }
386
568
  return out;
387
569
  }
570
+
571
+ // packages/core/src/adapters/opencode/db.ts
572
+ var ocModel = (d) => {
573
+ if (typeof d.model === "string")
574
+ return d.model;
575
+ if (d.model && typeof d.model === "object" && typeof d.model.id === "string")
576
+ return d.model.id;
577
+ return typeof d.modelID === "string" ? d.modelID : null;
578
+ };
579
+ var ocTs = (t, fallbackMs) => {
580
+ if (typeof t === "number")
581
+ return new Date(t).toISOString();
582
+ if (typeof t === "string" && !Number.isNaN(Date.parse(t)))
583
+ return new Date(t).toISOString();
584
+ return new Date(fallbackMs).toISOString();
585
+ };
586
+ function opencodeTurn(sessionId, msgId, data, fallbackMs = 0, sidechain = false) {
587
+ let d;
588
+ try {
589
+ d = JSON.parse(data);
590
+ } catch {
591
+ return null;
592
+ }
593
+ if ((d.type ?? d.role) !== "assistant")
594
+ return null;
595
+ const t = d.tokens ?? {};
596
+ let text = "";
597
+ const tools = [];
598
+ for (const p of Array.isArray(d.content) ? d.content : []) {
599
+ if (p?.type === "text" && typeof p.text === "string" && text.length < 400)
600
+ text = `${text}${p.text}`.slice(0, 400);
601
+ else if (p?.type === "tool") {
602
+ const name = p.tool ?? p.name;
603
+ if (name)
604
+ tools.push(name);
605
+ }
606
+ }
607
+ return {
608
+ id: `${sessionId}-${msgId}`,
609
+ ts: ocTs(d.time?.created, fallbackMs),
610
+ model: ocModel(d) ?? "opencode",
611
+ usage: {
612
+ input: t.input ?? 0,
613
+ output: t.output ?? 0,
614
+ cacheWrite: t.cache?.write ?? 0,
615
+ cacheWrite1h: 0,
616
+ cacheRead: t.cache?.read ?? 0,
617
+ thinking: t.reasoning ?? 0
618
+ },
619
+ text,
620
+ tools,
621
+ effort: null,
622
+ sidechain,
623
+ cost: typeof d.cost === "number" && d.cost > 0 ? d.cost : null
624
+ };
625
+ }
388
626
  // packages/core/src/adapters/claude-code/hooks.ts
389
627
  var HOOK_EVENTS = [
390
628
  "SessionStart",
@@ -491,6 +729,38 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
491
729
  payload.prompt = raw.prompt;
492
730
  return { ts, type, projectId, sessionId: raw.session_id ?? null, payload, raw };
493
731
  }
732
+ // packages/core/src/art.ts
733
+ var ROBOT = [
734
+ " g g ",
735
+ " X X ",
736
+ " X X ",
737
+ " ddddddddddddd ",
738
+ " dXXXXXXXXXXXd ",
739
+ " dggXXXXXXXXXd ",
740
+ " dddXXddXXXddXXddd ",
741
+ " dddXXddXXXddXXddd ",
742
+ " dddXXXXXXXXXXXddd ",
743
+ " dddXXdddddddXXddd ",
744
+ " dXXXXXXXXXXXd ",
745
+ " dXXXXXXXXXXXd ",
746
+ " ddddddddddddd ",
747
+ " XXX ",
748
+ " ddddd ",
749
+ " ddddddddddd ",
750
+ " dXXdggXXXXXXXdXXd ",
751
+ " dXXdXggggXddddXXd ",
752
+ " dXXdXggggXXXXdXXd ",
753
+ " dXXdXXXXXXddddXXd ",
754
+ " dXXdXXXXXXXXXdXXd ",
755
+ " dXXdXdddXXXXXdXXd ",
756
+ " ddddXXXXXXXXXdddd ",
757
+ " ddddddddddd ",
758
+ " XX XX ",
759
+ " XX XX ",
760
+ " XXXX XXXX ",
761
+ " dddd dddd "
762
+ ];
763
+ var ROBOT_HEAD = ROBOT.slice(0, 13);
494
764
  // packages/core/src/audit.ts
495
765
  var AUDIT_TYPES = new Set([
496
766
  "session.started",
@@ -636,8 +906,8 @@ function sinceToIso(since, now = Date.now()) {
636
906
  const m = /^(\d+)([dhm])$/.exec(since.trim());
637
907
  if (m) {
638
908
  const n = Number(m[1]);
639
- const ms = m[2] === "d" ? 86400000 : m[2] === "h" ? 3600000 : 60000;
640
- return new Date(now - n * ms).toISOString();
909
+ const ms2 = m[2] === "d" ? 86400000 : m[2] === "h" ? 3600000 : 60000;
910
+ return new Date(now - n * ms2).toISOString();
641
911
  }
642
912
  const t = Date.parse(since);
643
913
  return Number.isNaN(t) ? null : new Date(t).toISOString();
@@ -796,6 +1066,9 @@ var DEFAULT_CONFIG = {
796
1066
  gates: { required: [], auto: "session-end", defs: {} },
797
1067
  workflows: {},
798
1068
  budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
1069
+ models: { allow: [] },
1070
+ notify: { webhook: null },
1071
+ team: { url: null, forward: ["ledger", "cost"], interval: 5 },
799
1072
  events: { retain_days: 30 },
800
1073
  audit: { retain_days: 0 },
801
1074
  privacy: DEFAULT_PRIVACY,
@@ -891,6 +1164,26 @@ function validate(c) {
891
1164
  on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
892
1165
  },
893
1166
  workflows: parseWorkflows(c.workflows),
1167
+ notify: {
1168
+ webhook: (() => {
1169
+ const w = c.notify?.webhook;
1170
+ return typeof w === "string" && /^https?:\/\//.test(w.trim()) ? w.trim() : null;
1171
+ })()
1172
+ },
1173
+ models: {
1174
+ allow: Array.isArray(c.models?.allow) ? c.models.allow.filter((m) => typeof m === "string" && m.trim() !== "") : []
1175
+ },
1176
+ team: (() => {
1177
+ const t = c.team ?? {};
1178
+ const url = typeof t.url === "string" && /^https?:\/\//.test(t.url.trim()) ? t.url.trim().replace(/\/+$/, "") : null;
1179
+ const iv = Number(t.interval);
1180
+ const KINDS = ["ledger", "cost", "transcripts"];
1181
+ return {
1182
+ url,
1183
+ forward: Array.isArray(t.forward) ? t.forward.filter((k) => typeof k === "string" && KINDS.includes(k)) : ["ledger", "cost"],
1184
+ interval: Number.isFinite(iv) && iv >= 1 && iv <= 300 ? Math.round(iv) : 5
1185
+ };
1186
+ })(),
894
1187
  events: {
895
1188
  retain_days: days(c.events?.retain_days, 30)
896
1189
  },
@@ -1008,6 +1301,238 @@ function loadConfigDetailed(opts = {}) {
1008
1301
  function loadConfig(opts = {}) {
1009
1302
  return loadConfigDetailed(opts).config;
1010
1303
  }
1304
+ // packages/core/src/context.ts
1305
+ var CHARS_PER_TOKEN = 4;
1306
+ var estTokens = (chars) => Math.round(chars / CHARS_PER_TOKEN);
1307
+ var CONTEXT_DEFAULTS = { worstLimit: 5, toolLimit: 12 };
1308
+ function rereadWaste(reads) {
1309
+ const byPath = new Map;
1310
+ for (const r of reads) {
1311
+ const hit = byPath.get(r.path);
1312
+ if (hit) {
1313
+ hit.reads++;
1314
+ hit.chars += r.chars;
1315
+ } else {
1316
+ byPath.set(r.path, { reads: 1, chars: r.chars, first: r.chars });
1317
+ }
1318
+ }
1319
+ const out = [];
1320
+ for (const [path, v] of byPath) {
1321
+ if (v.reads < 2)
1322
+ continue;
1323
+ out.push({ path, reads: v.reads, chars: v.chars, wastedChars: v.chars - v.first });
1324
+ }
1325
+ return out.sort((a, b) => b.wastedChars - a.wastedChars || a.path.localeCompare(b.path));
1326
+ }
1327
+ function contextReport(results, reads, turns, opts = {}) {
1328
+ const o = { ...CONTEXT_DEFAULTS, ...opts };
1329
+ const ids = new Set;
1330
+ for (const r of results)
1331
+ ids.add(r.sessionId);
1332
+ for (const r of reads)
1333
+ ids.add(r.sessionId);
1334
+ for (const t of turns)
1335
+ ids.add(t.sessionId);
1336
+ const readsBySession = new Map;
1337
+ for (const r of reads) {
1338
+ const list = readsBySession.get(r.sessionId);
1339
+ if (list)
1340
+ list.push(r);
1341
+ else
1342
+ readsBySession.set(r.sessionId, [r]);
1343
+ }
1344
+ const charsBySession = new Map;
1345
+ for (const r of results)
1346
+ charsBySession.set(r.sessionId, (charsBySession.get(r.sessionId) ?? 0) + r.chars);
1347
+ const tokensBySession = new Map;
1348
+ for (const t of turns) {
1349
+ const hit = tokensBySession.get(t.sessionId);
1350
+ if (hit) {
1351
+ hit.input += t.input;
1352
+ hit.cacheRead += t.cacheRead;
1353
+ hit.cacheWrite += t.cacheWrite;
1354
+ hit.thinking += t.thinking;
1355
+ hit.output += t.output;
1356
+ } else
1357
+ tokensBySession.set(t.sessionId, { ...t });
1358
+ }
1359
+ const sessions = [];
1360
+ for (const sessionId of ids) {
1361
+ const worstAll = rereadWaste(readsBySession.get(sessionId) ?? []);
1362
+ const wastedChars2 = worstAll.reduce((a, w) => a + w.wastedChars, 0);
1363
+ const toolChars2 = charsBySession.get(sessionId) ?? 0;
1364
+ const tk = tokensBySession.get(sessionId);
1365
+ const cacheable = (tk?.cacheRead ?? 0) + (tk?.input ?? 0);
1366
+ sessions.push({
1367
+ sessionId,
1368
+ toolChars: toolChars2,
1369
+ toolTokens: estTokens(toolChars2),
1370
+ thinking: tk?.thinking ?? 0,
1371
+ cacheRead: tk?.cacheRead ?? 0,
1372
+ input: tk?.input ?? 0,
1373
+ cacheHit: cacheable ? (tk?.cacheRead ?? 0) / cacheable : 0,
1374
+ reads: (readsBySession.get(sessionId) ?? []).length,
1375
+ rereadFiles: worstAll.length,
1376
+ wastedChars: wastedChars2,
1377
+ wasteShare: toolChars2 ? wastedChars2 / toolChars2 : 0,
1378
+ worst: worstAll.slice(0, o.worstLimit)
1379
+ });
1380
+ }
1381
+ sessions.sort((a, b) => b.wastedChars - a.wastedChars || b.toolChars - a.toolChars);
1382
+ const byToolMap = new Map;
1383
+ for (const r of results) {
1384
+ const hit = byToolMap.get(r.tool);
1385
+ if (hit) {
1386
+ hit.calls++;
1387
+ hit.chars += r.chars;
1388
+ } else
1389
+ byToolMap.set(r.tool, { tool: r.tool, calls: 1, chars: r.chars, tokens: 0 });
1390
+ }
1391
+ const byTool = [...byToolMap.values()].map((t) => ({ ...t, tokens: estTokens(t.chars) })).sort((a, b) => b.chars - a.chars || a.tool.localeCompare(b.tool)).slice(0, o.toolLimit);
1392
+ const toolChars = sessions.reduce((a, s) => a + s.toolChars, 0);
1393
+ const wastedChars = sessions.reduce((a, s) => a + s.wastedChars, 0);
1394
+ const cacheRead = sessions.reduce((a, s) => a + s.cacheRead, 0);
1395
+ const input = sessions.reduce((a, s) => a + s.input, 0);
1396
+ return {
1397
+ sessions,
1398
+ byTool,
1399
+ totals: {
1400
+ sessions: sessions.length,
1401
+ toolChars,
1402
+ toolTokens: estTokens(toolChars),
1403
+ wastedChars,
1404
+ wastedTokens: estTokens(wastedChars),
1405
+ wasteShare: toolChars ? wastedChars / toolChars : 0,
1406
+ rereadFiles: sessions.reduce((a, s) => a + s.rereadFiles, 0),
1407
+ cacheHit: cacheRead + input ? cacheRead / (cacheRead + input) : 0
1408
+ }
1409
+ };
1410
+ }
1411
+ // packages/core/src/dag.ts
1412
+ var DAG_DEFAULTS = { dx: 190, dy: 34, passes: 2 };
1413
+ var edgeKey = (from, to) => JSON.stringify([from, to]);
1414
+ function findBackEdges(nodes, edges) {
1415
+ const ids = nodes.map((n) => n.id);
1416
+ const known = new Set(ids);
1417
+ const out = new Map;
1418
+ for (const id of ids)
1419
+ out.set(id, []);
1420
+ for (const e of edges)
1421
+ if (known.has(e.from) && known.has(e.to) && e.from !== e.to)
1422
+ out.get(e.from)?.push(e.to);
1423
+ for (const [, list] of out)
1424
+ list.sort();
1425
+ const back = new Set;
1426
+ const state = new Map;
1427
+ const visit = (start) => {
1428
+ const stack = [{ id: start, i: 0 }];
1429
+ state.set(start, 1);
1430
+ while (stack.length) {
1431
+ const top = stack[stack.length - 1];
1432
+ const next = out.get(top.id) ?? [];
1433
+ if (top.i >= next.length) {
1434
+ state.set(top.id, 2);
1435
+ stack.pop();
1436
+ continue;
1437
+ }
1438
+ const to = next[top.i++];
1439
+ const st = state.get(to) ?? 0;
1440
+ if (st === 1)
1441
+ back.add(edgeKey(top.id, to));
1442
+ else if (st === 0) {
1443
+ state.set(to, 1);
1444
+ stack.push({ id: to, i: 0 });
1445
+ }
1446
+ }
1447
+ };
1448
+ for (const id of [...ids].sort())
1449
+ if ((state.get(id) ?? 0) === 0)
1450
+ visit(id);
1451
+ return back;
1452
+ }
1453
+ function assignLayers(nodes, forward) {
1454
+ const layer = new Map;
1455
+ const parents = new Map;
1456
+ for (const n of nodes)
1457
+ parents.set(n.id, []);
1458
+ for (const e of forward)
1459
+ parents.get(e.to)?.push(e.from);
1460
+ const resolving = new Set;
1461
+ const depth = (id) => {
1462
+ const fixed = nodes.find((n) => n.id === id)?.layer;
1463
+ if (typeof fixed === "number") {
1464
+ layer.set(id, fixed);
1465
+ return fixed;
1466
+ }
1467
+ const hit = layer.get(id);
1468
+ if (hit !== undefined)
1469
+ return hit;
1470
+ if (resolving.has(id))
1471
+ return 0;
1472
+ resolving.add(id);
1473
+ const ps = parents.get(id) ?? [];
1474
+ const d = ps.length ? Math.max(...ps.map((p) => depth(p) + 1)) : 0;
1475
+ resolving.delete(id);
1476
+ layer.set(id, d);
1477
+ return d;
1478
+ };
1479
+ for (const n of [...nodes].sort((a, b) => a.id.localeCompare(b.id)))
1480
+ depth(n.id);
1481
+ return layer;
1482
+ }
1483
+ function layoutDag(nodes, edges, opts = {}) {
1484
+ const o = { ...DAG_DEFAULTS, ...opts };
1485
+ if (!nodes.length)
1486
+ return { nodes: [], edges: [], width: 0, height: 0, layers: 0 };
1487
+ const known = new Set(nodes.map((n) => n.id));
1488
+ const clean = edges.filter((e) => known.has(e.from) && known.has(e.to) && e.from !== e.to);
1489
+ const back = findBackEdges(nodes, clean);
1490
+ const isBack = (e) => back.has(edgeKey(e.from, e.to));
1491
+ const forward = clean.filter((e) => !isBack(e));
1492
+ const layerOf = assignLayers(nodes, forward);
1493
+ const maxLayer = Math.max(0, ...nodes.map((n) => layerOf.get(n.id) ?? 0));
1494
+ const groups = Array.from({ length: maxLayer + 1 }, () => []);
1495
+ for (const n of [...nodes].sort((a, b) => {
1496
+ const sa = o.seed?.[a.id] ?? Number.POSITIVE_INFINITY;
1497
+ const sb = o.seed?.[b.id] ?? Number.POSITIVE_INFINITY;
1498
+ return sa - sb || a.id.localeCompare(b.id);
1499
+ }))
1500
+ groups[layerOf.get(n.id) ?? 0].push(n.id);
1501
+ const parentsOf = new Map;
1502
+ for (const n of nodes)
1503
+ parentsOf.set(n.id, []);
1504
+ for (const e of forward)
1505
+ parentsOf.get(e.to)?.push(e.from);
1506
+ for (let pass = 0;pass < o.passes; pass++) {
1507
+ for (let l = 1;l <= maxLayer; l++) {
1508
+ const prev = groups[l - 1];
1509
+ const idx = new Map(prev.map((id, i) => [id, i]));
1510
+ const group = groups[l];
1511
+ const bary = new Map;
1512
+ for (const id of group) {
1513
+ const ps = (parentsOf.get(id) ?? []).map((p) => idx.get(p)).filter((v) => v !== undefined);
1514
+ bary.set(id, ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : group.indexOf(id));
1515
+ }
1516
+ group.sort((a, b) => bary.get(a) - bary.get(b) || a.localeCompare(b));
1517
+ }
1518
+ }
1519
+ const widest = Math.max(1, ...groups.map((g) => g.length));
1520
+ const placed = [];
1521
+ groups.forEach((group, l) => {
1522
+ const offset = (widest - group.length) * o.dy / 2;
1523
+ group.forEach((id, i) => {
1524
+ placed.push({ id, layer: l, order: i, x: l * o.dx, y: offset + i * o.dy });
1525
+ });
1526
+ });
1527
+ placed.sort((a, b) => a.layer - b.layer || a.order - b.order);
1528
+ return {
1529
+ nodes: placed,
1530
+ edges: clean.map((e) => ({ ...e, back: isBack(e) })),
1531
+ width: maxLayer * o.dx,
1532
+ height: Math.max(0, (widest - 1) * o.dy),
1533
+ layers: maxLayer + 1
1534
+ };
1535
+ }
1011
1536
  // packages/core/src/dispatch.ts
1012
1537
  function planDispatch(tasks, wanted, opts) {
1013
1538
  const byId = new Map(tasks.map((t) => [t.id, t]));
@@ -1443,6 +1968,83 @@ ${shown.map((f) => `- \`${f.path}\`${f.added >= 0 ? ` +${f.added} \u2212${f.dele
1443
1968
  return { title, body: b.join(`
1444
1969
  `) };
1445
1970
  }
1971
+ // packages/core/src/gatehealth.ts
1972
+ var ms2 = (iso) => new Date(iso).getTime();
1973
+ function percentile(xs, p) {
1974
+ if (!xs.length)
1975
+ return null;
1976
+ const s = [...xs].sort((a, b) => a - b);
1977
+ const rank = Math.max(1, Math.ceil(p / 100 * s.length));
1978
+ return s[Math.min(s.length, rank) - 1];
1979
+ }
1980
+ var GATE_HEALTH_DEFAULTS = { historyLimit: 20 };
1981
+ function gateHealth(samples, opts = {}) {
1982
+ const o = { ...GATE_HEALTH_DEFAULTS, ...opts };
1983
+ const byGate = new Map;
1984
+ for (const r of samples) {
1985
+ const list = byGate.get(r.gate);
1986
+ if (list)
1987
+ list.push(r);
1988
+ else
1989
+ byGate.set(r.gate, [r]);
1990
+ }
1991
+ const gates = [];
1992
+ for (const [gate, runsUnsorted] of byGate) {
1993
+ const runs = [...runsUnsorted].sort((a, b) => ms2(a.at) - ms2(b.at));
1994
+ const passes = runs.filter((r) => r.verdict === "pass").length;
1995
+ const byTask = new Map;
1996
+ for (const r of runs) {
1997
+ const key = `${r.projectId ?? ""}\x00${r.task}`;
1998
+ const list = byTask.get(key);
1999
+ if (list)
2000
+ list.push(r);
2001
+ else
2002
+ byTask.set(key, [r]);
2003
+ }
2004
+ let flips = 0;
2005
+ let flakyTasks = 0;
2006
+ for (const list of byTask.values()) {
2007
+ let changed = 0;
2008
+ for (let i = 1;i < list.length; i++)
2009
+ if (list[i].verdict !== list[i - 1].verdict)
2010
+ changed++;
2011
+ flips += changed;
2012
+ if (list.some((r) => r.verdict === "pass") && list.some((r) => r.verdict === "fail"))
2013
+ flakyTasks++;
2014
+ }
2015
+ const timed = runs.map((r) => r.durationMs).filter((d) => typeof d === "number");
2016
+ const last = runs.at(-1);
2017
+ gates.push({
2018
+ gate,
2019
+ runs: runs.length,
2020
+ passes,
2021
+ fails: runs.length - passes,
2022
+ passRate: passes / runs.length,
2023
+ flips,
2024
+ flakyTasks,
2025
+ flaky: flakyTasks > 0,
2026
+ p50Ms: percentile(timed, 50),
2027
+ p95Ms: percentile(timed, 95),
2028
+ maxMs: timed.length ? Math.max(...timed) : null,
2029
+ totalMs: timed.reduce((a, b) => a + b, 0),
2030
+ timedRuns: timed.length,
2031
+ lastVerdict: last?.verdict ?? null,
2032
+ lastAt: last?.at ?? null,
2033
+ history: runs.slice(-o.historyLimit).reverse().map((r) => ({ verdict: r.verdict, at: r.at, task: r.task, durationMs: r.durationMs }))
2034
+ });
2035
+ }
2036
+ gates.sort((a, b) => Number(b.flaky) - Number(a.flaky) || b.flakyTasks - a.flakyTasks || b.totalMs - a.totalMs);
2037
+ return {
2038
+ gates,
2039
+ totals: {
2040
+ gates: gates.length,
2041
+ runs: samples.length,
2042
+ fails: gates.reduce((a, g) => a + g.fails, 0),
2043
+ flakyGates: gates.filter((g) => g.flaky).length,
2044
+ totalMs: gates.reduce((a, g) => a + g.totalMs, 0)
2045
+ }
2046
+ };
2047
+ }
1446
2048
  // packages/core/src/gates.ts
1447
2049
  var NAME_RE2 = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
1448
2050
  function validateGateRun(input) {
@@ -1502,7 +2104,196 @@ function executedGateInput(task, gate, cmd, outcome) {
1502
2104
  gate,
1503
2105
  verdict: outcome.exitCode === 0 && !outcome.timedOut ? "pass" : "fail",
1504
2106
  rubric: `ran \`${cmd}\` \u2014 ${how} in ${(outcome.durationMs / 1000).toFixed(1)}s`,
1505
- evidence: evidenceTail(outcome.output) || null
2107
+ evidence: evidenceTail(outcome.output) || null,
2108
+ durationMs: outcome.durationMs
2109
+ };
2110
+ }
2111
+ // packages/core/src/graphs.ts
2112
+ function collisionGraph(rows, writeTools = WRITE_TOOLS) {
2113
+ const files = new Map;
2114
+ const sessions = new Map;
2115
+ for (const r of rows) {
2116
+ if (!r.path || !r.sessionId)
2117
+ continue;
2118
+ const f = files.get(r.path) ?? { readers: new Set, writers: new Set };
2119
+ const s = sessions.get(r.sessionId) ?? { files: new Set, writes: 0 };
2120
+ if (writeTools.has(r.tool)) {
2121
+ f.writers.add(r.sessionId);
2122
+ s.writes++;
2123
+ } else
2124
+ f.readers.add(r.sessionId);
2125
+ s.files.add(r.path);
2126
+ files.set(r.path, f);
2127
+ sessions.set(r.sessionId, s);
2128
+ }
2129
+ const out = [...files.entries()].map(([path, f]) => {
2130
+ const writers = [...f.writers].sort();
2131
+ const readers = [...f.readers].filter((id) => !f.writers.has(id)).sort();
2132
+ const touchers = writers.length + readers.length;
2133
+ return { path, readers, writers, contested: touchers >= 2 && writers.length >= 1 };
2134
+ });
2135
+ out.sort((a, b) => Number(b.contested) - Number(a.contested) || b.readers.length + b.writers.length - (a.readers.length + a.writers.length) || a.path.localeCompare(b.path));
2136
+ return {
2137
+ sessions: [...sessions.entries()].map(([id, s]) => ({
2138
+ id,
2139
+ files: s.files.size,
2140
+ writes: s.writes
2141
+ })),
2142
+ files: out,
2143
+ contested: out.filter((f) => f.contested).length
2144
+ };
2145
+ }
2146
+ // packages/core/src/worktree.ts
2147
+ import { join as join3 } from "path";
2148
+ function planBootstrap(cfg, repoRoot, worktree) {
2149
+ const seen = new Set;
2150
+ const copies = [];
2151
+ for (const raw of cfg.worktree.copy) {
2152
+ if (!isRepoRelative(raw))
2153
+ continue;
2154
+ const rel = raw.trim().replace(/^\.\//, "");
2155
+ if (seen.has(rel))
2156
+ continue;
2157
+ seen.add(rel);
2158
+ copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
2159
+ }
2160
+ return { copies, setup: cfg.worktree.setup };
2161
+ }
2162
+ var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
2163
+ function summarizeBootstrap(o) {
2164
+ const parts = [];
2165
+ if (o.copied.length)
2166
+ parts.push(`copied ${o.copied.join(", ")}`);
2167
+ if (o.skipped.length)
2168
+ parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
2169
+ if (o.setup)
2170
+ parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
2171
+ return parts.join("; ") || "nothing to do";
2172
+ }
2173
+ function canRemoveWorktree(w, heldByClaim, force) {
2174
+ if (w.main)
2175
+ return { ok: false, reason: "main" };
2176
+ if (heldByClaim)
2177
+ return { ok: false, reason: "held" };
2178
+ if (force)
2179
+ return { ok: true };
2180
+ if (w.dirty > 0)
2181
+ return { ok: false, reason: "dirty" };
2182
+ if (w.ahead > 0)
2183
+ return { ok: false, reason: "unpushed" };
2184
+ return { ok: true };
2185
+ }
2186
+ function removeRefusalMessage(reason, path, task) {
2187
+ switch (reason) {
2188
+ case "main":
2189
+ return `${path} is the main checkout \u2014 it is never removed`;
2190
+ case "held":
2191
+ return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
2192
+ case "dirty":
2193
+ return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
2194
+ case "unpushed":
2195
+ return `${path} has unpushed commits \u2014 push them, or --force to discard`;
2196
+ }
2197
+ }
2198
+ function planGc(worktrees, claims) {
2199
+ const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
2200
+ const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
2201
+ const out = [];
2202
+ for (const w of worktrees) {
2203
+ if (w.main || held.has(w.path))
2204
+ continue;
2205
+ const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
2206
+ if (!why)
2207
+ continue;
2208
+ const can = canRemoveWorktree(w, null, false);
2209
+ out.push({
2210
+ path: w.path,
2211
+ branch: w.branch,
2212
+ why,
2213
+ removable: can.ok,
2214
+ blocker: can.ok ? null : can.reason
2215
+ });
2216
+ }
2217
+ return out;
2218
+ }
2219
+
2220
+ // packages/core/src/hygiene.ts
2221
+ var HYGIENE_DEFAULTS = {
2222
+ staleDays: 7,
2223
+ abandonedDays: 30,
2224
+ hungryRssKb: 1024 * 1024,
2225
+ heavyKb: 2 * 1024 * 1024
2226
+ };
2227
+ var days2 = (ms3) => ms3 / 86400000;
2228
+ function classifyProcess(p, opts = {}) {
2229
+ const o = { ...HYGIENE_DEFAULTS, ...opts };
2230
+ if (!p.alive)
2231
+ return {
2232
+ ...p,
2233
+ issue: "dead",
2234
+ note: `pid ${p.pid} is gone but the registry still lists it${p.port ? ` holding port ${p.port}` : ""}`,
2235
+ reclaimable: true
2236
+ };
2237
+ if (p.sessionId && !p.sessionLive)
2238
+ return {
2239
+ ...p,
2240
+ issue: "orphaned",
2241
+ note: `still running after its session ended${p.port ? ` \u2014 port ${p.port} stays taken` : ""}`,
2242
+ reclaimable: true
2243
+ };
2244
+ if (p.rssKb !== null && p.rssKb >= o.hungryRssKb)
2245
+ return {
2246
+ ...p,
2247
+ issue: "hungry",
2248
+ note: `holding ${Math.round(p.rssKb / 1024)} MB of RSS`,
2249
+ reclaimable: false
2250
+ };
2251
+ return { ...p, issue: null, note: null, reclaimable: false };
2252
+ }
2253
+ function classifyWorktree(w, opts = {}) {
2254
+ const o = { ...HYGIENE_DEFAULTS, ...opts };
2255
+ const kb = w.diskKb ?? 0;
2256
+ const done = (issue, note, reclaimable) => ({
2257
+ ...w,
2258
+ issue,
2259
+ note,
2260
+ reclaimable,
2261
+ reclaimableKb: reclaimable ? kb : 0
2262
+ });
2263
+ if (w.main)
2264
+ return done(null, null, false);
2265
+ const removable = canRemoveWorktree(w, w.heldByClaim, false).ok;
2266
+ const inUse = w.liveSessions > 0 || w.heldByClaim !== null;
2267
+ const unsafe = w.dirty > 0 || w.ahead > 0;
2268
+ const idleDays = w.idleMs === null ? null : days2(w.idleMs);
2269
+ if (w.merged && !inUse && removable && idleDays !== null && idleDays >= o.staleDays)
2270
+ return done("stale", `merged and untouched for ${Math.floor(idleDays)} days${kb ? ` \u2014 ${Math.round(kb / 1024)} MB to reclaim` : ""}`, true);
2271
+ if (!w.merged && !inUse && idleDays !== null && idleDays >= o.abandonedDays)
2272
+ return done("abandoned", unsafe ? `untouched for ${Math.floor(idleDays)} days with ${w.dirty > 0 ? "uncommitted" : "unpushed"} work \u2014 review before removing` : `untouched for ${Math.floor(idleDays)} days and never merged`, false);
2273
+ if (kb >= o.heavyKb)
2274
+ return done("heavy", `${Math.round(kb / 1024)} MB on disk`, false);
2275
+ return done(null, null, false);
2276
+ }
2277
+ function hygieneReport(procs, worktrees, opts = {}) {
2278
+ const processes = procs.map((p) => classifyProcess(p, opts));
2279
+ const trees = worktrees.map((w) => classifyWorktree(w, opts));
2280
+ const issues = processes.filter((p) => p.issue).length + trees.filter((w) => w.issue).length;
2281
+ const rank = (i) => i ? 0 : 1;
2282
+ processes.sort((a, b) => rank(a.issue) - rank(b.issue) || (b.rssKb ?? 0) - (a.rssKb ?? 0));
2283
+ trees.sort((a, b) => rank(a.issue) - rank(b.issue) || (b.diskKb ?? 0) - (a.diskKb ?? 0));
2284
+ return {
2285
+ processes,
2286
+ worktrees: trees,
2287
+ totals: {
2288
+ processes: processes.length,
2289
+ deadProcesses: processes.filter((p) => p.issue === "dead").length,
2290
+ orphanedProcesses: processes.filter((p) => p.issue === "orphaned").length,
2291
+ worktrees: trees.length,
2292
+ staleWorktrees: trees.filter((w) => w.issue === "stale").length,
2293
+ diskKb: trees.reduce((a, w) => a + (w.diskKb ?? 0), 0),
2294
+ reclaimableKb: trees.reduce((a, w) => a + w.reclaimableKb, 0),
2295
+ issues
2296
+ }
1506
2297
  };
1507
2298
  }
1508
2299
  // packages/core/src/ledger.ts
@@ -1712,6 +2503,250 @@ function incidentKey(inc) {
1712
2503
  return `protected_ports:${portsIn(inc.command).join(",")}`;
1713
2504
  return inc.rule;
1714
2505
  }
2506
+ // packages/core/src/lineage.ts
2507
+ var LINEAGE_MAX_NODES = 40;
2508
+ var LINEAGE_FANOUT = 4;
2509
+ var groupId = (parent, kind) => `group:${parent}:${kind}`;
2510
+ function collapseFanout(sessions, edges, fanout, expanded) {
2511
+ const fans = new Map;
2512
+ for (const e of edges) {
2513
+ const k = groupId(e.from, e.kind);
2514
+ const list = fans.get(k) ?? [];
2515
+ list.push(e);
2516
+ fans.set(k, list);
2517
+ }
2518
+ const collapse = [...fans.entries()].filter(([id, list]) => list.length > fanout && !expanded.has(id));
2519
+ if (!collapse.length)
2520
+ return { sessions, edges, sizes: new Map };
2521
+ const hidden = new Set;
2522
+ const dropped = new Set;
2523
+ const extraSessions = [];
2524
+ const extraEdges = [];
2525
+ const sizes = new Map;
2526
+ for (const [id, list] of collapse) {
2527
+ const first = list[0];
2528
+ const parent = sessions.find((x) => x.id === first.from);
2529
+ sizes.set(id, list.length);
2530
+ for (const e of list) {
2531
+ dropped.add(e);
2532
+ hidden.add(e.to);
2533
+ }
2534
+ extraSessions.push({
2535
+ id,
2536
+ projectId: parent?.projectId ?? null,
2537
+ title: `${list.length} ${first.kind}${list.length === 1 ? "" : "s"}`,
2538
+ agent: parent?.agent ?? null,
2539
+ kind: "group",
2540
+ state: "ended",
2541
+ startedAt: first.at,
2542
+ endedAt: null,
2543
+ costUsd: null,
2544
+ outcome: null
2545
+ });
2546
+ extraEdges.push({ from: first.from, to: id, kind: first.kind, at: first.at });
2547
+ }
2548
+ const stillLinked = new Set;
2549
+ for (const e of edges) {
2550
+ if (dropped.has(e))
2551
+ continue;
2552
+ stillLinked.add(e.from);
2553
+ stillLinked.add(e.to);
2554
+ }
2555
+ const keptSessions = sessions.filter((x) => !hidden.has(x.id) || stillLinked.has(x.id));
2556
+ const keptEdges = edges.filter((e) => !dropped.has(e));
2557
+ return {
2558
+ sessions: [...keptSessions, ...extraSessions],
2559
+ edges: [...keptEdges, ...extraEdges],
2560
+ sizes
2561
+ };
2562
+ }
2563
+ function handoffEdges(holds) {
2564
+ const byTask = new Map;
2565
+ for (const h of holds) {
2566
+ if (!h.sessionId)
2567
+ continue;
2568
+ const key = `${h.projectId ?? ""}\x00${h.task}`;
2569
+ const list = byTask.get(key) ?? [];
2570
+ list.push({ sessionId: h.sessionId, at: h.at, task: h.task });
2571
+ byTask.set(key, list);
2572
+ }
2573
+ const out = [];
2574
+ for (const list of byTask.values()) {
2575
+ list.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
2576
+ for (let i = 1;i < list.length; i++) {
2577
+ const prev = list[i - 1];
2578
+ const cur = list[i];
2579
+ if (prev.sessionId === cur.sessionId)
2580
+ continue;
2581
+ out.push({
2582
+ from: prev.sessionId,
2583
+ to: cur.sessionId,
2584
+ kind: "handoff",
2585
+ at: cur.at,
2586
+ label: cur.task
2587
+ });
2588
+ }
2589
+ }
2590
+ return out;
2591
+ }
2592
+ function lineageGraph(sessions, edges, opts = {}) {
2593
+ const maxNodes = opts.maxNodes ?? LINEAGE_MAX_NODES;
2594
+ const known = new Map(sessions.map((s) => [s.id, s]));
2595
+ const seen = new Set;
2596
+ const usable = [];
2597
+ for (const e of edges) {
2598
+ if (!known.has(e.from) || !known.has(e.to) || e.from === e.to)
2599
+ continue;
2600
+ const key = JSON.stringify([e.from, e.to, e.kind]);
2601
+ if (seen.has(key))
2602
+ continue;
2603
+ seen.add(key);
2604
+ usable.push(e);
2605
+ }
2606
+ const collapsed = collapseFanout(sessions, usable, opts.fanout ?? LINEAGE_FANOUT, new Set(opts.expanded ?? []));
2607
+ const shownSessions = collapsed.sessions;
2608
+ const shownEdges = collapsed.edges;
2609
+ const groupSizes = collapsed.sizes;
2610
+ const byId = new Map(shownSessions.map((x) => [x.id, x]));
2611
+ const involved = new Set;
2612
+ for (const e of shownEdges) {
2613
+ involved.add(e.from);
2614
+ involved.add(e.to);
2615
+ }
2616
+ const all = shownSessions.filter((s) => involved.has(s.id));
2617
+ const deg0 = new Map;
2618
+ for (const e of shownEdges) {
2619
+ deg0.set(e.from, (deg0.get(e.from) ?? 0) + 1);
2620
+ deg0.set(e.to, (deg0.get(e.to) ?? 0) + 1);
2621
+ }
2622
+ const nodes = all.length <= maxNodes ? all : [...all].sort((a, b) => (deg0.get(b.id) ?? 0) - (deg0.get(a.id) ?? 0) || a.id.localeCompare(b.id)).slice(0, maxNodes);
2623
+ const truncated = all.length - nodes.length;
2624
+ if (!nodes.length)
2625
+ return {
2626
+ nodes: [],
2627
+ edges: [],
2628
+ width: 0,
2629
+ height: 0,
2630
+ roots: 0,
2631
+ byKind: { subagent: 0, dispatch: 0, message: 0, handoff: 0 },
2632
+ truncated: 0
2633
+ };
2634
+ const kept = new Set(nodes.map((s) => s.id));
2635
+ const shown = shownEdges.filter((e) => kept.has(e.from) && kept.has(e.to));
2636
+ const layout = layoutDag(nodes.map((s) => ({ id: s.id })), shown.map((e) => ({ from: e.from, to: e.to })), opts);
2637
+ const degree = new Map;
2638
+ for (const e of shown) {
2639
+ degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
2640
+ degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
2641
+ }
2642
+ const backSet = new Set(layout.edges.filter((e) => e.back).map((e) => JSON.stringify([e.from, e.to])));
2643
+ const byKind = {
2644
+ subagent: 0,
2645
+ dispatch: 0,
2646
+ message: 0,
2647
+ handoff: 0
2648
+ };
2649
+ for (const e of shown)
2650
+ byKind[e.kind]++;
2651
+ return {
2652
+ nodes: layout.nodes.map((p) => {
2653
+ const s = byId.get(p.id);
2654
+ const size = groupSizes.get(p.id) ?? null;
2655
+ const parts = size ? p.id.split(":") : null;
2656
+ return {
2657
+ ...s,
2658
+ layer: p.layer,
2659
+ order: p.order,
2660
+ x: p.x,
2661
+ y: p.y,
2662
+ degree: degree.get(p.id) ?? 0,
2663
+ groupSize: size,
2664
+ groupOf: parts ? parts.slice(1, -1).join(":") ?? null : null,
2665
+ groupKind: parts ? parts.at(-1) ?? null : null
2666
+ };
2667
+ }),
2668
+ edges: shown.map((e) => ({ ...e, back: backSet.has(JSON.stringify([e.from, e.to])) })),
2669
+ width: layout.width,
2670
+ height: layout.height,
2671
+ roots: layout.nodes.filter((p) => p.layer === 0).length,
2672
+ byKind,
2673
+ truncated
2674
+ };
2675
+ }
2676
+ // packages/core/src/mcphealth.ts
2677
+ var BUILTIN = "builtin";
2678
+ var MCP_HEALTH_DEFAULTS = { toolLimit: 6 };
2679
+ function serverOf(tool) {
2680
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(tool);
2681
+ return m?.[1] ?? BUILTIN;
2682
+ }
2683
+ function toolOf(tool) {
2684
+ return tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, "");
2685
+ }
2686
+ var stat = (tool, calls) => {
2687
+ const timed = calls.map((c) => c.ms).filter((m) => typeof m === "number");
2688
+ return {
2689
+ tool,
2690
+ calls: calls.length,
2691
+ errors: calls.filter((c) => c.errored).length,
2692
+ p50Ms: percentile(timed, 50),
2693
+ p95Ms: percentile(timed, 95),
2694
+ maxMs: timed.length ? Math.max(...timed) : null,
2695
+ totalMs: timed.reduce((a, b) => a + b, 0)
2696
+ };
2697
+ };
2698
+ function mcpHealth(calls, opts = {}) {
2699
+ const o = { ...MCP_HEALTH_DEFAULTS, ...opts };
2700
+ const byServer = new Map;
2701
+ for (const c of calls) {
2702
+ const s = serverOf(c.tool);
2703
+ const list = byServer.get(s);
2704
+ if (list)
2705
+ list.push(c);
2706
+ else
2707
+ byServer.set(s, [c]);
2708
+ }
2709
+ const servers = [];
2710
+ for (const [server, list] of byServer) {
2711
+ const byTool = new Map;
2712
+ for (const c of list) {
2713
+ const t = toolOf(c.tool);
2714
+ const tl = byTool.get(t);
2715
+ if (tl)
2716
+ tl.push(c);
2717
+ else
2718
+ byTool.set(t, [c]);
2719
+ }
2720
+ const tools = [...byTool.entries()].map(([t, cs]) => stat(t, cs)).sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool)).slice(0, o.toolLimit);
2721
+ const all = stat(server, list);
2722
+ servers.push({
2723
+ server,
2724
+ mcp: server !== BUILTIN,
2725
+ calls: all.calls,
2726
+ errors: all.errors,
2727
+ errorRate: all.calls ? all.errors / all.calls : 0,
2728
+ unanswered: list.filter((c) => c.ms === null).length,
2729
+ sessions: new Set(list.map((c) => c.sessionId)).size,
2730
+ p50Ms: all.p50Ms,
2731
+ p95Ms: all.p95Ms,
2732
+ maxMs: all.maxMs,
2733
+ totalMs: all.totalMs,
2734
+ tools,
2735
+ lastAt: list.map((c) => c.at).sort().at(-1) ?? null
2736
+ });
2737
+ }
2738
+ servers.sort((a, b) => Number(b.mcp) - Number(a.mcp) || b.totalMs - a.totalMs || b.calls - a.calls);
2739
+ return {
2740
+ servers,
2741
+ totals: {
2742
+ servers: servers.filter((s) => s.mcp).length,
2743
+ calls: calls.length,
2744
+ errors: servers.reduce((a, s) => a + s.errors, 0),
2745
+ totalMs: servers.reduce((a, s) => a + s.totalMs, 0),
2746
+ mcpMs: servers.filter((s) => s.mcp).reduce((a, s) => a + s.totalMs, 0)
2747
+ }
2748
+ };
2749
+ }
1715
2750
  // packages/core/src/memory.ts
1716
2751
  var MEMORY_KINDS = ["handoff", "incident", "gate", "session"];
1717
2752
  function handoffDoc(projectId, id, h, sessionId) {
@@ -1829,15 +2864,101 @@ function parseTo(to) {
1829
2864
  return { kind: "session", id: t };
1830
2865
  return { kind: "task", task: t };
1831
2866
  }
1832
- function formatMessages(ms) {
1833
- if (!ms.length)
2867
+ function formatMessages(ms3) {
2868
+ if (!ms3.length)
1834
2869
  return null;
1835
- const lines = ms.map((m) => `- from ${m.from ?? "unknown"}${m.task ? ` (re ${m.task})` : ""}: ${m.text}`);
1836
- return `[swarm] While you were working, message${ms.length === 1 ? "" : "s"} arrived:
2870
+ const lines = ms3.map((m) => `- from ${m.from ?? "unknown"}${m.task ? ` (re ${m.task})` : ""}: ${m.text}`);
2871
+ return `[swarm] While you were working, message${ms3.length === 1 ? "" : "s"} arrived:
1837
2872
  ${lines.join(`
1838
2873
  `)}
1839
2874
  Reply with swarm_send if a reply is expected.`;
1840
2875
  }
2876
+ // packages/core/src/outcomes.ts
2877
+ var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
2878
+ var median = (xs) => {
2879
+ if (!xs.length)
2880
+ return null;
2881
+ const s = [...xs].sort((a, b) => a - b);
2882
+ const mid = Math.floor(s.length / 2);
2883
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
2884
+ };
2885
+ function scorecard(key, rows) {
2886
+ const merged = rows.filter((r) => r.outcome === "merged");
2887
+ const reverted = rows.filter((r) => r.outcome === "reverted");
2888
+ const open = rows.filter((r) => r.outcome === "open");
2889
+ const noPr = rows.filter((r) => r.outcome === "no-pr");
2890
+ const finished = merged.length + reverted.length + noPr.length;
2891
+ const mergedCost = merged.reduce((a, r) => a + r.costUsd, 0);
2892
+ return {
2893
+ key,
2894
+ branches: rows.length,
2895
+ merged: merged.length,
2896
+ reverted: reverted.length,
2897
+ open: open.length,
2898
+ noPr: noPr.length,
2899
+ mergeRate: finished ? merged.length / finished : null,
2900
+ medianLeadHours: median(merged.map((r) => r.leadHours).filter((x) => x != null)),
2901
+ costPerMerge: merged.length ? mergedCost / merged.length : null
2902
+ };
2903
+ }
2904
+ function outcomeReport(sessions, prs, revertedShas) {
2905
+ const byBranch = new Map;
2906
+ for (const s of sessions) {
2907
+ if (!s.branch || DEFAULT_BRANCHES.has(s.branch))
2908
+ continue;
2909
+ const a = byBranch.get(s.branch) ?? [];
2910
+ a.push(s);
2911
+ byBranch.set(s.branch, a);
2912
+ }
2913
+ const prByBranch = new Map;
2914
+ for (const pr of prs) {
2915
+ const prev = prByBranch.get(pr.branch);
2916
+ if (!prev || pr.state === "merged" && prev.state !== "merged" || pr.state === prev.state && pr.number > prev.number)
2917
+ prByBranch.set(pr.branch, pr);
2918
+ }
2919
+ const rows = [...byBranch.entries()].map(([branch, ss]) => {
2920
+ const dominant = [...ss].sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0) || a.startedAt.localeCompare(b.startedAt))[0];
2921
+ const pr = prByBranch.get(branch) ?? null;
2922
+ const wasReverted = (sha) => {
2923
+ if (!sha)
2924
+ return false;
2925
+ const s = sha.toLowerCase();
2926
+ for (const r of revertedShas)
2927
+ if (s.startsWith(r) || r.startsWith(s))
2928
+ return true;
2929
+ return false;
2930
+ };
2931
+ const outcome = !pr ? "no-pr" : pr.state === "open" ? "open" : wasReverted(pr.mergeSha) ? "reverted" : "merged";
2932
+ const firstStart = ss.map((s) => s.startedAt).sort()[0];
2933
+ const leadHours = outcome === "merged" && pr?.mergedAt ? Math.max(0, (new Date(pr.mergedAt).getTime() - new Date(firstStart).getTime()) / 3600000) : null;
2934
+ return {
2935
+ branch,
2936
+ outcome,
2937
+ prNumber: pr?.number ?? null,
2938
+ title: pr?.title ?? null,
2939
+ url: pr?.url ?? null,
2940
+ mergedAt: pr?.mergedAt ?? null,
2941
+ leadHours,
2942
+ sessions: ss.map((s) => s.id),
2943
+ model: dominant.model,
2944
+ agent: dominant.agent,
2945
+ costUsd: ss.reduce((a, s) => a + (s.costUsd ?? 0), 0)
2946
+ };
2947
+ });
2948
+ rows.sort((a, b) => (b.mergedAt ?? "").localeCompare(a.mergedAt ?? "") || a.branch.localeCompare(b.branch));
2949
+ const group = (key) => {
2950
+ const m = new Map;
2951
+ for (const r of rows) {
2952
+ const k = key(r) ?? "unknown";
2953
+ m.set(k, [...m.get(k) ?? [], r]);
2954
+ }
2955
+ return [...m.entries()].map(([k, rs]) => scorecard(k, rs)).sort((a, b) => b.branches - a.branches);
2956
+ };
2957
+ return { branches: rows, byModel: group((r) => r.model), byAgent: group((r) => r.agent) };
2958
+ }
2959
+ function parseReverts(gitLog) {
2960
+ return new Set([...gitLog.matchAll(/This reverts commit ([0-9a-f]{7,40})/gi)].map((m) => m[1].toLowerCase()));
2961
+ }
1841
2962
  // packages/core/src/policy.ts
1842
2963
  import { createHash } from "crypto";
1843
2964
  var HOOK_MARK = "swarm-hook";
@@ -2022,6 +3143,147 @@ function projectIdentity(opts) {
2022
3143
  const name = parts[parts.length - 1] ?? opts.root;
2023
3144
  return { id: `p_${fnv1a(key)}`, root: opts.root, commonDir: opts.commonDir, name };
2024
3145
  }
3146
+ // packages/core/src/provenance.ts
3147
+ var SEVERITY = {
3148
+ "no-task": 0,
3149
+ "open-pr": 1,
3150
+ "no-pr": 2,
3151
+ "no-branch": 3,
3152
+ "no-session": 4,
3153
+ unclaimed: 5,
3154
+ complete: 6
3155
+ };
3156
+ var severity = (b) => SEVERITY[b ?? "complete"];
3157
+ function breakOf(l) {
3158
+ if (!l.task)
3159
+ return "no-task";
3160
+ if (!l.claim)
3161
+ return "unclaimed";
3162
+ if (!l.session)
3163
+ return "no-session";
3164
+ if (!l.branch)
3165
+ return "no-branch";
3166
+ if (!l.pr)
3167
+ return "no-pr";
3168
+ if (!l.merged)
3169
+ return "open-pr";
3170
+ return null;
3171
+ }
3172
+ function provenance(tasks, claims, sessions, branches) {
3173
+ const claimsByTask = new Map;
3174
+ for (const c of claims) {
3175
+ const list = claimsByTask.get(c.task);
3176
+ if (list)
3177
+ list.push(c);
3178
+ else
3179
+ claimsByTask.set(c.task, [c]);
3180
+ }
3181
+ const branchRow = new Map(branches.map((b) => [b.branch, b]));
3182
+ const sessionsByBranch = new Map;
3183
+ for (const s of sessions) {
3184
+ if (!s.branch)
3185
+ continue;
3186
+ const list = sessionsByBranch.get(s.branch);
3187
+ if (list)
3188
+ list.push(s);
3189
+ else
3190
+ sessionsByBranch.set(s.branch, [s]);
3191
+ }
3192
+ const chains = [];
3193
+ for (const t of tasks) {
3194
+ const cs = (claimsByTask.get(t.id) ?? []).sort((a, b) => new Date(a.acquiredAt).getTime() - new Date(b.acquiredAt).getTime());
3195
+ const branch = cs.map((c) => c.branch).find((b) => !!b) ?? null;
3196
+ const worktree = cs.map((c) => c.worktree).find((w) => !!w) ?? null;
3197
+ const row = branch ? branchRow.get(branch) : undefined;
3198
+ const byId = new Map;
3199
+ for (const s of branch ? sessionsByBranch.get(branch) ?? [] : [])
3200
+ byId.set(s.id, s);
3201
+ for (const c of cs) {
3202
+ if (!c.sessionId || byId.has(c.sessionId))
3203
+ continue;
3204
+ const hit = sessions.find((s) => s.id === c.sessionId);
3205
+ if (hit)
3206
+ byId.set(hit.id, hit);
3207
+ }
3208
+ const sess = [...byId.values()];
3209
+ const links = {
3210
+ task: true,
3211
+ claim: cs.length > 0,
3212
+ session: sess.length > 0,
3213
+ branch: !!branch,
3214
+ pr: !!row?.prNumber,
3215
+ merged: row?.outcome === "merged"
3216
+ };
3217
+ chains.push({
3218
+ task: t.id,
3219
+ fromTask: true,
3220
+ title: t.title,
3221
+ status: t.status,
3222
+ url: t.url ?? null,
3223
+ holders: [...new Set(cs.map((c) => c.owner).filter((o) => !!o))],
3224
+ claimedAt: cs[0]?.acquiredAt ?? null,
3225
+ worktree,
3226
+ branch,
3227
+ sessions: sess,
3228
+ costUsd: row?.costUsd ?? sess.reduce((a, s) => a + (s.costUsd ?? 0), 0),
3229
+ prNumber: row?.prNumber ?? null,
3230
+ prUrl: row?.url ?? null,
3231
+ outcome: row?.outcome ?? null,
3232
+ mergedAt: row?.mergedAt ?? null,
3233
+ leadHours: row?.leadHours ?? null,
3234
+ links,
3235
+ depth: Object.values(links).filter(Boolean).length,
3236
+ brokenAt: breakOf(links)
3237
+ });
3238
+ }
3239
+ const explained = new Set(chains.map((c) => c.branch).filter((b) => !!b));
3240
+ for (const b of branches) {
3241
+ if (explained.has(b.branch))
3242
+ continue;
3243
+ const links = {
3244
+ task: false,
3245
+ claim: false,
3246
+ session: b.sessions.length > 0,
3247
+ branch: true,
3248
+ pr: !!b.prNumber,
3249
+ merged: b.outcome === "merged"
3250
+ };
3251
+ chains.push({
3252
+ task: b.branch,
3253
+ fromTask: false,
3254
+ title: b.title ?? b.branch,
3255
+ status: b.outcome,
3256
+ url: null,
3257
+ holders: [],
3258
+ claimedAt: null,
3259
+ worktree: null,
3260
+ branch: b.branch,
3261
+ sessions: b.sessions.map((id) => sessions.find((s) => s.id === id)).filter((s) => !!s),
3262
+ costUsd: b.costUsd,
3263
+ prNumber: b.prNumber,
3264
+ prUrl: b.url,
3265
+ outcome: b.outcome,
3266
+ mergedAt: b.mergedAt,
3267
+ leadHours: b.leadHours,
3268
+ links,
3269
+ depth: Object.values(links).filter(Boolean).length,
3270
+ brokenAt: "no-task"
3271
+ });
3272
+ }
3273
+ chains.sort((a, b) => severity(a.brokenAt) - severity(b.brokenAt) || b.costUsd - a.costUsd || a.task.localeCompare(b.task));
3274
+ return {
3275
+ chains,
3276
+ totals: {
3277
+ tasks: chains.length,
3278
+ complete: chains.filter((c) => c.brokenAt === null).length,
3279
+ broken: chains.filter((c) => c.brokenAt !== null).length,
3280
+ unclaimed: chains.filter((c) => c.brokenAt === "unclaimed").length,
3281
+ noPr: chains.filter((c) => c.brokenAt === "no-pr").length,
3282
+ untracked: chains.filter((c) => c.brokenAt === "no-task").length,
3283
+ costUsd: chains.reduce((a, c) => a + c.costUsd, 0)
3284
+ }
3285
+ };
3286
+ }
2025
3287
  // packages/core/src/questions.ts
2026
3288
  function validateQuestion(text, options) {
2027
3289
  const t = typeof text === "string" ? text.trim() : "";
@@ -2178,6 +3440,49 @@ ${lines.join(`
2178
3440
  `)}` : ""}`
2179
3441
  };
2180
3442
  }
3443
+ // packages/core/src/stall.ts
3444
+ var STALL_DEFAULTS = { window: 12, repeat: 3, repeatErrors: 2, errors: 4 };
3445
+ function toolResponseErrored(resp) {
3446
+ if (typeof resp === "string")
3447
+ return /^\s*error[:\s]/i.test(resp);
3448
+ if (!resp || typeof resp !== "object")
3449
+ return false;
3450
+ const r = resp;
3451
+ if (r.is_error === true || r.isError === true)
3452
+ return true;
3453
+ if (r.success === false)
3454
+ return true;
3455
+ if (r.interrupted === true)
3456
+ return true;
3457
+ if (typeof r.error === "string" && r.error.length > 0)
3458
+ return true;
3459
+ return false;
3460
+ }
3461
+ function detectStall(calls, opts = {}) {
3462
+ const o = { ...STALL_DEFAULTS, ...opts };
3463
+ const tail = calls.slice(-o.window);
3464
+ const last = tail.at(-1);
3465
+ if (!last)
3466
+ return null;
3467
+ let run = 0;
3468
+ let runErrors = 0;
3469
+ for (let i = tail.length - 1;i >= 0; i--) {
3470
+ const c = tail[i];
3471
+ if (!c || c.tool !== last.tool || c.input !== last.input)
3472
+ break;
3473
+ run++;
3474
+ if (c.errored)
3475
+ runErrors++;
3476
+ }
3477
+ if (run >= o.repeat && runErrors >= o.repeatErrors)
3478
+ return { kind: "repeat", reason: `repeating a failing ${last.tool} call \xD7${run}` };
3479
+ let streak = 0;
3480
+ for (let i = tail.length - 1;i >= 0 && tail[i]?.errored; i--)
3481
+ streak++;
3482
+ if (streak >= o.errors)
3483
+ return { kind: "errors", reason: `${streak} tool calls failing in a row` };
3484
+ return null;
3485
+ }
2181
3486
  // packages/core/src/tasks.ts
2182
3487
  var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
2183
3488
  var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
@@ -2337,88 +3642,157 @@ function linearIssuesQuery(teamKey, first = 200) {
2337
3642
  inverseRelations { nodes { type issue { identifier } } }
2338
3643
  } } }`;
2339
3644
  }
2340
- // packages/core/src/worktree.ts
2341
- import { join as join3 } from "path";
2342
- function planBootstrap(cfg, repoRoot, worktree) {
2343
- const seen = new Set;
2344
- const copies = [];
2345
- for (const raw of cfg.worktree.copy) {
2346
- if (!isRepoRelative(raw))
2347
- continue;
2348
- const rel = raw.trim().replace(/^\.\//, "");
2349
- if (seen.has(rel))
2350
- continue;
2351
- seen.add(rel);
2352
- copies.push({ rel, from: join3(repoRoot, rel), to: join3(worktree, rel) });
2353
- }
2354
- return { copies, setup: cfg.worktree.setup };
2355
- }
2356
- var needsBootstrap = (plan) => plan.copies.length > 0 || plan.setup !== null;
2357
- function summarizeBootstrap(o) {
2358
- const parts = [];
2359
- if (o.copied.length)
2360
- parts.push(`copied ${o.copied.join(", ")}`);
2361
- if (o.skipped.length)
2362
- parts.push(`skipped ${o.skipped.join(", ")} (missing)`);
2363
- if (o.setup)
2364
- parts.push(`${o.setup.command} \u2192 ${o.setup.exitCode === 0 ? "ok" : `exit ${o.setup.exitCode}`} in ${(o.setup.durationMs / 1000).toFixed(1)}s`);
2365
- return parts.join("; ") || "nothing to do";
2366
- }
2367
- function canRemoveWorktree(w, heldByClaim, force) {
2368
- if (w.main)
2369
- return { ok: false, reason: "main" };
2370
- if (heldByClaim)
2371
- return { ok: false, reason: "held" };
2372
- if (force)
2373
- return { ok: true };
2374
- if (w.dirty > 0)
2375
- return { ok: false, reason: "dirty" };
2376
- if (w.ahead > 0)
2377
- return { ok: false, reason: "unpushed" };
2378
- return { ok: true };
3645
+ // packages/core/src/team.ts
3646
+ import { createPublicKey, verify as nodeVerify } from "crypto";
3647
+ function modelAllowed(model, allow) {
3648
+ if (!allow.length)
3649
+ return true;
3650
+ return allow.some((g) => {
3651
+ const re = new RegExp(`^${g.trim().split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
3652
+ return re.test(model);
3653
+ });
2379
3654
  }
2380
- function removeRefusalMessage(reason, path, task) {
2381
- switch (reason) {
2382
- case "main":
2383
- return `${path} is the main checkout \u2014 it is never removed`;
2384
- case "held":
2385
- return `${path} is held by claim ${task ?? "?"} \u2014 release the claim instead`;
2386
- case "dirty":
2387
- return `${path} has uncommitted changes \u2014 commit or stash them, or --force to discard`;
2388
- case "unpushed":
2389
- return `${path} has unpushed commits \u2014 push them, or --force to discard`;
3655
+ function verifyPolicySignature(toml, signatureB64, publicKeyB64) {
3656
+ try {
3657
+ return nodeVerify(null, Buffer.from(toml), createPublicKey({ key: Buffer.from(publicKeyB64, "base64"), format: "der", type: "spki" }), Buffer.from(signatureB64, "base64"));
3658
+ } catch {
3659
+ return false;
2390
3660
  }
2391
3661
  }
2392
- function planGc(worktrees, claims) {
2393
- const held = new Map(claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]));
2394
- const stale = new Set(claims.filter((c) => c.state !== "held").map((c) => c.worktree));
3662
+ function clusterProjectKey(remoteUrl) {
3663
+ if (!remoteUrl)
3664
+ return null;
3665
+ const url = remoteUrl.trim();
3666
+ const m = url.match(/^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) ?? url.match(/^(?:ssh:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](.+?)(?:\.git)?\/?$/);
3667
+ if (!m?.[1] || !m[2])
3668
+ return null;
3669
+ const host = m[1].toLowerCase();
3670
+ if (host.includes(" ") || !host.includes("."))
3671
+ return null;
3672
+ return `${host}/${m[2]}`;
3673
+ }
3674
+ // packages/core/src/waiting.ts
3675
+ var emptyByKind = () => ({
3676
+ permission: { episodes: 0, blockedMs: 0 },
3677
+ question: { episodes: 0, blockedMs: 0 },
3678
+ notification: { episodes: 0, blockedMs: 0 }
3679
+ });
3680
+ var ms3 = (iso) => new Date(iso).getTime();
3681
+ function median2(xs) {
3682
+ if (!xs.length)
3683
+ return 0;
3684
+ const s = [...xs].sort((a, b) => a - b);
3685
+ const mid = s.length >> 1;
3686
+ return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
3687
+ }
3688
+ function pairWaits(samples, now, sessionEnds = {}) {
3689
+ const nowMs = ms3(now);
3690
+ const open = new Map;
2395
3691
  const out = [];
2396
- for (const w of worktrees) {
2397
- if (w.main || held.has(w.path))
3692
+ const id = (s) => `${s.sessionId}\x00${s.kind}\x00${s.key}`;
3693
+ const rank = (p) => p === "start" ? 0 : 1;
3694
+ const ordered = [...samples].sort((a, b) => ms3(a.ts) - ms3(b.ts) || rank(a.phase) - rank(b.phase));
3695
+ for (const s of ordered) {
3696
+ const k = id(s);
3697
+ if (s.phase === "start") {
3698
+ if (!open.has(k))
3699
+ open.set(k, s);
2398
3700
  continue;
2399
- const why = w.merged ? "merged" : stale.has(w.path) ? "released-claim" : null;
2400
- if (!why)
3701
+ }
3702
+ const start = open.get(k);
3703
+ if (!start)
2401
3704
  continue;
2402
- const can = canRemoveWorktree(w, null, false);
3705
+ open.delete(k);
2403
3706
  out.push({
2404
- path: w.path,
2405
- branch: w.branch,
2406
- why,
2407
- removable: can.ok,
2408
- blocker: can.ok ? null : can.reason
3707
+ sessionId: start.sessionId,
3708
+ projectId: start.projectId ?? null,
3709
+ kind: start.kind,
3710
+ key: start.key,
3711
+ startedAt: start.ts,
3712
+ endedAt: s.ts,
3713
+ ms: Math.max(0, ms3(s.ts) - ms3(start.ts)),
3714
+ open: false,
3715
+ label: start.label ?? s.label ?? null
2409
3716
  });
2410
3717
  }
2411
- return out;
3718
+ for (const start of open.values()) {
3719
+ const ended = sessionEnds[start.sessionId];
3720
+ const until = ended ? Math.min(ms3(ended), nowMs) : nowMs;
3721
+ out.push({
3722
+ sessionId: start.sessionId,
3723
+ projectId: start.projectId ?? null,
3724
+ kind: start.kind,
3725
+ key: start.key,
3726
+ startedAt: start.ts,
3727
+ endedAt: ended ?? null,
3728
+ ms: Math.max(0, until - ms3(start.ts)),
3729
+ open: !ended,
3730
+ label: start.label ?? null
3731
+ });
3732
+ }
3733
+ return out.sort((a, b) => ms3(a.startedAt) - ms3(b.startedAt));
3734
+ }
3735
+ function waitingReport(episodes) {
3736
+ const bySession = new Map;
3737
+ for (const e of episodes) {
3738
+ const list = bySession.get(e.sessionId);
3739
+ if (list)
3740
+ list.push(e);
3741
+ else
3742
+ bySession.set(e.sessionId, [e]);
3743
+ }
3744
+ const sessions = [];
3745
+ for (const [sessionId, list] of bySession) {
3746
+ const byKind = emptyByKind();
3747
+ for (const e of list) {
3748
+ const k = byKind[e.kind];
3749
+ k.episodes++;
3750
+ k.blockedMs += e.ms;
3751
+ }
3752
+ const openEp = list.filter((e) => e.open).sort((a, b) => ms3(a.startedAt) - ms3(b.startedAt))[0];
3753
+ const durations = list.map((e) => e.ms);
3754
+ sessions.push({
3755
+ sessionId,
3756
+ projectId: list[0]?.projectId ?? null,
3757
+ episodes: list.length,
3758
+ blockedMs: durations.reduce((a, b) => a + b, 0),
3759
+ longestMs: durations.length ? Math.max(...durations) : 0,
3760
+ medianMs: median2(durations),
3761
+ openSince: openEp?.startedAt ?? null,
3762
+ openKind: openEp?.kind ?? null,
3763
+ openLabel: openEp?.label ?? null,
3764
+ byKind
3765
+ });
3766
+ }
3767
+ sessions.sort((a, b) => b.blockedMs - a.blockedMs);
3768
+ const all = episodes.map((e) => e.ms);
3769
+ const totalsByKind = emptyByKind();
3770
+ for (const e of episodes) {
3771
+ const k = totalsByKind[e.kind];
3772
+ k.episodes++;
3773
+ k.blockedMs += e.ms;
3774
+ }
3775
+ return {
3776
+ sessions,
3777
+ totals: {
3778
+ episodes: episodes.length,
3779
+ blockedMs: all.reduce((a, b) => a + b, 0),
3780
+ medianMs: median2(all),
3781
+ longestMs: all.length ? Math.max(...all) : 0,
3782
+ waitingNow: sessions.filter((s) => s.openSince).length,
3783
+ byKind: totalsByKind
3784
+ }
3785
+ };
2412
3786
  }
2413
3787
  // packages/daemon/src/app.ts
2414
- import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
3788
+ import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3, statSync as statSync2 } from "fs";
2415
3789
  import { homedir as homedir4 } from "os";
2416
- import { dirname as dirname4, join as join9 } from "path";
3790
+ import { dirname as dirname4, join as join10 } from "path";
2417
3791
  import { fileURLToPath as fileURLToPath2 } from "url";
2418
3792
 
2419
3793
  // node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
2420
3794
  var compose = (middleware, onError, onNotFound) => {
2421
- return (context, next) => {
3795
+ return (context2, next) => {
2422
3796
  let index = -1;
2423
3797
  return dispatch2(0);
2424
3798
  async function dispatch2(i) {
@@ -2431,31 +3805,31 @@ var compose = (middleware, onError, onNotFound) => {
2431
3805
  let handler;
2432
3806
  if (middleware[i]) {
2433
3807
  handler = middleware[i][0][0];
2434
- context.req.routeIndex = i;
3808
+ context2.req.routeIndex = i;
2435
3809
  } else {
2436
3810
  handler = i === middleware.length && next || undefined;
2437
3811
  }
2438
3812
  if (handler) {
2439
3813
  try {
2440
- res = await handler(context, () => dispatch2(i + 1));
3814
+ res = await handler(context2, () => dispatch2(i + 1));
2441
3815
  } catch (err) {
2442
3816
  if (err instanceof Error && onError) {
2443
- context.error = err;
2444
- res = await onError(err, context);
3817
+ context2.error = err;
3818
+ res = await onError(err, context2);
2445
3819
  isError = true;
2446
3820
  } else {
2447
3821
  throw err;
2448
3822
  }
2449
3823
  }
2450
3824
  } else {
2451
- if (context.finalized === false && onNotFound) {
2452
- res = await onNotFound(context);
3825
+ if (context2.finalized === false && onNotFound) {
3826
+ res = await onNotFound(context2);
2453
3827
  }
2454
3828
  }
2455
- if (res && (context.finalized === false || isError)) {
2456
- context.res = res;
3829
+ if (res && (context2.finalized === false || isError)) {
3830
+ context2.res = res;
2457
3831
  }
2458
- return context;
3832
+ return context2;
2459
3833
  }
2460
3834
  };
2461
3835
  };
@@ -2876,7 +4250,7 @@ var raw = (value, callbacks) => {
2876
4250
  escapedString.callbacks = callbacks;
2877
4251
  return escapedString;
2878
4252
  };
2879
- var resolveCallback = async (str, phase, preserveCallbacks, context, buffer) => {
4253
+ var resolveCallback = async (str, phase, preserveCallbacks, context2, buffer) => {
2880
4254
  if (typeof str === "object" && !(str instanceof String)) {
2881
4255
  if (!(str instanceof Promise)) {
2882
4256
  str = str.toString();
@@ -2894,7 +4268,7 @@ var resolveCallback = async (str, phase, preserveCallbacks, context, buffer) =>
2894
4268
  } else {
2895
4269
  buffer = [str];
2896
4270
  }
2897
- const resStr = Promise.all(callbacks.map((c) => c({ phase, buffer, context }))).then((res) => Promise.all(res.filter(Boolean).map((str2) => resolveCallback(str2, phase, false, context, buffer))).then(() => buffer[0]));
4271
+ const resStr = Promise.all(callbacks.map((c) => c({ phase, buffer, context: context2 }))).then((res) => Promise.all(res.filter(Boolean).map((str2) => resolveCallback(str2, phase, false, context2, buffer))).then(() => buffer[0]));
2898
4272
  if (preserveCallbacks) {
2899
4273
  return raw(await resStr, callbacks);
2900
4274
  } else {
@@ -3293,11 +4667,11 @@ var Hono = class _Hono {
3293
4667
  const composed = compose(matchResult[0], this.errorHandler, this.#notFoundHandler);
3294
4668
  return (async () => {
3295
4669
  try {
3296
- const context = await composed(c);
3297
- if (!context.finalized) {
4670
+ const context2 = await composed(c);
4671
+ if (!context2.finalized) {
3298
4672
  throw new Error("Context is not finalized. Did you forget to return a Response object or `await next()`?");
3299
4673
  }
3300
- return context.res;
4674
+ return context2.res;
3301
4675
  } catch (err) {
3302
4676
  return this.#handleError(err, c);
3303
4677
  }
@@ -3370,7 +4744,7 @@ var Node = class _Node {
3370
4744
  #index;
3371
4745
  #varIndex;
3372
4746
  #children = /* @__PURE__ */ Object.create(null);
3373
- insert(tokens, index, paramMap, context, isStatic) {
4747
+ insert(tokens, index, paramMap, context2, isStatic) {
3374
4748
  let node = this;
3375
4749
  for (let i = 0, len = tokens.length;i < len; i++) {
3376
4750
  const token = tokens[i];
@@ -3403,7 +4777,7 @@ var Node = class _Node {
3403
4777
  nextNode = node.#children[regexpStr] = new _Node;
3404
4778
  }
3405
4779
  if (name !== "") {
3406
- nextNode.#varIndex ??= context.varIndex++;
4780
+ nextNode.#varIndex ??= context2.varIndex++;
3407
4781
  paramMap.push([name, nextNode.#varIndex]);
3408
4782
  }
3409
4783
  } else {
@@ -3928,8 +5302,8 @@ var StreamingApi = class {
3928
5302
  `);
3929
5303
  return this;
3930
5304
  }
3931
- sleep(ms) {
3932
- return new Promise((res) => setTimeout(res, ms));
5305
+ sleep(ms4) {
5306
+ return new Promise((res) => setTimeout(res, ms4));
3933
5307
  }
3934
5308
  async close() {
3935
5309
  this.closed = true;
@@ -4183,6 +5557,7 @@ class Dispatcher {
4183
5557
  task: t.id,
4184
5558
  runId: r.run.id,
4185
5559
  worktree: r.run.worktree,
5560
+ by: this.opts.get(projectId)?.owner ?? null,
4186
5561
  summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
4187
5562
  }
4188
5563
  });
@@ -4343,6 +5718,64 @@ class ForgeService {
4343
5718
  }
4344
5719
  }));
4345
5720
  }
5721
+ outcomeCache = new Map;
5722
+ async merged(projectId, root) {
5723
+ const hit = this.outcomeCache.get(projectId);
5724
+ if (hit && Date.now() - hit.at < 600000)
5725
+ return hit;
5726
+ let merged = [];
5727
+ const remote = this.remote(root);
5728
+ if (remote?.forge === "github") {
5729
+ const out = await this.run([
5730
+ "gh",
5731
+ "pr",
5732
+ "list",
5733
+ "--state",
5734
+ "merged",
5735
+ "--limit",
5736
+ "200",
5737
+ "--json",
5738
+ "number,title,headRefName,url,createdAt,mergedAt,mergeCommit"
5739
+ ], root);
5740
+ if (out)
5741
+ merged = JSON.parse(out).map((r) => ({
5742
+ branch: String(r.headRefName ?? ""),
5743
+ number: Number(r.number ?? 0),
5744
+ title: String(r.title ?? ""),
5745
+ url: String(r.url ?? ""),
5746
+ createdAt: r.createdAt ?? null,
5747
+ mergedAt: r.mergedAt ?? null,
5748
+ mergeSha: (r.mergeCommit?.oid ?? null)?.toLowerCase() ?? null
5749
+ }));
5750
+ } else if (remote?.forge === "gitlab") {
5751
+ const out = await this.run(["glab", "mr", "list", "--merged", "--output", "json"], root);
5752
+ if (out)
5753
+ merged = JSON.parse(out).map((r) => ({
5754
+ branch: String(r.source_branch ?? ""),
5755
+ number: Number(r.iid ?? 0),
5756
+ title: String(r.title ?? ""),
5757
+ url: String(r.web_url ?? ""),
5758
+ createdAt: r.created_at ?? null,
5759
+ mergedAt: r.merged_at ?? null,
5760
+ mergeSha: (r.merge_commit_sha ?? null)?.toLowerCase() ?? null
5761
+ }));
5762
+ }
5763
+ const log = Bun.spawnSync([
5764
+ "git",
5765
+ "-C",
5766
+ root,
5767
+ "log",
5768
+ "--grep",
5769
+ "This reverts commit",
5770
+ "--format=%B",
5771
+ "-n",
5772
+ "300"
5773
+ ]);
5774
+ const reverted = log.exitCode === 0 ? [...parseReverts(new TextDecoder().decode(log.stdout))] : [];
5775
+ const entry = { at: Date.now(), merged, reverted };
5776
+ this.outcomeCache.set(projectId, entry);
5777
+ return entry;
5778
+ }
4346
5779
  remote(root) {
4347
5780
  const r = Bun.spawnSync(["git", "-C", root, "remote", "get-url", "origin"]);
4348
5781
  if (r.exitCode !== 0)
@@ -4575,6 +6008,16 @@ function currentBranch(cwd) {
4575
6008
  branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
4576
6009
  return branchCache.get(cwd)?.v ?? null;
4577
6010
  }
6011
+ var originCache = new Map;
6012
+ function originUrl(root) {
6013
+ const hit = originCache.get(root);
6014
+ const now = Date.now();
6015
+ if (hit && now - hit.t < 300000)
6016
+ return hit.v;
6017
+ const v = git(root, ["config", "--get", "remote.origin.url"])?.trim() || null;
6018
+ originCache.set(root, { v, t: now });
6019
+ return v;
6020
+ }
4578
6021
  function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
4579
6022
  const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
4580
6023
  const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
@@ -4706,6 +6149,14 @@ class Runner {
4706
6149
  return { ok: false, reason: "unknown project" };
4707
6150
  if (!input.prompt.trim())
4708
6151
  return { ok: false, reason: "prompt is required" };
6152
+ if (input.model) {
6153
+ const allow = this.store.config(input.projectId).models.allow;
6154
+ if (!modelAllowed(input.model, allow))
6155
+ return {
6156
+ ok: false,
6157
+ reason: `model "${input.model}" is not in [models] allow (${allow.join(", ")})`
6158
+ };
6159
+ }
4709
6160
  if (input.permissionMode && !PERMISSION_MODES.includes(input.permissionMode))
4710
6161
  return { ok: false, reason: `permission mode must be one of ${PERMISSION_MODES.join(", ")}` };
4711
6162
  if (this.get(input.task)?.projectId === input.projectId)
@@ -5004,6 +6455,7 @@ class Runner {
5004
6455
  import { Database } from "bun:sqlite";
5005
6456
  import {
5006
6457
  closeSync,
6458
+ copyFileSync,
5007
6459
  existsSync as existsSync6,
5008
6460
  mkdirSync as mkdirSync4,
5009
6461
  openSync as openSync3,
@@ -5016,7 +6468,7 @@ import {
5016
6468
  unlinkSync,
5017
6469
  writeFileSync as writeFileSync2
5018
6470
  } from "fs";
5019
- import { homedir as homedir3, tmpdir, userInfo } from "os";
6471
+ import { homedir as homedir3, hostname, tmpdir, userInfo } from "os";
5020
6472
  import { basename, dirname as dirname3, join as join8 } from "path";
5021
6473
 
5022
6474
  // packages/daemon/src/bootstrap.ts
@@ -5126,14 +6578,14 @@ class TaskSources {
5126
6578
  `)[0] ?? code}`);
5127
6579
  return normalizeGithubIssues(JSON.parse(out));
5128
6580
  }
5129
- async linear(team) {
6581
+ async linear(team2) {
5130
6582
  const key = this.env.LINEAR_API_KEY;
5131
6583
  if (!key)
5132
6584
  throw new Error("LINEAR_API_KEY not set \u2014 export it in the environment swarmd starts from (never stored)");
5133
6585
  const r = await fetch("https://api.linear.app/graphql", {
5134
6586
  method: "POST",
5135
6587
  headers: { "content-type": "application/json", authorization: key },
5136
- body: JSON.stringify({ query: linearIssuesQuery(team) })
6588
+ body: JSON.stringify({ query: linearIssuesQuery(team2) })
5137
6589
  });
5138
6590
  if (!r.ok)
5139
6591
  throw new Error(`Linear API ${r.status}`);
@@ -5159,11 +6611,12 @@ CREATE INDEX IF NOT EXISTS events_type_seq ON events(type, seq);
5159
6611
  CREATE TABLE IF NOT EXISTS turns (
5160
6612
  id TEXT PRIMARY KEY, session_id TEXT, agent_id TEXT, ts TEXT, model TEXT, effort TEXT, sidechain INTEGER,
5161
6613
  input INTEGER, output INTEGER, cache_write INTEGER, cache_write_1h INTEGER, cache_read INTEGER, thinking INTEGER,
5162
- cost_usd REAL, text TEXT, tools TEXT
6614
+ cost_usd REAL, cost_fixed INTEGER DEFAULT 0, text TEXT, tools TEXT
5163
6615
  );
5164
6616
  CREATE INDEX IF NOT EXISTS turns_session ON turns(session_id, ts);
5165
6617
  CREATE INDEX IF NOT EXISTS turns_ts ON turns(ts);
5166
6618
  CREATE TABLE IF NOT EXISTS tails (path TEXT PRIMARY KEY, session_id TEXT, agent_id TEXT, offset INTEGER);
6619
+ CREATE TABLE IF NOT EXISTS outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, payload TEXT, created_at TEXT);
5167
6620
  CREATE TABLE IF NOT EXISTS resources (
5168
6621
  name TEXT, project_id TEXT, kind TEXT, owner TEXT, session_id TEXT,
5169
6622
  pid INTEGER, port INTEGER, acquired_at TEXT, expires_at TEXT, released INTEGER DEFAULT 0,
@@ -5182,7 +6635,7 @@ CREATE TABLE IF NOT EXISTS processes (
5182
6635
  CREATE INDEX IF NOT EXISTS processes_live ON processes(ended_at, project_id);
5183
6636
  CREATE TABLE IF NOT EXISTS gates (
5184
6637
  id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, gate TEXT, verdict TEXT,
5185
- rubric TEXT, evidence TEXT, session_id TEXT, created_at TEXT
6638
+ rubric TEXT, evidence TEXT, session_id TEXT, duration_ms INTEGER, created_at TEXT
5186
6639
  );
5187
6640
  CREATE INDEX IF NOT EXISTS gates_task ON gates(project_id, task, created_at);
5188
6641
  CREATE TABLE IF NOT EXISTS handoffs (
@@ -5229,6 +6682,8 @@ class Store {
5229
6682
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
5230
6683
  this.db.exec(SCHEMA);
5231
6684
  this.ensureColumn("sessions", "agent", "TEXT DEFAULT 'claude-code'");
6685
+ this.ensureColumn("claims", "team_state", "TEXT");
6686
+ this.ensureColumn("turns", "cost_fixed", "INTEGER DEFAULT 0");
5232
6687
  this.ensureColumn("projects", "sort_order", "INTEGER");
5233
6688
  this.ensureColumn("projects", "icon", "TEXT");
5234
6689
  this.ensureColumn("projects", "color", "TEXT");
@@ -5254,6 +6709,12 @@ class Store {
5254
6709
  setMeta(key, value) {
5255
6710
  this.db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
5256
6711
  }
6712
+ metaValue(key) {
6713
+ return this.meta(key) || null;
6714
+ }
6715
+ setMetaValue(key, value) {
6716
+ this.setMeta(key, value);
6717
+ }
5257
6718
  slimExistingEvents() {
5258
6719
  if (this.meta("events_slim") === "1")
5259
6720
  return;
@@ -5317,7 +6778,7 @@ class Store {
5317
6778
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);
5318
6779
  }
5319
6780
  }
5320
- static SCHEMA_VERSION = 1;
6781
+ static SCHEMA_VERSION = 2;
5321
6782
  schemaVersion() {
5322
6783
  return Number(this.meta("schema_version") ?? 0);
5323
6784
  }
@@ -5355,6 +6816,16 @@ class Store {
5355
6816
  fill("incident_acks", "'dashboard'", null, "acks");
5356
6817
  fill("sessions", "NULL", "id", "sessions");
5357
6818
  fill("events", "COALESCE(json_extract(payload, '$.owner'), json_extract(payload, '$.by'))", "session_id", "events");
6819
+ },
6820
+ (db) => {
6821
+ this.ensureColumn("gates", "duration_ms", "INTEGER");
6822
+ const rows = db.query("SELECT id, rubric FROM gates WHERE duration_ms IS NULL AND rubric LIKE '%s'").all();
6823
+ const upd = db.query("UPDATE gates SET duration_ms = ? WHERE id = ?");
6824
+ for (const r of rows) {
6825
+ const m = /\bin ([0-9]+(?:\.[0-9]+)?)s$/.exec(r.rubric ?? "");
6826
+ if (m)
6827
+ upd.run(Math.round(Number(m[1]) * 1000), r.id);
6828
+ }
5358
6829
  }
5359
6830
  ];
5360
6831
  for (let v = this.schemaVersion();v < steps.length; v++) {
@@ -5862,10 +7333,10 @@ class Store {
5862
7333
  OR (to_kind = 'task' AND project_id = ? AND task IS ?)
5863
7334
  OR (to_kind = 'lead' AND project_id = ? AND ? = 'interactive'))
5864
7335
  ORDER BY id`).all(sessionId, sessionId, s.project_id, task, s.project_id, s.kind);
5865
- const ms = rows.map(rowToMessage);
5866
- if (ms.length && !opts.peek)
5867
- this.db.query(`UPDATE messages SET delivered_at = ?, session_id = ? WHERE id IN (${ms.map(() => "?").join(",")})`).run(new Date().toISOString(), sessionId, ...ms.map((m) => m.id));
5868
- return ms;
7336
+ const ms4 = rows.map(rowToMessage);
7337
+ if (ms4.length && !opts.peek)
7338
+ this.db.query(`UPDATE messages SET delivered_at = ?, session_id = ? WHERE id IN (${ms4.map(() => "?").join(",")})`).run(new Date().toISOString(), sessionId, ...ms4.map((m) => m.id));
7339
+ return ms4;
5869
7340
  }
5870
7341
  markMessageDelivered(id, sessionId) {
5871
7342
  this.db.query("UPDATE messages SET delivered_at = ?, session_id = COALESCE(?, session_id) WHERE id = ? AND delivered_at IS NULL").run(new Date().toISOString(), sessionId, id);
@@ -5920,6 +7391,7 @@ class Store {
5920
7391
  rubric: r.rubric,
5921
7392
  evidence: r.evidence ?? null,
5922
7393
  sessionId: r.session_id ?? null,
7394
+ durationMs: r.duration_ms ?? null,
5923
7395
  createdAt: r.created_at
5924
7396
  };
5925
7397
  }
@@ -6070,10 +7542,10 @@ class Store {
6070
7542
  };
6071
7543
  const done = (async () => {
6072
7544
  let diffText = "";
6073
- let stat = "";
7545
+ let stat2 = "";
6074
7546
  try {
6075
7547
  const diff = await worktreeDiff(p.root, where.worktree);
6076
- stat = diff.files.map((f) => `${f.status ?? "M"} ${f.path} (+${f.added} -${f.deleted})`).join(`
7548
+ stat2 = diff.files.map((f) => `${f.status ?? "M"} ${f.path} (+${f.added} -${f.deleted})`).join(`
6077
7549
  `);
6078
7550
  diffText = await worktreePatch(where.worktree, diff.base);
6079
7551
  } catch (e) {
@@ -6095,7 +7567,7 @@ class Store {
6095
7567
  task,
6096
7568
  title: taskRow?.title ?? null,
6097
7569
  branch: w?.branch ?? null,
6098
- stat,
7570
+ stat: stat2,
6099
7571
  patch: diffText
6100
7572
  });
6101
7573
  writeFileSync2(where.log, `$ claude -p <review prompt, ${prompt.length} chars> --output-format json (read-only)
@@ -6253,8 +7725,8 @@ ${err}
6253
7725
  return v;
6254
7726
  const createdAt = new Date().toISOString();
6255
7727
  const sessionId = this.knownSession(input.sessionId);
6256
- const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at, actor_kind, actor_id)
6257
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt, ...actorCols(this.actorFor(input.sessionId ? null : "daemon", sessionId)));
7728
+ const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, duration_ms, created_at, actor_kind, actor_id)
7729
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, typeof input.durationMs === "number" ? Math.max(0, Math.round(input.durationMs)) : null, createdAt, ...actorCols(this.actorFor(input.sessionId ? null : "daemon", sessionId)));
6258
7730
  const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
6259
7731
  this.remember(gateDoc(projectId, run2.id, run2, sessionId));
6260
7732
  this.append({
@@ -6414,6 +7886,18 @@ ${err}
6414
7886
  };
6415
7887
  return { decision: d, display: input.command ?? input.file_path ?? tool };
6416
7888
  }
7889
+ const tb = this.teamBudgets().find((x) => x.level === "exceeded" && x.on_exceed === "ask" && (x.scope !== "project" || x.key === this.clusterKeyFor(project.id)));
7890
+ if (tb) {
7891
+ const label = tb.scope === "org" ? "the org" : `${tb.scope} ${tb.key}`;
7892
+ return {
7893
+ decision: {
7894
+ action: "ask",
7895
+ rule: "budget",
7896
+ reason: `team ${tb.kind} budget for ${label} is exceeded ($${tb.spent.toFixed(2)} of $${tb.limit}) \u2014 the team's on_exceed = "ask": confirm each change, or have an admin raise it (POST /t1/budgets)`
7897
+ },
7898
+ display: input.command ?? input.file_path ?? tool
7899
+ };
7900
+ }
6417
7901
  }
6418
7902
  const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
6419
7903
  const cmd = tool === "Bash" ? input.command : undefined;
@@ -6581,7 +8065,7 @@ ${err}
6581
8065
  this.reprice();
6582
8066
  }
6583
8067
  reprice() {
6584
- const rows = this.db.query("SELECT id, model, input, output, cache_write, cache_write_1h, cache_read FROM turns").all();
8068
+ const rows = this.db.query("SELECT id, model, input, output, cache_write, cache_write_1h, cache_read FROM turns WHERE cost_fixed IS NOT 1").all();
6585
8069
  const up = this.db.query("UPDATE turns SET cost_usd = ? WHERE id = ?");
6586
8070
  const tx = this.db.transaction(() => {
6587
8071
  for (const r of rows)
@@ -6742,12 +8226,69 @@ ${err}
6742
8226
  if (stored.type === "incident.opened")
6743
8227
  this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
6744
8228
  this.projectSession(stored);
8229
+ if (stored.type === "incident.opened") {
8230
+ const webhook = this.policyFor(null).config.notify.webhook;
8231
+ if (webhook) {
8232
+ const p2 = stored.payload ?? {};
8233
+ const project = this.project(stored.projectId)?.name ?? stored.projectId;
8234
+ fetch(webhook, {
8235
+ method: "POST",
8236
+ headers: { "content-type": "application/json" },
8237
+ body: JSON.stringify({
8238
+ text: `Swarm incident \xB7 ${p2.rule ?? "?"} \xB7 ${project}
8239
+ ${p2.command ?? ""}
8240
+ ${p2.reason ?? ""}`.trim(),
8241
+ rule: p2.rule,
8242
+ project,
8243
+ sessionId: stored.sessionId,
8244
+ ts: stored.ts
8245
+ }),
8246
+ signal: AbortSignal.timeout(5000)
8247
+ }).catch(() => {});
8248
+ }
8249
+ }
8250
+ const team2 = this.policyFor(null).config.team;
8251
+ if (team2.url && team2.forward.includes("ledger") && isAuditType(stored.type)) {
8252
+ this.db.query("INSERT INTO outbox (kind, payload, created_at) VALUES ('event', ?, ?)").run(JSON.stringify({
8253
+ seq: stored.seq,
8254
+ ts: stored.ts,
8255
+ type: stored.type,
8256
+ projectId: stored.projectId,
8257
+ sessionId: stored.sessionId,
8258
+ actor: actor2,
8259
+ payload: slim.payload ?? null
8260
+ }), stored.ts);
8261
+ }
6745
8262
  this.touch();
6746
8263
  const wire = toWire(stored);
6747
8264
  for (const l of this.listeners)
6748
8265
  l(wire);
6749
8266
  return stored;
6750
8267
  }
8268
+ outboxPending(limit = 200) {
8269
+ return this.db.query("SELECT seq, kind, payload FROM outbox ORDER BY seq LIMIT ?").all(limit);
8270
+ }
8271
+ outboxAck(upTo) {
8272
+ this.db.query("DELETE FROM outbox WHERE seq <= ?").run(upTo);
8273
+ }
8274
+ outboxStatus() {
8275
+ const r = this.db.query("SELECT COUNT(*) AS n, MIN(created_at) AS oldest FROM outbox").get();
8276
+ return { pending: r.n, oldest: r.oldest };
8277
+ }
8278
+ machineIdentity() {
8279
+ let id = this.meta("machine_id");
8280
+ if (!id) {
8281
+ id = crypto.randomUUID();
8282
+ this.setMeta("machine_id", id);
8283
+ }
8284
+ return { id, name: hostname() };
8285
+ }
8286
+ spendRollup(day = new Date().toISOString().slice(0, 10)) {
8287
+ return this.db.query(`SELECT s.project_id AS projectId, COALESCE(s.agent, 'claude-code') AS agent, t.model AS model,
8288
+ SUM(t.cost_usd) AS cost, SUM(t.input + t.cache_write + t.cache_read) AS tokensIn, SUM(t.output) AS tokensOut
8289
+ FROM turns t JOIN sessions s ON s.id = t.session_id
8290
+ WHERE t.ts >= ? AND t.ts < ? GROUP BY s.project_id, agent, t.model`).all(`${day}T00:00:00.000Z`, `${day}T23:59:59.999Z`);
8291
+ }
6751
8292
  audit(opts = {}) {
6752
8293
  const where = [`type IN (${AUDIT_TYPES_SQL})`];
6753
8294
  const args = [];
@@ -6767,9 +8308,9 @@ ${err}
6767
8308
  const rows = this.db.query(`SELECT * FROM (SELECT ${WIRE_COLS} FROM events WHERE ${where.join(" AND ")} ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(...args, limit);
6768
8309
  return rows.map((r) => auditRow(wireRowToEvent(r)));
6769
8310
  }
6770
- prune(days2) {
8311
+ prune(days3) {
6771
8312
  const cfg = this.policyFor(null).config;
6772
- const chatter = days2 ?? cfg.events.retain_days;
8313
+ const chatter = days3 ?? cfg.events.retain_days;
6773
8314
  const cutoff = new Date(Date.now() - chatter * 86400000).toISOString();
6774
8315
  let n = this.db.query(`DELETE FROM events WHERE ts < ? AND type NOT IN (${AUDIT_TYPES_SQL})`).run(cutoff).changes;
6775
8316
  if (cfg.audit.retain_days > 0) {
@@ -6875,13 +8416,13 @@ ${err}
6875
8416
  persistTurns(sessionId, agentId, turns) {
6876
8417
  const privacy = this.policyFor(null).config.privacy;
6877
8418
  const res = this.redactions();
6878
- const up = this.db.query(`INSERT INTO turns (id, session_id, agent_id, ts, model, effort, sidechain, input, output, cache_write, cache_write_1h, cache_read, thinking, cost_usd, text, tools)
6879
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8419
+ const up = this.db.query(`INSERT INTO turns (id, session_id, agent_id, ts, model, effort, sidechain, input, output, cache_write, cache_write_1h, cache_read, thinking, cost_usd, cost_fixed, text, tools)
8420
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6880
8421
  ON CONFLICT(id) DO UPDATE SET input=excluded.input, output=excluded.output, cache_write=excluded.cache_write, cache_write_1h=excluded.cache_write_1h,
6881
- cache_read=excluded.cache_read, thinking=excluded.thinking, cost_usd=excluded.cost_usd, text=CASE WHEN excluded.text != '' THEN excluded.text ELSE turns.text END, tools=excluded.tools`);
8422
+ cache_read=excluded.cache_read, thinking=excluded.thinking, cost_usd=excluded.cost_usd, cost_fixed=excluded.cost_fixed, text=CASE WHEN excluded.text != '' THEN excluded.text ELSE turns.text END, tools=excluded.tools`);
6882
8423
  const tx = this.db.transaction((ts) => {
6883
8424
  for (const t of ts) {
6884
- up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, costUsd(t.model, t.usage, this.prices), privacy.store_reasoning ? redactValue(t.text, res) : "", JSON.stringify(t.tools));
8425
+ up.run(t.id, sessionId, agentId, t.ts, t.model, t.effort, t.sidechain ? 1 : 0, t.usage.input, t.usage.output, t.usage.cacheWrite, t.usage.cacheWrite1h ?? 0, t.usage.cacheRead, t.usage.thinking, t.cost ?? costUsd(t.model, t.usage, this.prices), t.cost != null ? 1 : 0, privacy.store_reasoning ? redactValue(t.text, res) : "", JSON.stringify(t.tools));
6885
8426
  }
6886
8427
  });
6887
8428
  if (turns.length)
@@ -7072,22 +8613,176 @@ ${err}
7072
8613
  let title;
7073
8614
  let fresh = false;
7074
8615
  try {
7075
- const m = statSync(sumPath).mtimeMs;
7076
- const hit = this.grokSummary.get(sumPath);
7077
- if (hit && hit.mtime === m)
7078
- title = hit.title;
7079
- else {
7080
- const sum = JSON.parse(readFileSync3(sumPath, "utf8"));
7081
- title = sum.session_summary;
7082
- this.grokSummary.set(sumPath, { mtime: m, title });
7083
- fresh = true;
7084
- }
7085
- } catch {}
7086
- n += this.ingestLog(path, "grok", parseGrokUpdates, cwd, title);
7087
- if (title && fresh) {
7088
- this.db.query("UPDATE sessions SET title = ? WHERE id = ? AND (title IS NULL OR title = '')").run(title, sid);
8616
+ const m = statSync(sumPath).mtimeMs;
8617
+ const hit = this.grokSummary.get(sumPath);
8618
+ if (hit && hit.mtime === m)
8619
+ title = hit.title;
8620
+ else {
8621
+ const sum = JSON.parse(readFileSync3(sumPath, "utf8"));
8622
+ title = sum.session_summary;
8623
+ this.grokSummary.set(sumPath, { mtime: m, title });
8624
+ fresh = true;
8625
+ }
8626
+ } catch {}
8627
+ n += this.ingestLog(path, "grok", parseGrokUpdates, cwd, title);
8628
+ if (title && fresh) {
8629
+ this.db.query("UPDATE sessions SET title = ? WHERE id = ? AND (title IS NULL OR title = '')").run(title, sid);
8630
+ }
8631
+ }
8632
+ }
8633
+ return n;
8634
+ }
8635
+ heldClaimsForSync() {
8636
+ return this.db.query("SELECT project_id, task, acquired_at, expires_at, actor_kind, actor_id, team_state FROM claims WHERE state = 'held'").all().map((r) => ({
8637
+ projectId: r.project_id,
8638
+ task: r.task,
8639
+ acquiredAt: r.acquired_at,
8640
+ expiresAt: r.expires_at,
8641
+ actorKind: r.actor_kind ?? null,
8642
+ actorId: r.actor_id ?? null,
8643
+ teamState: r.team_state ?? null
8644
+ }));
8645
+ }
8646
+ markClaimTeamState(projectId, task, state) {
8647
+ this.db.query("UPDATE claims SET team_state = ? WHERE project_id = ? AND task = ?").run(state, projectId, task);
8648
+ }
8649
+ revokeClaimConflict(projectId, task, holder) {
8650
+ const row = this.db.query("SELECT state FROM claims WHERE project_id = ? AND task = ?").get(projectId, task);
8651
+ if (row?.state !== "held")
8652
+ return;
8653
+ const now = new Date().toISOString();
8654
+ this.db.query("UPDATE claims SET state = 'released', released_at = ?, team_state = 'conflict' WHERE project_id = ? AND task = ?").run(now, projectId, task);
8655
+ this.append({
8656
+ ts: now,
8657
+ type: "claim.released",
8658
+ projectId,
8659
+ sessionId: null,
8660
+ payload: { task, summary: `revoked \u2014 the team ledger holds ${task} on ${holder}` }
8661
+ });
8662
+ this.append({
8663
+ ts: now,
8664
+ type: "incident.opened",
8665
+ projectId,
8666
+ sessionId: null,
8667
+ payload: {
8668
+ rule: "claim_conflict",
8669
+ action: "revoked",
8670
+ command: task,
8671
+ reason: `the team daemon holds ${task} for ${holder}; the local claim was revoked \u2014 the worktree is untouched`
8672
+ }
8673
+ });
8674
+ }
8675
+ aiderCarries = new Map;
8676
+ recoverAiderCarry(sessionId) {
8677
+ const s = this.db.query("SELECT started_at, model, title FROM sessions WHERE id = ?").get(sessionId);
8678
+ if (!s)
8679
+ return null;
8680
+ const t = this.db.query("SELECT COUNT(*) AS n FROM turns WHERE session_id = ?").get(sessionId);
8681
+ return {
8682
+ sessionId,
8683
+ startMs: Date.parse(s.started_at) || 0,
8684
+ model: s.model,
8685
+ title: s.title,
8686
+ turns: t.n,
8687
+ text: "",
8688
+ tools: [],
8689
+ pending: null
8690
+ };
8691
+ }
8692
+ tailAider(windowMs = 3 * 24 * 60 * 60000) {
8693
+ const roots = this.db.query("SELECT DISTINCT root FROM projects WHERE root IS NOT NULL AND root != ''").all();
8694
+ let n = 0;
8695
+ for (const { root } of roots) {
8696
+ const path = join8(root, ".aider.chat.history.md");
8697
+ let mtime;
8698
+ try {
8699
+ mtime = statSync(path).mtimeMs;
8700
+ } catch {
8701
+ continue;
8702
+ }
8703
+ if (mtime < Date.now() - windowMs)
8704
+ continue;
8705
+ const row = this.db.query("SELECT offset, session_id FROM tails WHERE path = ?").get(path);
8706
+ const r = this.readFrom(path, row?.offset ?? 0);
8707
+ if (!r)
8708
+ continue;
8709
+ let carry = this.aiderCarries.get(path) ?? null;
8710
+ if (!carry && row?.session_id)
8711
+ carry = this.recoverAiderCarry(row.session_id);
8712
+ const { segments, carry: next } = parseAiderHistory(r.chunk, path, carry);
8713
+ this.aiderCarries.set(path, next);
8714
+ const lastSeg = segments.at(-1);
8715
+ for (const seg of segments) {
8716
+ this.ensureAgentSession(seg.sessionId, "aider", root, seg.startMs || mtime);
8717
+ this.persistTurns(seg.sessionId, null, seg.turns);
8718
+ const live = seg === lastSeg && Date.now() - mtime < 90000;
8719
+ const lastSeen = new Date(seg === lastSeg ? mtime : seg.startMs + seg.turns.length * 1000).toISOString();
8720
+ const lastText = [...seg.turns].reverse().find((t) => t.text)?.text ?? null;
8721
+ this.db.query("UPDATE sessions SET title = COALESCE(title, ?), model = COALESCE(?, model), last_text = COALESCE(?, last_text), last_seen_at = ?, state = ?, ended_at = CASE WHEN ? = 'ended' AND ended_at IS NULL THEN ? ELSE ended_at END WHERE id = ?").run(seg.title, seg.model, lastText, lastSeen, live ? "active" : "ended", live ? "active" : "ended", lastSeen, seg.sessionId);
8722
+ n += seg.turns.length;
8723
+ }
8724
+ this.db.query("INSERT INTO tails (path, session_id, agent_id, offset) VALUES (?, ?, NULL, ?) ON CONFLICT(path) DO UPDATE SET offset = excluded.offset, session_id = excluded.session_id").run(path, lastSeg?.sessionId ?? row?.session_id ?? null, r.next);
8725
+ }
8726
+ return n;
8727
+ }
8728
+ ocDbs = new Map;
8729
+ tailOpencode(windowMs = 3 * 24 * 60 * 60000) {
8730
+ const dir = process.env.SWARM_OPENCODE_DIR ?? join8(process.env.XDG_DATA_HOME ?? join8(homedir3(), ".local", "share"), "opencode");
8731
+ let files;
8732
+ try {
8733
+ files = readdirSync(dir).filter((f) => /^opencode[^/]*\.db$/.test(f));
8734
+ } catch {
8735
+ return 0;
8736
+ }
8737
+ let n = 0;
8738
+ for (const f of files) {
8739
+ const path = join8(dir, f);
8740
+ let db = this.ocDbs.get(path);
8741
+ if (!db) {
8742
+ try {
8743
+ db = new Database(path, { readonly: true });
8744
+ } catch {
8745
+ continue;
7089
8746
  }
8747
+ this.ocDbs.set(path, db);
8748
+ }
8749
+ const row = this.db.query("SELECT offset FROM tails WHERE path = ?").get(path);
8750
+ const lower = Math.max(row?.offset ?? 0, Date.now() - windowMs);
8751
+ let rows;
8752
+ try {
8753
+ rows = db.query(`SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data,
8754
+ s.directory, s.title, s.parent_id
8755
+ FROM message m JOIN session s ON s.id = m.session_id
8756
+ WHERE m.time_updated > ? ORDER BY m.time_updated ASC LIMIT 2000`).all(lower);
8757
+ } catch {
8758
+ continue;
8759
+ }
8760
+ if (!rows.length)
8761
+ continue;
8762
+ let cursor = lower;
8763
+ const bySession = new Map;
8764
+ for (const m of rows) {
8765
+ cursor = Math.max(cursor, m.time_updated ?? 0);
8766
+ const g = bySession.get(m.session_id) ?? { rows: [], last: 0 };
8767
+ g.rows.push(m);
8768
+ g.last = Math.max(g.last, m.time_updated ?? m.time_created ?? 0);
8769
+ bySession.set(m.session_id, g);
7090
8770
  }
8771
+ for (const [sid, g] of bySession) {
8772
+ const first = g.rows[0];
8773
+ if (!first)
8774
+ continue;
8775
+ this.ensureAgentSession(sid, "opencode", first.directory ?? "", first.time_created ?? g.last);
8776
+ const turns = g.rows.map((m) => opencodeTurn(sid, m.id, m.data, m.time_created ?? 0, first.parent_id != null)).filter((t) => t != null);
8777
+ this.persistTurns(sid, null, turns);
8778
+ const live = Date.now() - g.last < 90000;
8779
+ const lastSeen = new Date(g.last).toISOString();
8780
+ const lastText = [...turns].reverse().find((t) => t.text)?.text ?? null;
8781
+ const model = [...turns].reverse().find((t) => t.model !== "opencode")?.model ?? null;
8782
+ this.db.query("UPDATE sessions SET title = COALESCE(?, title), model = COALESCE(?, model), last_text = COALESCE(?, last_text), last_seen_at = ?, state = ?, ended_at = CASE WHEN ? = 'ended' AND ended_at IS NULL THEN ? ELSE ended_at END WHERE id = ?").run(first.title, model, lastText, lastSeen, live ? "active" : "ended", live ? "active" : "ended", lastSeen, sid);
8783
+ n += turns.length;
8784
+ }
8785
+ this.db.query("INSERT INTO tails (path, session_id, agent_id, offset) VALUES (?, NULL, NULL, ?) ON CONFLICT(path) DO UPDATE SET offset = excluded.offset").run(path, cursor);
7091
8786
  }
7092
8787
  return n;
7093
8788
  }
@@ -7144,7 +8839,8 @@ ${err}
7144
8839
  acquiredAt: r.acquired_at,
7145
8840
  expiresAt: r.expires_at,
7146
8841
  releasedAt: r.released_at ?? null,
7147
- state: r.state
8842
+ state: r.state,
8843
+ sessionId: r.actor_kind === "agent" ? r.actor_id ?? null : null
7148
8844
  }));
7149
8845
  const now = Date.now();
7150
8846
  for (const c of rows)
@@ -7272,6 +8968,507 @@ ${err}
7272
8968
  expiresAt: r.expires_at
7273
8969
  }));
7274
8970
  }
8971
+ waiting(projectId, days3 = 7) {
8972
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
8973
+ const pArgs = projectId ? [projectId] : [];
8974
+ const paired = this.db.query(`SELECT type, session_id, project_id, ts,
8975
+ COALESCE(json_extract(payload,'$.requestId'), json_extract(payload,'$.id')) AS key,
8976
+ COALESCE(json_extract(payload,'$.tool'), json_extract(payload,'$.text')) AS label
8977
+ FROM events
8978
+ WHERE type IN ('permission.requested','permission.resolved','question.asked','question.answered')
8979
+ AND ts >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...pArgs);
8980
+ const notes = this.db.query(`SELECT n.seq, n.session_id, n.project_id, n.ts,
8981
+ json_extract(n.payload,'$.summary') AS label,
8982
+ (SELECT MIN(a.ts) FROM events a
8983
+ WHERE a.session_id = n.session_id AND a.seq > n.seq
8984
+ AND a.type IN ('prompt.submitted','tool.requested')) AS resumed
8985
+ FROM events n
8986
+ WHERE n.type = 'session.notification' AND n.ts >= ?${projectId ? " AND n.project_id = ?" : ""}`).all(since, ...pArgs);
8987
+ const samples = [];
8988
+ for (const r of paired) {
8989
+ if (!r.session_id || r.key === null)
8990
+ continue;
8991
+ const kind = r.type.startsWith("permission") ? "permission" : "question";
8992
+ samples.push({
8993
+ sessionId: r.session_id,
8994
+ projectId: r.project_id,
8995
+ kind,
8996
+ key: String(r.key),
8997
+ phase: r.type.endsWith(".requested") || r.type.endsWith(".asked") ? "start" : "end",
8998
+ ts: r.ts,
8999
+ ...r.label ? { label: r.label.slice(0, 120) } : {}
9000
+ });
9001
+ }
9002
+ for (const n of notes) {
9003
+ if (!n.session_id)
9004
+ continue;
9005
+ const key = String(n.seq);
9006
+ samples.push({
9007
+ sessionId: n.session_id,
9008
+ projectId: n.project_id,
9009
+ kind: "notification",
9010
+ key,
9011
+ phase: "start",
9012
+ ts: n.ts,
9013
+ ...n.label ? { label: n.label.slice(0, 120) } : {}
9014
+ });
9015
+ if (n.resumed)
9016
+ samples.push({
9017
+ sessionId: n.session_id,
9018
+ projectId: n.project_id,
9019
+ kind: "notification",
9020
+ key,
9021
+ phase: "end",
9022
+ ts: n.resumed
9023
+ });
9024
+ }
9025
+ const ends = {};
9026
+ for (const r of this.db.query("SELECT id, ended_at FROM sessions WHERE ended_at IS NOT NULL").all())
9027
+ ends[r.id] = r.ended_at;
9028
+ const report = waitingReport(pairWaits(samples, new Date().toISOString(), ends));
9029
+ const meta = new Map(this.db.query("SELECT id, title, agent, project_id FROM sessions").all().map((r) => [r.id, r]));
9030
+ return {
9031
+ ...report,
9032
+ sessions: report.sessions.map((s) => ({
9033
+ ...s,
9034
+ title: meta.get(s.sessionId)?.title ?? null,
9035
+ agent: meta.get(s.sessionId)?.agent ?? "claude-code",
9036
+ projectId: s.projectId ?? meta.get(s.sessionId)?.project_id ?? null
9037
+ }))
9038
+ };
9039
+ }
9040
+ gateHealth(projectId, days3 = 30) {
9041
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
9042
+ const rows = this.db.query(`SELECT project_id, task, gate, verdict, duration_ms, created_at FROM gates
9043
+ WHERE created_at >= ?${projectId ? " AND project_id = ?" : ""}`).all(...projectId ? [since, projectId] : [since]);
9044
+ return gateHealth(rows.filter((r) => r.verdict === "pass" || r.verdict === "fail").map((r) => ({
9045
+ projectId: r.project_id,
9046
+ task: r.task,
9047
+ gate: r.gate,
9048
+ verdict: r.verdict,
9049
+ durationMs: r.duration_ms,
9050
+ at: r.created_at
9051
+ })));
9052
+ }
9053
+ static worktreeIdleMs(path) {
9054
+ let newest = 0;
9055
+ for (const f of [path, join8(path, ".git")]) {
9056
+ try {
9057
+ newest = Math.max(newest, statSync(f).mtimeMs);
9058
+ } catch {}
9059
+ }
9060
+ return newest ? Math.max(0, Date.now() - newest) : null;
9061
+ }
9062
+ duCache = new Map;
9063
+ duInflight = null;
9064
+ refreshDisk(paths, ttlMs) {
9065
+ if (this.duInflight)
9066
+ return;
9067
+ const stale = paths.filter((p) => {
9068
+ const hit = this.duCache.get(p);
9069
+ return !hit || Date.now() - hit.t >= ttlMs;
9070
+ });
9071
+ if (!stale.length)
9072
+ return;
9073
+ this.duInflight = (async () => {
9074
+ for (const path of stale) {
9075
+ let v = null;
9076
+ try {
9077
+ const proc = Bun.spawn(["du", "-sk", "-x", path], { stdout: "pipe", stderr: "ignore" });
9078
+ const out = await new Response(proc.stdout).text();
9079
+ if (await proc.exited === 0) {
9080
+ const n = Number.parseInt(out.trim().split(/\s+/)[0] ?? "", 10);
9081
+ if (Number.isFinite(n))
9082
+ v = n;
9083
+ }
9084
+ } catch {}
9085
+ this.duCache.set(path, { v, t: Date.now() });
9086
+ }
9087
+ })().finally(() => {
9088
+ this.duInflight = null;
9089
+ });
9090
+ }
9091
+ hygiene(projectId, diskTtlMs = 600000) {
9092
+ const rows = this.db.query(`SELECT * FROM processes WHERE ended_at IS NULL${projectId ? " AND project_id = ?" : ""}
9093
+ ORDER BY started_at DESC`).all(...projectId ? [projectId] : []).map((r) => this.rowToProcess(r));
9094
+ const usage = new Map;
9095
+ if (rows.length) {
9096
+ try {
9097
+ const out = Bun.spawnSync([
9098
+ "ps",
9099
+ "-o",
9100
+ "pid=,pcpu=,rss=",
9101
+ "-p",
9102
+ rows.map((r) => r.pid).join(",")
9103
+ ]);
9104
+ for (const line of new TextDecoder().decode(out.stdout).split(`
9105
+ `)) {
9106
+ const [pid, cpu, rss] = line.trim().split(/\s+/);
9107
+ if (pid)
9108
+ usage.set(Number(pid), { cpu: Number(cpu) || 0, rss: Number(rss) || 0 });
9109
+ }
9110
+ } catch {}
9111
+ }
9112
+ const liveSessionIds = new Set(this.db.query("SELECT id FROM sessions WHERE ended_at IS NULL AND state IN ('active','waiting')").all().map((r) => r.id));
9113
+ const procs = rows.map((p) => {
9114
+ const u = usage.get(p.pid);
9115
+ return {
9116
+ pid: p.pid,
9117
+ name: p.name,
9118
+ kind: p.kind,
9119
+ projectId: p.projectId,
9120
+ sessionId: p.sessionId,
9121
+ port: p.port,
9122
+ startedAt: p.startedAt,
9123
+ alive: this.processIsOurs(p),
9124
+ sessionLive: !p.sessionId || liveSessionIds.has(p.sessionId),
9125
+ cpuPct: u ? u.cpu : null,
9126
+ rssKb: u ? u.rss : null
9127
+ };
9128
+ });
9129
+ const projects = projectId ? [projectId] : this.projects().map((p) => p.id);
9130
+ const claims = this.claims().filter((c) => c.state !== "released");
9131
+ const trees = [];
9132
+ for (const pid of projects)
9133
+ for (const w of this.worktrees(pid)) {
9134
+ const held = claims.find((c) => c.worktree === w.path);
9135
+ trees.push({
9136
+ projectId: pid,
9137
+ path: w.path,
9138
+ branch: w.branch,
9139
+ main: w.main,
9140
+ dirty: w.dirty,
9141
+ ahead: w.ahead,
9142
+ merged: w.merged,
9143
+ idleMs: Store.worktreeIdleMs(w.path),
9144
+ diskKb: this.duCache.get(w.path)?.v ?? null,
9145
+ heldByClaim: held?.task ?? null,
9146
+ liveSessions: this.sessions().filter((s) => s.cwd?.startsWith(w.path) && !s.endedAt && s.state !== "ended").length
9147
+ });
9148
+ }
9149
+ this.refreshDisk(trees.map((t) => t.path), diskTtlMs);
9150
+ return hygieneReport(procs, trees);
9151
+ }
9152
+ lineage(projectId, days3 = 14, expanded = []) {
9153
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
9154
+ const pArgs = projectId ? [projectId] : [];
9155
+ const rows = this.db.query(`SELECT id, project_id, title, agent, kind, state, parent_id, started_at, ended_at
9156
+ FROM sessions WHERE last_seen_at >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...pArgs);
9157
+ const cost = new Map(this.db.query("SELECT session_id, SUM(cost_usd) AS c FROM turns WHERE ts >= ? GROUP BY session_id").all(since).map((r) => [r.session_id, r.c]));
9158
+ const sessions = rows.map((r) => ({
9159
+ id: r.id,
9160
+ projectId: r.project_id,
9161
+ title: r.title,
9162
+ agent: r.agent ?? "claude-code",
9163
+ kind: r.kind,
9164
+ state: r.state,
9165
+ startedAt: r.started_at,
9166
+ endedAt: r.ended_at,
9167
+ costUsd: cost.get(r.id) ?? null,
9168
+ outcome: null
9169
+ }));
9170
+ const known = new Set(sessions.map((s) => s.id));
9171
+ const edges = [];
9172
+ for (const a of this.db.query(`SELECT t.session_id, t.agent_id, MIN(t.ts) AS first_ts, MAX(t.ts) AS last_ts,
9173
+ SUM(t.cost_usd) AS cost, COUNT(*) AS turns
9174
+ FROM turns t WHERE t.agent_id IS NOT NULL AND t.ts >= ?
9175
+ GROUP BY t.session_id, t.agent_id`).all(since)) {
9176
+ const parent = sessions.find((x) => x.id === a.session_id);
9177
+ if (!parent)
9178
+ continue;
9179
+ const id = `sub:${a.session_id}:${a.agent_id}`;
9180
+ sessions.push({
9181
+ id,
9182
+ projectId: parent.projectId,
9183
+ title: `subagent ${a.agent_id.slice(0, 8)} \xB7 ${a.turns} turn${a.turns === 1 ? "" : "s"}`,
9184
+ agent: parent.agent,
9185
+ kind: "subagent",
9186
+ state: "ended",
9187
+ startedAt: a.first_ts,
9188
+ endedAt: a.last_ts,
9189
+ costUsd: a.cost,
9190
+ outcome: null
9191
+ });
9192
+ known.add(id);
9193
+ edges.push({ from: a.session_id, to: id, kind: "subagent", at: a.first_ts });
9194
+ }
9195
+ for (const d of this.db.query(`SELECT session_id, ts, json_extract(payload,'$.by') AS by, json_extract(payload,'$.task') AS task
9196
+ FROM events WHERE type = 'dispatch.started' AND ts >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...pArgs)) {
9197
+ if (!d.session_id || !d.by)
9198
+ continue;
9199
+ const a = actorFrom(d.by, null);
9200
+ if (a.kind === "agent" && known.has(a.id) && a.id !== d.session_id)
9201
+ edges.push({ from: a.id, to: d.session_id, kind: "dispatch", at: d.ts, label: d.task });
9202
+ }
9203
+ for (const m of this.db.query(`SELECT session_id, ts, json_extract(payload,'$.recipient') AS to_session,
9204
+ json_extract(payload,'$.text') AS text
9205
+ FROM events WHERE type = 'message.sent' AND ts >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...pArgs))
9206
+ if (m.session_id && m.to_session)
9207
+ edges.push({
9208
+ from: m.session_id,
9209
+ to: m.to_session,
9210
+ kind: "message",
9211
+ at: m.ts,
9212
+ label: m.text?.slice(0, 80) ?? null
9213
+ });
9214
+ const holds = this.db.query(`SELECT task, project_id, actor_kind, actor_id, acquired_at FROM claims
9215
+ WHERE acquired_at >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...pArgs).map((c) => ({
9216
+ task: c.task,
9217
+ projectId: c.project_id,
9218
+ sessionId: c.actor_kind === "agent" ? c.actor_id : null,
9219
+ at: c.acquired_at
9220
+ }));
9221
+ edges.push(...handoffEdges(holds));
9222
+ return lineageGraph(sessions, edges, { expanded });
9223
+ }
9224
+ mcpHealth(projectId, days3 = 7) {
9225
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
9226
+ const rows = this.db.query(`SELECT session_id, seq, ts, type,
9227
+ json_extract(payload,'$.tool') AS tool,
9228
+ json_extract(payload,'$.toolResponse') AS response
9229
+ FROM events
9230
+ WHERE type IN ('tool.requested','tool.completed') AND ts >= ?
9231
+ AND json_extract(payload,'$.tool') IS NOT NULL${projectId ? " AND project_id = ?" : ""}
9232
+ ORDER BY session_id, seq`).all(...projectId ? [since, projectId] : [since]);
9233
+ const calls = [];
9234
+ const pending = new Map;
9235
+ const abandon = (sessionId, tool, at) => {
9236
+ calls.push({ sessionId, tool, ms: null, errored: false, at });
9237
+ };
9238
+ for (const r of rows) {
9239
+ if (!r.session_id)
9240
+ continue;
9241
+ const perSession = pending.get(r.session_id) ?? new Map;
9242
+ pending.set(r.session_id, perSession);
9243
+ if (r.type === "tool.requested") {
9244
+ const open = perSession.get(r.tool);
9245
+ if (open)
9246
+ abandon(r.session_id, r.tool, open);
9247
+ perSession.set(r.tool, r.ts);
9248
+ continue;
9249
+ }
9250
+ const startedAt = perSession.get(r.tool);
9251
+ perSession.delete(r.tool);
9252
+ calls.push({
9253
+ sessionId: r.session_id,
9254
+ tool: r.tool,
9255
+ ms: startedAt ? Math.max(0, new Date(r.ts).getTime() - new Date(startedAt).getTime()) : null,
9256
+ errored: toolResponseErrored(safeJson(r.response)),
9257
+ at: r.ts
9258
+ });
9259
+ }
9260
+ for (const [sessionId, perSession] of pending)
9261
+ for (const [tool, at] of perSession)
9262
+ abandon(sessionId, tool, at);
9263
+ return mcpHealth(calls);
9264
+ }
9265
+ context(projectId, days3 = 7) {
9266
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
9267
+ const pArgs = projectId ? [projectId] : [];
9268
+ const where = projectId ? " AND project_id = ?" : "";
9269
+ const results = this.db.query(`SELECT session_id, json_extract(payload,'$.tool') AS tool,
9270
+ LENGTH(json_extract(payload,'$.toolResponse')) AS chars
9271
+ FROM events
9272
+ WHERE type = 'tool.completed' AND ts >= ?${where}
9273
+ AND json_extract(payload,'$.tool') IS NOT NULL
9274
+ AND json_extract(payload,'$.toolResponse') IS NOT NULL`).all(since, ...pArgs).filter((r) => r.session_id).map((r) => ({ sessionId: r.session_id, tool: r.tool, chars: r.chars ?? 0 }));
9275
+ const reads = this.db.query(`SELECT req.session_id,
9276
+ json_extract(req.payload,'$.toolInput.file_path') AS path,
9277
+ (SELECT LENGTH(json_extract(done.payload,'$.toolResponse')) FROM events done
9278
+ WHERE done.session_id = req.session_id AND done.seq > req.seq
9279
+ AND done.type = 'tool.completed'
9280
+ AND json_extract(done.payload,'$.tool') = 'Read'
9281
+ ORDER BY done.seq LIMIT 1) AS chars
9282
+ FROM events req
9283
+ WHERE req.type = 'tool.requested' AND json_extract(req.payload,'$.tool') = 'Read'
9284
+ AND req.ts >= ?${where.replace("project_id", "req.project_id")}
9285
+ AND json_extract(req.payload,'$.toolInput.file_path') IS NOT NULL`).all(since, ...pArgs).filter((r) => r.session_id).map((r) => ({ sessionId: r.session_id, path: r.path, chars: r.chars ?? 0 }));
9286
+ const turns = this.db.query(`SELECT t.session_id, SUM(t.input) AS input, SUM(t.cache_read) AS cache_read,
9287
+ SUM(t.cache_write) AS cache_write, SUM(t.thinking) AS thinking, SUM(t.output) AS output
9288
+ FROM turns t${projectId ? " JOIN sessions s ON s.id = t.session_id" : ""}
9289
+ WHERE t.ts >= ?${projectId ? " AND s.project_id = ?" : ""}
9290
+ GROUP BY t.session_id`).all(since, ...pArgs).map((r) => ({
9291
+ sessionId: r.session_id,
9292
+ input: r.input ?? 0,
9293
+ cacheRead: r.cache_read ?? 0,
9294
+ cacheWrite: r.cache_write ?? 0,
9295
+ thinking: r.thinking ?? 0,
9296
+ output: r.output ?? 0
9297
+ }));
9298
+ const report = contextReport(results, reads, turns);
9299
+ const meta = new Map(this.db.query("SELECT id, title, agent FROM sessions").all().map((r) => [r.id, r]));
9300
+ return {
9301
+ ...report,
9302
+ sessions: report.sessions.map((s) => ({
9303
+ ...s,
9304
+ title: meta.get(s.sessionId)?.title ?? null,
9305
+ agent: meta.get(s.sessionId)?.agent ?? "claude-code"
9306
+ }))
9307
+ };
9308
+ }
9309
+ abTrials(projectId) {
9310
+ const projects = projectId ? [projectId] : this.projects().map((p) => p.id);
9311
+ const out = [];
9312
+ for (const pid of projects) {
9313
+ const tasks2 = [
9314
+ ...new Set(this.claims(pid).map((c) => splitArmTask(c.task)).filter((x) => x.arm).map((x) => x.task))
9315
+ ];
9316
+ for (const t of tasks2.sort())
9317
+ out.push({ ...this.abTrial(pid, t), projectId: pid });
9318
+ }
9319
+ const rank = { undecided: 0, "all-failed": 1, winner: 2 };
9320
+ return out.sort((a, b) => rank[a.verdict] - rank[b.verdict] || b.totals.costUsd - a.totals.costUsd);
9321
+ }
9322
+ diffCache = new Map;
9323
+ diffInflight = null;
9324
+ refreshDiffs(worktrees, base, ttlMs = 30000) {
9325
+ if (this.diffInflight)
9326
+ return;
9327
+ const stale = worktrees.filter((w) => {
9328
+ const hit = this.diffCache.get(w);
9329
+ return !hit || Date.now() - hit.t >= ttlMs;
9330
+ });
9331
+ if (!stale.length)
9332
+ return;
9333
+ this.diffInflight = (async () => {
9334
+ for (const wt of stale) {
9335
+ let v = null;
9336
+ try {
9337
+ const proc = Bun.spawn(["git", "diff", "--shortstat", `${base}...HEAD`], {
9338
+ cwd: wt,
9339
+ stdout: "pipe",
9340
+ stderr: "ignore"
9341
+ });
9342
+ const out = await new Response(proc.stdout).text();
9343
+ if (await proc.exited === 0) {
9344
+ const f = /(\d+) files? changed/.exec(out)?.[1];
9345
+ const i = /(\d+) insertions?/.exec(out)?.[1];
9346
+ const d = /(\d+) deletions?/.exec(out)?.[1];
9347
+ v = [Number(f ?? 0), Number(i ?? 0), Number(d ?? 0)];
9348
+ }
9349
+ } catch {}
9350
+ this.diffCache.set(wt, { v, t: Date.now() });
9351
+ }
9352
+ })().finally(() => {
9353
+ this.diffInflight = null;
9354
+ });
9355
+ }
9356
+ abTrial(projectId, task) {
9357
+ const claims = this.claims(projectId).filter((c) => splitArmTask(c.task).task === task);
9358
+ const all = this.sessions();
9359
+ const sessions = new Map(all.map((s) => [s.id, s]));
9360
+ const byCwd = new Map;
9361
+ for (const s of all)
9362
+ if (s.cwd && !byCwd.has(s.cwd))
9363
+ byCwd.set(s.cwd, s);
9364
+ const gateRuns = this.gateRuns(projectId, undefined, 2000);
9365
+ const arms = [];
9366
+ for (const c of claims) {
9367
+ const label = splitArmTask(c.task).arm;
9368
+ if (!label)
9369
+ continue;
9370
+ const sess = (c.worktree ? byCwd.get(c.worktree) : undefined) ?? (c.sessionId ? sessions.get(c.sessionId) : undefined);
9371
+ const sid = sess?.id ?? c.sessionId;
9372
+ const g = gateRuns.filter((x) => x.task === c.task);
9373
+ const latest = new Map;
9374
+ for (const run2 of [...g].sort((a, b) => a.createdAt < b.createdAt ? -1 : 1))
9375
+ latest.set(run2.gate, run2);
9376
+ const verdicts = [...latest.values()];
9377
+ const diff = c.worktree ? this.diffCache.get(c.worktree)?.v : null;
9378
+ arms.push({
9379
+ label,
9380
+ task: c.task,
9381
+ model: sess?.model ?? null,
9382
+ agent: sess?.agent ?? "claude-code",
9383
+ sessionId: sid,
9384
+ worktree: c.worktree || null,
9385
+ startedAt: c.acquiredAt,
9386
+ endedAt: sess?.endedAt ?? null,
9387
+ state: sess && sess.state !== "ended" ? "running" : sess ? "done" : c.state === "held" ? "running" : "failed",
9388
+ costUsd: sess?.costUsd ?? 0,
9389
+ turns: sess?.turns ?? 0,
9390
+ gatesPassed: verdicts.filter((v) => v.verdict === "pass").length,
9391
+ gatesFailed: verdicts.filter((v) => v.verdict === "fail").length,
9392
+ filesChanged: diff ? diff[0] : null,
9393
+ insertions: diff ? diff[1] : null,
9394
+ deletions: diff ? diff[2] : null
9395
+ });
9396
+ }
9397
+ const base = this.worktrees(projectId).find((w) => w.main)?.branch ?? "main";
9398
+ this.refreshDiffs(arms.map((a) => a.worktree).filter((w) => !!w), base);
9399
+ return scoreTrial(task, arms);
9400
+ }
9401
+ collisions(projectId) {
9402
+ const cutoff = new Date(Date.now() - IDLE_MS).toISOString();
9403
+ const live = this.db.query(`SELECT id, project_id, title, agent, kind FROM sessions
9404
+ WHERE state IN ('active','waiting') AND ended_at IS NULL AND last_seen_at >= ?${projectId ? " AND project_id = ?" : ""}`).all(...projectId ? [cutoff, projectId] : [cutoff]);
9405
+ if (!live.length)
9406
+ return { sessions: [], files: [], contested: 0 };
9407
+ const rows = this.db.query(`SELECT session_id, json_extract(payload,'$.tool') AS tool, json_extract(payload,'$.toolInput.file_path') AS path
9408
+ FROM events WHERE type = 'tool.requested' AND session_id IN (${live.map(() => "?").join(",")})
9409
+ AND json_extract(payload,'$.toolInput.file_path') IS NOT NULL`).all(...live.map((s) => s.id));
9410
+ const g = collisionGraph(rows.map((r) => ({ sessionId: r.session_id, tool: r.tool ?? "", path: r.path ?? "" })));
9411
+ const meta = new Map(live.map((s) => [s.id, s]));
9412
+ return {
9413
+ ...g,
9414
+ sessions: g.sessions.map((s) => {
9415
+ const m = meta.get(s.id);
9416
+ return {
9417
+ ...s,
9418
+ title: m?.title ?? null,
9419
+ agent: m?.agent ?? "claude-code",
9420
+ projectId: m?.project_id ?? null
9421
+ };
9422
+ })
9423
+ };
9424
+ }
9425
+ stalls = new Map;
9426
+ checkStalls() {
9427
+ const live = this.db.query("SELECT id, project_id FROM sessions WHERE state IN ('active','waiting') AND ended_at IS NULL AND last_seen_at >= ?").all(new Date(Date.now() - IDLE_MS).toISOString());
9428
+ const liveIds = new Set(live.map((s) => s.id));
9429
+ for (const id of [...this.stalls.keys()])
9430
+ if (!liveIds.has(id))
9431
+ this.stalls.delete(id);
9432
+ let flagged = 0;
9433
+ for (const s of live) {
9434
+ const rows = this.db.query("SELECT payload FROM events WHERE session_id = ? AND type = 'tool.completed' ORDER BY seq DESC LIMIT 12").all(s.id);
9435
+ const calls = rows.reverse().map((r) => {
9436
+ let p = {};
9437
+ try {
9438
+ p = JSON.parse(r.payload || "{}");
9439
+ } catch {}
9440
+ return {
9441
+ tool: typeof p.tool === "string" ? p.tool : "?",
9442
+ input: JSON.stringify(p.toolInput ?? null),
9443
+ errored: toolResponseErrored(p.toolResponse),
9444
+ ts: ""
9445
+ };
9446
+ });
9447
+ const stall2 = detectStall(calls);
9448
+ if (!stall2) {
9449
+ this.stalls.delete(s.id);
9450
+ continue;
9451
+ }
9452
+ flagged++;
9453
+ const prev = this.stalls.get(s.id);
9454
+ this.stalls.set(s.id, stall2);
9455
+ if (prev?.kind === stall2.kind)
9456
+ continue;
9457
+ this.append({
9458
+ ts: new Date().toISOString(),
9459
+ type: "session.stuck",
9460
+ projectId: s.project_id,
9461
+ sessionId: s.id,
9462
+ payload: {
9463
+ kind: stall2.kind,
9464
+ reason: stall2.reason,
9465
+ summary: `session looks stuck \u2014 ${stall2.reason}`
9466
+ }
9467
+ });
9468
+ this.touch();
9469
+ }
9470
+ return flagged;
9471
+ }
7275
9472
  sweepOrphans() {
7276
9473
  const now = Date.now();
7277
9474
  let n = 0;
@@ -7649,6 +9846,7 @@ ${err}
7649
9846
  lastType: r.last_type,
7650
9847
  lastText: r.last_text ?? null,
7651
9848
  state,
9849
+ stuck: state === "active" || state === "waiting" ? this.stalls.get(r.id)?.reason ?? null : null,
7652
9850
  toolCalls: r.tool_calls,
7653
9851
  subagents: r.subagents,
7654
9852
  turns: r.turns,
@@ -7713,8 +9911,120 @@ ${err}
7713
9911
  fn(p.id, b.status);
7714
9912
  this.touch();
7715
9913
  }
9914
+ for (const b of this.teamBudgets()) {
9915
+ if (b.level === "ok")
9916
+ continue;
9917
+ const mapKey = `team:${b.scope}:${b.key}`;
9918
+ const seen = `${day}:${b.level}`;
9919
+ const affected = b.scope === "project" ? this.projects().filter((p) => this.clusterKeyFor(p.id) === b.key) : this.projects();
9920
+ if (this.budgetNotified.get(mapKey) !== seen) {
9921
+ this.budgetNotified.set(mapKey, seen);
9922
+ const label = b.scope === "org" ? "the org" : `${b.scope} ${b.key}`;
9923
+ this.append({
9924
+ ts: new Date().toISOString(),
9925
+ type: "incident.opened",
9926
+ projectId: affected[0]?.id ?? "p_unknown",
9927
+ sessionId: null,
9928
+ payload: {
9929
+ rule: "budget",
9930
+ action: b.level === "exceeded" ? b.on_exceed : "warn",
9931
+ command: `team ${b.kind ?? ""} budget \xB7 ${label}`,
9932
+ reason: b.level === "exceeded" ? `${label} spent $${b.spent.toFixed(2)} of the $${b.limit} ${b.kind} ceiling set on the team daemon. ${b.on_exceed === "stop" ? "Spawned runs were stopped." : b.on_exceed === "ask" ? "Every Bash/Edit/Write now asks first." : "An admin can raise it via POST /t1/budgets."}` : `${label} is at $${b.spent.toFixed(2)} of the $${b.limit} ${b.kind} ceiling \u2014 approaching the team's limit`
9933
+ }
9934
+ });
9935
+ this.touch();
9936
+ }
9937
+ if (b.level === "exceeded" && b.on_exceed === "stop")
9938
+ for (const p of affected)
9939
+ for (const fn of this.budgetListeners)
9940
+ fn(p.id, {
9941
+ level: "exceeded",
9942
+ kind: b.kind === "daily" ? "daily" : "weekly",
9943
+ spent: b.spent,
9944
+ limit: b.limit,
9945
+ pct: b.limit ? b.spent / b.limit : 1,
9946
+ daily: { spent: b.spent, limit: b.limit, pct: 1 },
9947
+ weekly: { spent: 0, limit: null, pct: 0 }
9948
+ });
9949
+ }
7716
9950
  return out;
7717
9951
  }
9952
+ teamBudgets() {
9953
+ try {
9954
+ return JSON.parse(this.metaValue("team_budget") ?? "[]");
9955
+ } catch {
9956
+ return [];
9957
+ }
9958
+ }
9959
+ backupTo(destDir) {
9960
+ mkdirSync4(destDir, { recursive: true });
9961
+ const files = [];
9962
+ const dbDest = join8(destDir, "swarm.db");
9963
+ if (existsSync6(dbDest))
9964
+ unlinkSync(dbDest);
9965
+ this.db.exec(`VACUUM INTO '${dbDest.replaceAll("'", "''")}'`);
9966
+ files.push("swarm.db");
9967
+ for (const f of [
9968
+ "config.toml",
9969
+ "policy.toml",
9970
+ "policy.sig.json",
9971
+ "token",
9972
+ "pricing.json",
9973
+ "pricing.litellm.json",
9974
+ "team-token"
9975
+ ]) {
9976
+ const src = join8(this.home, f);
9977
+ if (!existsSync6(src))
9978
+ continue;
9979
+ copyFileSync(src, join8(destDir, f));
9980
+ files.push(f);
9981
+ }
9982
+ return { dest: destDir, files };
9983
+ }
9984
+ clusterKeyCache = new Map;
9985
+ clusterKeyFor(projectId) {
9986
+ const hit = this.clusterKeyCache.get(projectId);
9987
+ if (hit)
9988
+ return hit;
9989
+ const root = this.db.query("SELECT root FROM projects WHERE id = ?").get(projectId)?.root;
9990
+ const key = root && clusterProjectKey(originUrl(root)) || `local:${projectId}`;
9991
+ this.clusterKeyCache.set(projectId, key);
9992
+ return key;
9993
+ }
9994
+ taskSpendRollup(day = new Date().toISOString().slice(0, 10)) {
9995
+ return this.db.query(`SELECT s.project_id AS projectId, c.task AS task, SUM(t.cost_usd) AS cost
9996
+ FROM turns t JOIN sessions s ON s.id = t.session_id
9997
+ JOIN claims c ON c.project_id = s.project_id AND c.worktree != '' AND (s.cwd = c.worktree OR s.cwd LIKE c.worktree || '/%')
9998
+ WHERE t.ts >= ? AND t.ts < ? GROUP BY s.project_id, c.task`).all(`${day}T00:00:00.000Z`, `${day}T23:59:59.999Z`);
9999
+ }
10000
+ modelFlagged = new Set;
10001
+ checkModels() {
10002
+ const since = new Date(Date.now() - IDLE_MS).toISOString();
10003
+ const rows = this.db.query("SELECT id, project_id, model FROM sessions WHERE model IS NOT NULL AND model != '' AND state != 'ended' AND last_seen_at > ?").all(since);
10004
+ let n = 0;
10005
+ for (const s of rows) {
10006
+ if (this.modelFlagged.has(s.id))
10007
+ continue;
10008
+ const allow = this.config(s.project_id).models.allow;
10009
+ if (!allow.length || modelAllowed(s.model, allow))
10010
+ continue;
10011
+ this.modelFlagged.add(s.id);
10012
+ n++;
10013
+ this.append({
10014
+ ts: new Date().toISOString(),
10015
+ type: "incident.opened",
10016
+ projectId: s.project_id,
10017
+ sessionId: s.id,
10018
+ payload: {
10019
+ rule: "model_allowlist",
10020
+ action: "observed",
10021
+ command: s.model,
10022
+ reason: `session runs on "${s.model}", outside [models] allow (${allow.join(", ")}) \u2014 nothing was interrupted; spawned runs on this model are refused`
10023
+ }
10024
+ });
10025
+ }
10026
+ return n;
10027
+ }
7718
10028
  spend() {
7719
10029
  const dayStart = new Date;
7720
10030
  dayStart.setHours(0, 0, 0, 0);
@@ -7862,6 +10172,16 @@ ${err}
7862
10172
  WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).get(...projectId ? [projectId] : []);
7863
10173
  return r.n;
7864
10174
  }
10175
+ openIncidentsByProject() {
10176
+ const rows = this.db.query(`SELECT e.project_id AS pid, COUNT(*) AS n FROM events e
10177
+ LEFT JOIN incident_acks a ON a.seq = e.seq
10178
+ WHERE e.type = 'incident.opened' AND a.seq IS NULL AND e.project_id IS NOT NULL
10179
+ GROUP BY e.project_id`).all();
10180
+ const out = {};
10181
+ for (const r of rows)
10182
+ out[r.pid] = r.n;
10183
+ return out;
10184
+ }
7865
10185
  ackIncident(seq, by) {
7866
10186
  const row = this.db.query("SELECT seq FROM events WHERE seq = ? AND type = 'incident.opened'").get(seq);
7867
10187
  if (!row)
@@ -8185,14 +10505,14 @@ ${err}
8185
10505
  const from = localDayIso(-13);
8186
10506
  const rows = this.db.query(`SELECT s.project_id AS pid, substr(t.ts, 1, 10) AS day, SUM(t.cost_usd) AS usd
8187
10507
  FROM turns t JOIN sessions s ON s.id = t.session_id WHERE t.ts >= ? GROUP BY pid, day`).all(from);
8188
- const days2 = [];
10508
+ const days3 = [];
8189
10509
  for (let i = 13;i >= 0; i--)
8190
- days2.push(localDayIso(-i).slice(0, 10));
10510
+ days3.push(localDayIso(-i).slice(0, 10));
8191
10511
  const out = {};
8192
10512
  for (const r of rows) {
8193
10513
  out[r.pid] ??= new Array(14).fill(0);
8194
10514
  const arr = out[r.pid];
8195
- const i = days2.indexOf(r.day);
10515
+ const i = days3.indexOf(r.day);
8196
10516
  if (i >= 0)
8197
10517
  arr[i] = (arr[i] ?? 0) + (r.usd ?? 0);
8198
10518
  }
@@ -8213,12 +10533,22 @@ ${err}
8213
10533
  processes: this.memoised("processes", 5000, () => this.processes()),
8214
10534
  incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
8215
10535
  openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
10536
+ openIncidentsByProject: this.memoised("openIncidentsByProject", 30000, () => this.openIncidentsByProject()),
8216
10537
  questions: this.questions({ open: true, limit: 50 }),
8217
10538
  resources: this.resources(),
8218
10539
  seq: this.seq()
8219
10540
  };
8220
10541
  }
8221
10542
  }
10543
+ function safeJson(v) {
10544
+ if (v === null)
10545
+ return null;
10546
+ try {
10547
+ return JSON.parse(v);
10548
+ } catch {
10549
+ return v;
10550
+ }
10551
+ }
8222
10552
  var WIRE_COLS = "seq, ts, type, project_id, session_id, actor_kind, actor_id, json_remove(payload, '$.toolInput', '$.toolResponse', '$.prompt') AS payload";
8223
10553
  var RAW_TOOL_KEYS = ["tool_input", "tool_response", "toolInput", "toolResponse", "toolResult"];
8224
10554
  var TOOL_INPUT_MAX = 2048;
@@ -8344,6 +10674,192 @@ function localDayIso(offsetDays) {
8344
10674
  return d.toISOString();
8345
10675
  }
8346
10676
 
10677
+ // packages/daemon/src/team.ts
10678
+ import { writeFileSync as writeFileSync3 } from "fs";
10679
+ import { join as join9 } from "path";
10680
+ var SPEND_EVERY_MS = 60000;
10681
+ var MAX_BACKOFF_MS = 300000;
10682
+ var POLICY_EVERY_MS = 300000;
10683
+
10684
+ class TeamForwarder {
10685
+ store;
10686
+ version;
10687
+ lastTry = 0;
10688
+ backoffMs = 0;
10689
+ lastSpend = 0;
10690
+ constructor(store, version) {
10691
+ this.store = store;
10692
+ this.version = version;
10693
+ }
10694
+ projectKey(projectId) {
10695
+ return this.store.clusterKeyFor(projectId);
10696
+ }
10697
+ status() {
10698
+ const team2 = this.store.policyFor(null).config.team;
10699
+ const box = this.store.outboxStatus();
10700
+ return {
10701
+ configured: team2.url != null,
10702
+ url: team2.url,
10703
+ forward: team2.forward,
10704
+ pending: box.pending,
10705
+ oldest: box.oldest,
10706
+ lastAckAt: this.store.metaValue("team_last_ack") ?? null,
10707
+ lastError: this.store.metaValue("team_last_error") ?? null,
10708
+ machine: this.store.machineIdentity(),
10709
+ authed: this.store.metaValue("team_machine_token") != null
10710
+ };
10711
+ }
10712
+ async tick(now = Date.now()) {
10713
+ const team2 = this.store.policyFor(null).config.team;
10714
+ if (!team2.url)
10715
+ return 0;
10716
+ if (now - this.lastTry < team2.interval * 1000 + this.backoffMs)
10717
+ return 0;
10718
+ this.lastTry = now;
10719
+ const records = [];
10720
+ const events = team2.forward.includes("ledger") ? this.store.outboxPending() : [];
10721
+ for (const e of events) {
10722
+ const body = JSON.parse(e.payload);
10723
+ if (typeof body.projectId === "string")
10724
+ body.projectKey = this.projectKey(body.projectId);
10725
+ records.push({ seq: e.seq, kind: e.kind, body });
10726
+ }
10727
+ let spendRows = 0;
10728
+ if (team2.forward.includes("cost") && now - this.lastSpend > SPEND_EVERY_MS) {
10729
+ const day = new Date(now).toISOString().slice(0, 10);
10730
+ for (const r of this.store.spendRollup(day)) {
10731
+ records.push({
10732
+ seq: 0,
10733
+ kind: "spend",
10734
+ body: { day, ...r, projectKey: this.projectKey(r.projectId) }
10735
+ });
10736
+ spendRows++;
10737
+ }
10738
+ for (const r of this.store.taskSpendRollup(day)) {
10739
+ records.push({
10740
+ seq: 0,
10741
+ kind: "spend_task",
10742
+ body: { day, ...r, projectKey: this.projectKey(r.projectId) }
10743
+ });
10744
+ spendRows++;
10745
+ }
10746
+ }
10747
+ const held = this.store.heldClaimsForSync();
10748
+ if (!records.length && !held.length)
10749
+ return 0;
10750
+ const machine = { ...this.store.machineIdentity(), version: this.version };
10751
+ const token = this.store.metaValue("team_machine_token");
10752
+ const headers = {
10753
+ "content-type": "application/json",
10754
+ ...token ? { authorization: `Bearer ${token}` } : {}
10755
+ };
10756
+ const base = this.store.policyFor(null).config.team.url;
10757
+ try {
10758
+ if (records.length) {
10759
+ const req = { machine, records };
10760
+ const res = await fetch(`${base}/t1/ingest`, {
10761
+ method: "POST",
10762
+ headers,
10763
+ body: JSON.stringify(req),
10764
+ signal: AbortSignal.timeout(1e4)
10765
+ });
10766
+ if (!res.ok)
10767
+ throw new Error(`ingest ${res.status}`);
10768
+ const reply = await res.json();
10769
+ if (reply.ack > 0)
10770
+ this.store.outboxAck(reply.ack);
10771
+ if (spendRows)
10772
+ this.lastSpend = now;
10773
+ }
10774
+ if (held.length) {
10775
+ const claims = held.map((c) => ({
10776
+ projectKey: this.projectKey(c.projectId),
10777
+ task: c.task,
10778
+ acquiredAt: c.acquiredAt,
10779
+ expiresAt: c.expiresAt,
10780
+ actor: c.actorKind && c.actorId ? { kind: c.actorKind, id: c.actorId } : undefined
10781
+ }));
10782
+ const res = await fetch(`${base}/t1/claims`, {
10783
+ method: "POST",
10784
+ headers,
10785
+ body: JSON.stringify({ machine, claims }),
10786
+ signal: AbortSignal.timeout(1e4)
10787
+ });
10788
+ if (!res.ok)
10789
+ throw new Error(`claims ${res.status}`);
10790
+ const reply = await res.json();
10791
+ for (const r of reply.results) {
10792
+ const local = held.find((c) => c.task === r.task && this.projectKey(c.projectId) === r.projectKey);
10793
+ if (!local)
10794
+ continue;
10795
+ if (r.status === "ok") {
10796
+ if (local.teamState !== "registered")
10797
+ this.store.markClaimTeamState(local.projectId, local.task, "registered");
10798
+ } else
10799
+ this.store.revokeClaimConflict(local.projectId, local.task, r.holder);
10800
+ }
10801
+ }
10802
+ try {
10803
+ const res = await fetch(`${base}/t1/budget`, {
10804
+ headers,
10805
+ signal: AbortSignal.timeout(1e4)
10806
+ });
10807
+ if (res.ok) {
10808
+ const { budgets } = await res.json();
10809
+ this.store.setMetaValue("team_budget", JSON.stringify(budgets ?? []));
10810
+ }
10811
+ } catch {}
10812
+ this.backoffMs = 0;
10813
+ this.store.setMetaValue("team_last_ack", new Date(now).toISOString());
10814
+ this.store.setMetaValue("team_last_error", "");
10815
+ await this.syncPolicy(base, headers, now);
10816
+ return records.length + held.length;
10817
+ } catch (e) {
10818
+ this.backoffMs = Math.min(this.backoffMs ? this.backoffMs * 2 : 5000, MAX_BACKOFF_MS);
10819
+ this.store.setMetaValue("team_last_error", e.message);
10820
+ return 0;
10821
+ }
10822
+ }
10823
+ lastPolicy = 0;
10824
+ async syncPolicy(base, headers, now) {
10825
+ if (now - this.lastPolicy < POLICY_EVERY_MS)
10826
+ return;
10827
+ this.lastPolicy = now;
10828
+ try {
10829
+ const res = await fetch(`${base}/t1/policy`, {
10830
+ headers,
10831
+ signal: AbortSignal.timeout(1e4)
10832
+ });
10833
+ if (!res.ok)
10834
+ return;
10835
+ const { policy: policy2 } = await res.json();
10836
+ if (!policy2)
10837
+ return;
10838
+ let pinned = this.store.metaValue("team_policy_pubkey");
10839
+ if (!pinned) {
10840
+ pinned = policy2.publicKey;
10841
+ this.store.setMetaValue("team_policy_pubkey", pinned);
10842
+ }
10843
+ if (!verifyPolicySignature(policy2.toml, policy2.signature, pinned)) {
10844
+ this.store.setMetaValue("team_last_error", "org policy signature invalid \u2014 not installed");
10845
+ return;
10846
+ }
10847
+ const file = join9(this.store.home, "policy.toml");
10848
+ const prev = this.store.metaValue("team_policy_sig");
10849
+ if (prev === policy2.signature)
10850
+ return;
10851
+ writeFileSync3(file, policy2.toml, { mode: 384 });
10852
+ writeFileSync3(join9(this.store.home, "policy.sig.json"), JSON.stringify({
10853
+ signature: policy2.signature,
10854
+ publicKey: pinned,
10855
+ fetchedAt: new Date(now).toISOString(),
10856
+ url: base
10857
+ }), { mode: 384 });
10858
+ this.store.setMetaValue("team_policy_sig", policy2.signature);
10859
+ } catch {}
10860
+ }
10861
+ }
10862
+
8347
10863
  // packages/daemon/src/workflow.ts
8348
10864
  class WorkflowEngine {
8349
10865
  store;
@@ -8535,13 +11051,13 @@ class WorkflowEngine {
8535
11051
  }
8536
11052
 
8537
11053
  // packages/daemon/src/app.ts
8538
- var VERSION = "0.9.0";
11054
+ var VERSION = "0.11.0";
8539
11055
  var WEB_DIR = (() => {
8540
11056
  if (process.env.SWARM_WEB_DIR)
8541
11057
  return process.env.SWARM_WEB_DIR;
8542
11058
  const here = dirname4(fileURLToPath2(import.meta.url));
8543
- const dev = join9(here, "../../web/public");
8544
- return existsSync7(join9(dev, "index.html")) ? dev : join9(here, "../web");
11059
+ const dev = join10(here, "../../web/public");
11060
+ return existsSync7(join10(dev, "index.html")) ? dev : join10(here, "../web");
8545
11061
  })();
8546
11062
  var REPLAY_TAIL = 200;
8547
11063
  var wireCache = new WeakMap;
@@ -8559,7 +11075,7 @@ function hookRepoRoot(store, raw2) {
8559
11075
  }
8560
11076
  function claudeSettings() {
8561
11077
  try {
8562
- const p = process.env.CLAUDE_SETTINGS ?? join9(homedir4(), ".claude", "settings.json");
11078
+ const p = process.env.CLAUDE_SETTINGS ?? join10(homedir4(), ".claude", "settings.json");
8563
11079
  return existsSync7(p) ? JSON.parse(readFileSync4(p, "utf8")) : null;
8564
11080
  } catch {
8565
11081
  return null;
@@ -8570,7 +11086,7 @@ function diskVersion() {
8570
11086
  const entry = daemonCommand().at(-1);
8571
11087
  if (!entry || !existsSync7(entry))
8572
11088
  return null;
8573
- for (const f of [entry, join9(dirname4(entry), "app.ts")]) {
11089
+ for (const f of [entry, join10(dirname4(entry), "app.ts")]) {
8574
11090
  if (!existsSync7(f))
8575
11091
  continue;
8576
11092
  const m = /SWARM_VERSION\s*\?\?\s*"(\d+\.\d+\.\d+)"/.exec(readFileSync4(f, "utf8"));
@@ -8588,6 +11104,7 @@ function createApp(store = new Store, hooks2 = {}) {
8588
11104
  const runner = new Runner(store, store.home);
8589
11105
  const dispatcher = new Dispatcher(store, runner, forge2);
8590
11106
  const workflows2 = new WorkflowEngine(store, runner, forge2);
11107
+ const team2 = new TeamForwarder(store, VERSION);
8591
11108
  store.onBudgetStop((projectId) => {
8592
11109
  dispatcher.clear(projectId);
8593
11110
  for (const run2 of runner.list(projectId))
@@ -8650,7 +11167,7 @@ function createApp(store = new Store, hooks2 = {}) {
8650
11167
  dir = homedir4();
8651
11168
  }
8652
11169
  try {
8653
- const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync7(join9(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
11170
+ const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync7(join10(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
8654
11171
  const parent = dirname4(dir);
8655
11172
  return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
8656
11173
  } catch (e) {
@@ -8659,10 +11176,130 @@ function createApp(store = new Store, hooks2 = {}) {
8659
11176
  });
8660
11177
  app.get("/v1/state", (c) => c.json(store.snapshot()));
8661
11178
  app.get("/v1/stats", (c) => c.json(store.stats(c.req.query("project") || undefined)));
11179
+ app.get("/v1/graphs/collisions", (c) => c.json(store.collisions(c.req.query("project") || undefined)));
11180
+ app.get("/v1/graphs/lineage", (c) => c.json(store.lineage(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 14) || 14)), c.req.queries("expand") ?? [])));
11181
+ app.get("/v1/context", (c) => c.json(store.context(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)))));
11182
+ app.get("/v1/mcp/health", (c) => c.json(store.mcpHealth(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)))));
11183
+ app.get("/v1/hygiene", (c) => c.json(store.hygiene(c.req.query("project") || undefined)));
11184
+ app.get("/v1/gates/health", (c) => c.json(store.gateHealth(c.req.query("project") || undefined, Math.max(1, Math.min(365, Number(c.req.query("days") ?? 30) || 30)))));
11185
+ app.get("/v1/waiting", (c) => c.json(store.waiting(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)))));
8662
11186
  app.get("/v1/incidents", (c) => c.json(store.incidents(Number(c.req.query("limit") ?? 50), {
8663
11187
  open: c.req.query("open") === "1",
8664
11188
  projectId: c.req.query("project") || undefined
8665
11189
  })));
11190
+ const outcomesFor = async (project) => {
11191
+ const sessions = store.snapshot().sessions.filter((s) => !project || s.projectId === project).map((s) => ({
11192
+ id: s.id,
11193
+ branch: s.branch,
11194
+ model: s.model,
11195
+ agent: s.agent,
11196
+ costUsd: s.costUsd,
11197
+ startedAt: s.startedAt
11198
+ }));
11199
+ const prs = [];
11200
+ const reverted = new Set;
11201
+ for (const p of store.projects().filter((x) => !project || x.id === project)) {
11202
+ const o = await forge2.merged(p.id, p.root);
11203
+ for (const m of o.merged)
11204
+ prs.push({ ...m, state: "merged" });
11205
+ for (const sha of o.reverted)
11206
+ reverted.add(sha);
11207
+ }
11208
+ for (const pr of forge2.prs())
11209
+ if (!project || pr.projectId === project)
11210
+ prs.push({
11211
+ branch: pr.branch,
11212
+ number: pr.number,
11213
+ state: "open",
11214
+ title: pr.title,
11215
+ url: pr.url,
11216
+ createdAt: pr.createdAt,
11217
+ mergedAt: null,
11218
+ mergeSha: null
11219
+ });
11220
+ return outcomeReport(sessions, prs, reverted);
11221
+ };
11222
+ app.get("/v1/outcomes", async (c) => c.json(await outcomesFor(c.req.query("project") || undefined)));
11223
+ app.get("/v1/ab", (c) => {
11224
+ const project = c.req.query("project") || undefined;
11225
+ const task = c.req.query("task");
11226
+ if (task) {
11227
+ if (!project)
11228
+ return c.json({ error: "project is required with task" }, 400);
11229
+ return c.json(store.abTrial(project, task));
11230
+ }
11231
+ return c.json({ trials: store.abTrials(project) });
11232
+ });
11233
+ app.post("/v1/ab", async (c) => {
11234
+ const b = await c.req.json().catch(() => ({}));
11235
+ if (!b.projectId || !b.task)
11236
+ return c.json({ error: "projectId and task are required" }, 400);
11237
+ const arms = (b.arms ?? []).filter((a) => a.model || a.label);
11238
+ if (arms.length < 2)
11239
+ return c.json({ error: "a trial needs at least two arms" }, 400);
11240
+ const cfg = store.config(b.projectId);
11241
+ const started = [];
11242
+ const failed = [];
11243
+ for (const a of arms) {
11244
+ const label = (a.label ?? a.model ?? "").replace(/[^a-zA-Z0-9._-]+/g, "-");
11245
+ const id = armTask(b.task, label);
11246
+ const r = await runner.start({
11247
+ projectId: b.projectId,
11248
+ task: id,
11249
+ prompt: taskPrompt({ id: b.task, title: b.title ?? b.task }, {
11250
+ requiredGates: cfg.gates.required,
11251
+ executableGates: cfg.gates.required.filter((g) => cfg.gates.defs[g]),
11252
+ openPr: false
11253
+ }),
11254
+ owner: `ab:${label}`,
11255
+ permissionMode: cfg.dispatch.permission_mode ?? "acceptEdits",
11256
+ ...a.model ? { model: a.model } : {},
11257
+ ...cfg.dispatch.max_turns ? { maxTurns: cfg.dispatch.max_turns } : {}
11258
+ });
11259
+ if (r.ok)
11260
+ started.push(label);
11261
+ else
11262
+ failed.push({ arm: label, reason: r.reason });
11263
+ }
11264
+ store.append({
11265
+ ts: new Date().toISOString(),
11266
+ type: "dispatch.queued",
11267
+ projectId: b.projectId,
11268
+ sessionId: null,
11269
+ payload: {
11270
+ task: b.task,
11271
+ arms: arms.map((a) => a.label ?? a.model),
11272
+ summary: `A/B ${b.task}: ${started.length} arm${started.length === 1 ? "" : "s"} started`
11273
+ }
11274
+ });
11275
+ return c.json({ ok: failed.length === 0, started, failed }, failed.length ? 207 : 200);
11276
+ });
11277
+ app.get("/v1/provenance", async (c) => {
11278
+ const project = c.req.query("project") || undefined;
11279
+ const { branches } = await outcomesFor(project);
11280
+ const projects = store.projects().filter((p) => !project || p.id === project);
11281
+ const tasks2 = [];
11282
+ for (const p of projects)
11283
+ for (const t of store.tasks(p.id)?.tasks ?? [])
11284
+ tasks2.push({ id: t.id, title: t.title, status: t.statusText, url: null });
11285
+ const claims = store.claims(project).map((cl) => ({
11286
+ task: cl.task,
11287
+ sessionId: cl.sessionId,
11288
+ owner: cl.owner || null,
11289
+ worktree: cl.worktree || null,
11290
+ branch: cl.branch || null,
11291
+ acquiredAt: cl.acquiredAt,
11292
+ state: cl.state
11293
+ }));
11294
+ const sessions = store.snapshot().sessions.filter((s) => !project || s.projectId === project).map((s) => ({
11295
+ id: s.id,
11296
+ title: s.title,
11297
+ agent: s.agent,
11298
+ branch: s.branch,
11299
+ costUsd: s.costUsd
11300
+ }));
11301
+ return c.json(provenance(tasks2, claims, sessions, branches));
11302
+ });
8666
11303
  app.get("/v1/memory", (c) => {
8667
11304
  const q = c.req.query("q") ?? "";
8668
11305
  const kind = c.req.query("kind");
@@ -8676,13 +11313,33 @@ function createApp(store = new Store, hooks2 = {}) {
8676
11313
  })
8677
11314
  });
8678
11315
  });
11316
+ app.post("/v1/backup", async (c) => {
11317
+ const b = await c.req.json().catch(() => ({}));
11318
+ if (typeof b.dest !== "string" || !b.dest.startsWith("/"))
11319
+ return c.json({ error: "dest must be an absolute path" }, 400);
11320
+ try {
11321
+ return c.json(store.backupTo(b.dest));
11322
+ } catch (e) {
11323
+ return c.json({ error: e.message }, 500);
11324
+ }
11325
+ });
11326
+ app.get("/v1/team", (c) => c.json(team2.status()));
11327
+ app.post("/v1/team/credentials", async (c) => {
11328
+ const b = await c.req.json().catch(() => ({}));
11329
+ if (typeof b.token !== "string" || !b.token)
11330
+ return c.json({ error: "token required" }, 400);
11331
+ store.setMetaValue("team_machine_token", b.token);
11332
+ if (typeof b.policyPublicKey === "string" && b.policyPublicKey)
11333
+ store.setMetaValue("team_policy_pubkey", b.policyPublicKey);
11334
+ return c.json({ ok: true, machine: store.machineIdentity() });
11335
+ });
8679
11336
  app.get("/v1/policy", (c) => {
8680
11337
  const id = c.req.query("project");
8681
11338
  const p = id ? store.project(id) : null;
8682
11339
  if (id && !p)
8683
11340
  return c.json({ error: "unknown project" }, 404);
8684
- const { provenance, overridden, policy: policy2 } = store.policyFor(p?.root ?? null);
8685
- return c.json({ ...policy2, provenance, overridden });
11341
+ const { provenance: provenance3, overridden, policy: policy2 } = store.policyFor(p?.root ?? null);
11342
+ return c.json({ ...policy2, provenance: provenance3, overridden });
8686
11343
  });
8687
11344
  app.get("/v1/audit", (c) => {
8688
11345
  const since = sinceToIso(c.req.query("since"));
@@ -9227,18 +11884,24 @@ function createApp(store = new Store, hooks2 = {}) {
9227
11884
  });
9228
11885
  });
9229
11886
  });
9230
- app.get("/", (c) => c.html(readFileSync4(join9(WEB_DIR, "index.html"), "utf8")));
11887
+ app.get("/", (c) => c.html(readFileSync4(join10(WEB_DIR, "index.html"), "utf8")));
9231
11888
  const MIME = { js: "text/javascript", css: "text/css" };
9232
11889
  app.get("/:file{[a-z0-9-]+\\.(js|css)}", (c) => {
9233
11890
  const f = c.req.param("file");
9234
- const p = join9(WEB_DIR, f);
11891
+ const p = join10(WEB_DIR, f);
9235
11892
  if (!existsSync7(p))
9236
11893
  return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
11894
+ const st = statSync2(p);
11895
+ const etag = `W/"${st.size.toString(16)}-${st.mtimeMs.toString(16)}"`;
11896
+ if (c.req.header("if-none-match") === etag)
11897
+ return c.body(null, 304, { etag });
9237
11898
  return c.body(readFileSync4(p, "utf8"), 200, {
9238
- "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain"
11899
+ "content-type": MIME[f.split(".").pop() ?? ""] ?? "text/plain",
11900
+ "cache-control": "no-cache",
11901
+ etag
9239
11902
  });
9240
11903
  });
9241
- return { app, store, forge: forge2, runner, dispatcher, workflows: workflows2 };
11904
+ return { app, store, forge: forge2, runner, dispatcher, workflows: workflows2, team: team2 };
9242
11905
  }
9243
11906
 
9244
11907
  // packages/daemon/src/demo.ts
@@ -9330,7 +11993,7 @@ function seedDemo(store) {
9330
11993
  // packages/daemon/src/bin.ts
9331
11994
  var DEFAULT_PORT2 = process.env.SWARM_PORT ? DEFAULT_PORT : loadConfig().daemon.port;
9332
11995
  var appHooks = {};
9333
- var { app, store, runner } = createApp(new Store, appHooks);
11996
+ var { app, store, runner, team: team2 } = createApp(new Store, appHooks);
9334
11997
  if (process.env.SWARM_DEMO === "1" && isEmpty(store))
9335
11998
  seedDemo(store);
9336
11999
  function serve() {
@@ -9378,6 +12041,8 @@ if (!DEMO) {
9378
12041
  store.tailCodex(backfillMs);
9379
12042
  store.tailGrok(backfillMs);
9380
12043
  store.tailGemini(backfillMs);
12044
+ store.tailAider(backfillMs);
12045
+ store.tailOpencode(backfillMs);
9381
12046
  }
9382
12047
  var tick = 0;
9383
12048
  var tailer = setInterval(() => {
@@ -9388,6 +12053,8 @@ var tailer = setInterval(() => {
9388
12053
  store.tailCodex();
9389
12054
  store.tailGrok();
9390
12055
  store.tailGemini();
12056
+ store.tailAider();
12057
+ store.tailOpencode();
9391
12058
  }
9392
12059
  store.reapResources();
9393
12060
  store.reapProcesses();
@@ -9395,6 +12062,11 @@ var tailer = setInterval(() => {
9395
12062
  store.sweepOrphans();
9396
12063
  if (tick % 6 === 0)
9397
12064
  store.checkBudgets();
12065
+ if (tick % 12 === 0)
12066
+ store.checkModels();
12067
+ if (tick % 2 === 0)
12068
+ store.checkStalls();
12069
+ team2.tick();
9398
12070
  }, 5000);
9399
12071
  store.refreshAllWorktrees();
9400
12072
  var wtRefresh = setInterval(() => void store.refreshAllWorktrees(), 15000);