@ra3orblade/swarm 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/swarm-hook.js +687 -166
- package/dist/swarm-mcp.js +2 -1
- package/dist/swarm.js +711 -239
- package/dist/swarmd.js +2573 -870
- package/package.json +1 -1
- package/web/dashboard.js +18 -18
- package/web/release-notes.js +1 -1
package/dist/swarmd.js
CHANGED
|
@@ -15,7 +15,8 @@ var SRC = {
|
|
|
15
15
|
swarm: "cli",
|
|
16
16
|
swarmd: "daemon",
|
|
17
17
|
"swarm-hook": "hook",
|
|
18
|
-
"swarm-mcp": "mcp"
|
|
18
|
+
"swarm-mcp": "mcp",
|
|
19
|
+
"swarm-teamd": "team"
|
|
19
20
|
};
|
|
20
21
|
function resolveBin(name, from = import.meta.url) {
|
|
21
22
|
const here = dirname(fileURLToPath(from));
|
|
@@ -360,9 +361,14 @@ function parseTranscriptChunk(chunk) {
|
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
// packages/core/src/adapters/codex/rollout.ts
|
|
363
|
-
function parseCodexRollout(chunk) {
|
|
364
|
-
const out = {
|
|
365
|
-
|
|
364
|
+
function parseCodexRollout(chunk, sessionIdHint) {
|
|
365
|
+
const out = {
|
|
366
|
+
turns: [],
|
|
367
|
+
sessionId: sessionIdHint ?? null,
|
|
368
|
+
cwd: null,
|
|
369
|
+
model: null,
|
|
370
|
+
title: null
|
|
371
|
+
};
|
|
366
372
|
let text = "";
|
|
367
373
|
let tools = [];
|
|
368
374
|
let lastTs = "";
|
|
@@ -400,9 +406,10 @@ function parseCodexRollout(chunk) {
|
|
|
400
406
|
if (!u)
|
|
401
407
|
continue;
|
|
402
408
|
const cacheRead = u.cached_input_tokens ?? 0;
|
|
409
|
+
const ts = d.timestamp ?? (lastTs || new Date(0).toISOString());
|
|
403
410
|
const turn = {
|
|
404
|
-
id: `${out.sessionId ?? "codex"}
|
|
405
|
-
ts
|
|
411
|
+
id: `${out.sessionId ?? "codex"}-${ts}-${u.input_tokens ?? 0}-${cacheRead}-${u.output_tokens ?? 0}-${u.reasoning_output_tokens ?? 0}`,
|
|
412
|
+
ts,
|
|
406
413
|
model: out.model ?? "gpt-5",
|
|
407
414
|
usage: {
|
|
408
415
|
input: Math.max(0, (u.input_tokens ?? 0) - cacheRead),
|
|
@@ -418,7 +425,6 @@ function parseCodexRollout(chunk) {
|
|
|
418
425
|
sidechain: false
|
|
419
426
|
};
|
|
420
427
|
out.turns.push(turn);
|
|
421
|
-
n++;
|
|
422
428
|
text = "";
|
|
423
429
|
tools = [];
|
|
424
430
|
}
|
|
@@ -443,8 +449,14 @@ function partText(content) {
|
|
|
443
449
|
}
|
|
444
450
|
return out.slice(0, 400);
|
|
445
451
|
}
|
|
446
|
-
function parseGeminiChat(chunk) {
|
|
447
|
-
const out = {
|
|
452
|
+
function parseGeminiChat(chunk, sessionIdHint) {
|
|
453
|
+
const out = {
|
|
454
|
+
turns: [],
|
|
455
|
+
sessionId: sessionIdHint ?? null,
|
|
456
|
+
model: null,
|
|
457
|
+
cwd: null,
|
|
458
|
+
title: null
|
|
459
|
+
};
|
|
448
460
|
let subagent = false;
|
|
449
461
|
for (const raw of chunk.split(`
|
|
450
462
|
`)) {
|
|
@@ -634,7 +646,8 @@ var HOOK_EVENTS = [
|
|
|
634
646
|
"Stop",
|
|
635
647
|
"SessionEnd",
|
|
636
648
|
"Notification",
|
|
637
|
-
"PreCompact"
|
|
649
|
+
"PreCompact",
|
|
650
|
+
"PermissionRequest"
|
|
638
651
|
];
|
|
639
652
|
var MAP = {
|
|
640
653
|
SessionStart: "session.started",
|
|
@@ -646,8 +659,20 @@ var MAP = {
|
|
|
646
659
|
Stop: "agent.text",
|
|
647
660
|
SessionEnd: "session.ended",
|
|
648
661
|
Notification: "session.notification",
|
|
649
|
-
PreCompact: "agent.text"
|
|
662
|
+
PreCompact: "agent.text",
|
|
663
|
+
PermissionRequest: "permission.requested"
|
|
650
664
|
};
|
|
665
|
+
function permissionReason(raw) {
|
|
666
|
+
const sug = raw.permission_suggestions;
|
|
667
|
+
if (Array.isArray(sug)) {
|
|
668
|
+
for (const x of sug) {
|
|
669
|
+
const r = x?.reasoning;
|
|
670
|
+
if (typeof r === "string" && r.trim())
|
|
671
|
+
return r.trim();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return `Claude Code is asking before it runs this (${typeof raw.permission_mode === "string" ? raw.permission_mode : "default"} mode)`;
|
|
675
|
+
}
|
|
651
676
|
function summarizeToolInput(tool, input) {
|
|
652
677
|
const i = input ?? {};
|
|
653
678
|
const s = (v) => typeof v === "string" ? v : JSON.stringify(v ?? "");
|
|
@@ -711,10 +736,20 @@ function normalizeHook(event, raw, projectId, ts = new Date().toISOString()) {
|
|
|
711
736
|
case "PreCompact":
|
|
712
737
|
summary = "context compaction";
|
|
713
738
|
break;
|
|
739
|
+
case "PermissionRequest":
|
|
740
|
+
summary = `permission: ${tool ?? "?"} ${summarizeToolInput(tool, raw.tool_input)}`.trim();
|
|
741
|
+
break;
|
|
714
742
|
default:
|
|
715
743
|
summary = event;
|
|
716
744
|
}
|
|
717
745
|
const payload = { hook: event, cwd: raw.cwd ?? null, summary };
|
|
746
|
+
if (event === "PermissionRequest") {
|
|
747
|
+
if (typeof raw.tool_use_id === "string")
|
|
748
|
+
payload.requestId = raw.tool_use_id;
|
|
749
|
+
payload.display = summarizeToolInput(tool, raw.tool_input);
|
|
750
|
+
payload.reason = permissionReason(raw);
|
|
751
|
+
payload.source = "interactive";
|
|
752
|
+
}
|
|
718
753
|
if (tool)
|
|
719
754
|
payload.tool = tool;
|
|
720
755
|
if (raw.tool_input !== undefined)
|
|
@@ -848,6 +883,8 @@ var AUDIT_TYPES = new Set([
|
|
|
848
883
|
"process.started",
|
|
849
884
|
"process.exited",
|
|
850
885
|
"gate.recorded",
|
|
886
|
+
"gate.blocked",
|
|
887
|
+
"codify.applied",
|
|
851
888
|
"handoff.recorded",
|
|
852
889
|
"permission.requested",
|
|
853
890
|
"permission.resolved",
|
|
@@ -1027,6 +1064,31 @@ function runProfile(name) {
|
|
|
1027
1064
|
return null;
|
|
1028
1065
|
return RUN_PROFILES[name] ?? null;
|
|
1029
1066
|
}
|
|
1067
|
+
// packages/core/src/collision-context.ts
|
|
1068
|
+
var DEFAULT_COLLISION_WINDOW_MIN = 15;
|
|
1069
|
+
function ago(ms) {
|
|
1070
|
+
const m = Math.floor(ms / 60000);
|
|
1071
|
+
if (m < 1)
|
|
1072
|
+
return "moments ago";
|
|
1073
|
+
if (m === 1)
|
|
1074
|
+
return "a minute ago";
|
|
1075
|
+
return `${m} minutes ago`;
|
|
1076
|
+
}
|
|
1077
|
+
function collisionWarning(path, sessionId, edits, live, now, windowMs) {
|
|
1078
|
+
const others = edits.filter((e) => e.sessionId !== sessionId && now - e.at <= windowMs && live.has(e.sessionId)).sort((a, b) => b.at - a.at).map((e) => ({ ...live.get(e.sessionId), agoMs: now - e.at }));
|
|
1079
|
+
if (!others.length)
|
|
1080
|
+
return null;
|
|
1081
|
+
const who = others.map((o) => {
|
|
1082
|
+
const name = o.title ? `"${o.title}"` : `session ${o.sessionId.slice(0, 8)}`;
|
|
1083
|
+
const where = [o.task ? `task ${o.task}` : null, o.branch ? `branch ${o.branch}` : null].filter(Boolean).join(", ");
|
|
1084
|
+
return `${name}${where ? ` (${where})` : ""} ${ago(o.agoMs)}`;
|
|
1085
|
+
}).join("; ");
|
|
1086
|
+
return {
|
|
1087
|
+
path,
|
|
1088
|
+
others,
|
|
1089
|
+
text: `[swarm] heads-up: ${path} was also edited by ${who} \u2014 still live. You may be changing the same thing twice or undoing theirs. Look at what changed there (git diff / git log -p on that file) before you go further, or leave that file to them.`
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1030
1092
|
// packages/core/src/config.ts
|
|
1031
1093
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
1032
1094
|
import { join as join2 } from "path";
|
|
@@ -1125,11 +1187,21 @@ function parseGateDefs(gates) {
|
|
|
1125
1187
|
var DEFAULT_CONFIG = {
|
|
1126
1188
|
daemon: { port: 7777, auth: "loopback-optional" },
|
|
1127
1189
|
tasks: { source: null, labels: [], team: null },
|
|
1128
|
-
gates: {
|
|
1190
|
+
gates: {
|
|
1191
|
+
required: [],
|
|
1192
|
+
auto: "session-end",
|
|
1193
|
+
on_stop: "record",
|
|
1194
|
+
max_blocks: 3,
|
|
1195
|
+
stop_timeout: 300,
|
|
1196
|
+
defs: {}
|
|
1197
|
+
},
|
|
1129
1198
|
workflows: {},
|
|
1130
|
-
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
|
|
1199
|
+
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn", window_warn_at: 0.8 },
|
|
1131
1200
|
models: { allow: [] },
|
|
1132
1201
|
notify: { webhook: null },
|
|
1202
|
+
messages: { wake: true },
|
|
1203
|
+
codify: { target: "both" },
|
|
1204
|
+
broker: { interactive_wait: 30 },
|
|
1133
1205
|
team: { url: null, forward: ["ledger", "cost"], interval: 5 },
|
|
1134
1206
|
events: { retain_days: 30 },
|
|
1135
1207
|
audit: { retain_days: 0 },
|
|
@@ -1150,10 +1222,45 @@ var DEFAULT_CONFIG = {
|
|
|
1150
1222
|
protected_ports: "ask",
|
|
1151
1223
|
no_foreign_worktree: "ask",
|
|
1152
1224
|
claim_required_to_write: "off",
|
|
1225
|
+
no_verify: "off",
|
|
1226
|
+
dry_run_first: "off",
|
|
1227
|
+
custom: [],
|
|
1228
|
+
collision_context: true,
|
|
1229
|
+
collision_window: 15,
|
|
1153
1230
|
protected: { ports: [] }
|
|
1154
1231
|
}
|
|
1155
1232
|
};
|
|
1156
1233
|
var MODES = ["ask", "deny", "off"];
|
|
1234
|
+
var REWRITE_MODES = ["rewrite", "ask", "deny", "off"];
|
|
1235
|
+
function parseCustomRules(raw) {
|
|
1236
|
+
if (!Array.isArray(raw))
|
|
1237
|
+
return [];
|
|
1238
|
+
const out = [];
|
|
1239
|
+
const seen = new Set;
|
|
1240
|
+
for (const v of raw) {
|
|
1241
|
+
if (!isRecord2(v))
|
|
1242
|
+
continue;
|
|
1243
|
+
const name = typeof v.name === "string" ? v.name.trim() : "";
|
|
1244
|
+
const match = typeof v.match === "string" ? v.match : "";
|
|
1245
|
+
if (!/^[a-z0-9][a-z0-9_.-]{0,39}$/i.test(name) || !match || seen.has(name))
|
|
1246
|
+
continue;
|
|
1247
|
+
try {
|
|
1248
|
+
new RegExp(match);
|
|
1249
|
+
} catch {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
const action = REWRITE_MODES.includes(v.action) ? v.action : "ask";
|
|
1253
|
+
seen.add(name);
|
|
1254
|
+
out.push({
|
|
1255
|
+
name,
|
|
1256
|
+
match,
|
|
1257
|
+
action,
|
|
1258
|
+
...typeof v.replace === "string" ? { replace: v.replace } : {},
|
|
1259
|
+
...typeof v.reason === "string" && v.reason.trim() ? { reason: v.reason.trim() } : {}
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
return out;
|
|
1263
|
+
}
|
|
1157
1264
|
function isRecord2(v) {
|
|
1158
1265
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1159
1266
|
}
|
|
@@ -1187,6 +1294,7 @@ var days = (v, fallback) => {
|
|
|
1187
1294
|
};
|
|
1188
1295
|
function validate(c) {
|
|
1189
1296
|
const mode = (v, fallback) => MODES.includes(v) ? v : fallback;
|
|
1297
|
+
const rewriteMode = (v, fallback) => REWRITE_MODES.includes(v) ? v : fallback;
|
|
1190
1298
|
const port = Number(c.daemon?.port);
|
|
1191
1299
|
const source = c.tasks?.source;
|
|
1192
1300
|
const setup = c.worktree?.setup;
|
|
@@ -1217,13 +1325,28 @@ function validate(c) {
|
|
|
1217
1325
|
gates: {
|
|
1218
1326
|
required: Array.isArray(rawGates?.required) ? rawGates.required.filter((g) => typeof g === "string" && g.trim() !== "") : [],
|
|
1219
1327
|
auto: AUTO_MODES.includes(auto) ? auto : "session-end",
|
|
1328
|
+
on_stop: rawGates?.on_stop === "block" ? "block" : "record",
|
|
1329
|
+
max_blocks: (() => {
|
|
1330
|
+
const n = Number(rawGates?.max_blocks);
|
|
1331
|
+
return Number.isInteger(n) && n >= 0 && n <= 20 ? n : 3;
|
|
1332
|
+
})(),
|
|
1333
|
+
stop_timeout: (() => {
|
|
1334
|
+
const n = Number(rawGates?.stop_timeout);
|
|
1335
|
+
return Number.isFinite(n) && n >= 5 && n <= 1800 ? n : 300;
|
|
1336
|
+
})(),
|
|
1220
1337
|
defs: parseGateDefs(rawGates)
|
|
1221
1338
|
},
|
|
1222
1339
|
budget: {
|
|
1223
1340
|
daily: usd(b.daily),
|
|
1224
1341
|
weekly: usd(b.weekly),
|
|
1225
1342
|
warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
|
|
1226
|
-
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
|
|
1343
|
+
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn",
|
|
1344
|
+
window_warn_at: (() => {
|
|
1345
|
+
if (b.window_warn_at === false || b.window_warn_at === null || b.window_warn_at === 0)
|
|
1346
|
+
return null;
|
|
1347
|
+
const w = Number(b.window_warn_at);
|
|
1348
|
+
return Number.isFinite(w) && w > 0 && w < 1 ? w : 0.8;
|
|
1349
|
+
})()
|
|
1227
1350
|
},
|
|
1228
1351
|
workflows: parseWorkflows(c.workflows),
|
|
1229
1352
|
notify: {
|
|
@@ -1232,6 +1355,23 @@ function validate(c) {
|
|
|
1232
1355
|
return typeof w === "string" && /^https?:\/\//.test(w.trim()) ? w.trim() : null;
|
|
1233
1356
|
})()
|
|
1234
1357
|
},
|
|
1358
|
+
messages: {
|
|
1359
|
+
wake: c.messages?.wake !== false
|
|
1360
|
+
},
|
|
1361
|
+
codify: {
|
|
1362
|
+
target: (() => {
|
|
1363
|
+
const t = c.codify?.target;
|
|
1364
|
+
return t === "claude-md" || t === "swarm-toml" ? t : "both";
|
|
1365
|
+
})()
|
|
1366
|
+
},
|
|
1367
|
+
broker: {
|
|
1368
|
+
interactive_wait: (() => {
|
|
1369
|
+
const w = Number(c.broker?.interactive_wait);
|
|
1370
|
+
if (!Number.isFinite(w) || w < 0)
|
|
1371
|
+
return 30;
|
|
1372
|
+
return Math.min(Math.round(w), 120);
|
|
1373
|
+
})()
|
|
1374
|
+
},
|
|
1235
1375
|
models: {
|
|
1236
1376
|
allow: Array.isArray(c.models?.allow) ? c.models.allow.filter((m) => typeof m === "string" && m.trim() !== "") : []
|
|
1237
1377
|
},
|
|
@@ -1278,6 +1418,14 @@ function validate(c) {
|
|
|
1278
1418
|
protected_ports: mode(c.rules?.protected_ports, "ask"),
|
|
1279
1419
|
no_foreign_worktree: mode(c.rules?.no_foreign_worktree, "ask"),
|
|
1280
1420
|
claim_required_to_write: mode(c.rules?.claim_required_to_write, "off"),
|
|
1421
|
+
no_verify: rewriteMode(c.rules?.no_verify, "off"),
|
|
1422
|
+
dry_run_first: rewriteMode(c.rules?.dry_run_first, "off"),
|
|
1423
|
+
custom: parseCustomRules(c.rules?.custom),
|
|
1424
|
+
collision_context: c.rules?.collision_context !== false,
|
|
1425
|
+
collision_window: (() => {
|
|
1426
|
+
const n = Number(c.rules?.collision_window);
|
|
1427
|
+
return Number.isFinite(n) && n >= 1 && n <= 240 ? Math.round(n) : 15;
|
|
1428
|
+
})(),
|
|
1281
1429
|
protected: {
|
|
1282
1430
|
ports: Array.isArray(c.rules?.protected?.ports) ? c.rules.protected.ports.filter((p) => Number.isInteger(p) && p > 0 && p < 65536) : []
|
|
1283
1431
|
}
|
|
@@ -1719,14 +1867,120 @@ var DEFAULT_MODES = {
|
|
|
1719
1867
|
protected_ports: "ask",
|
|
1720
1868
|
no_foreign_worktree: "ask",
|
|
1721
1869
|
claim_required_to_write: "off",
|
|
1870
|
+
no_verify: "off",
|
|
1871
|
+
dry_run_first: "off",
|
|
1872
|
+
custom: [],
|
|
1722
1873
|
protected: { ports: [] }
|
|
1723
1874
|
};
|
|
1724
|
-
function
|
|
1875
|
+
function isSingleCommand(cmd) {
|
|
1876
|
+
return !/[&|;<>`\n]/.test(cmd) && !cmd.includes("$(");
|
|
1877
|
+
}
|
|
1878
|
+
function stripFlagsOutsideQuotes(cmd, flags) {
|
|
1879
|
+
let out = "";
|
|
1880
|
+
let quote = null;
|
|
1881
|
+
let i = 0;
|
|
1882
|
+
while (i < cmd.length) {
|
|
1883
|
+
const ch = cmd[i];
|
|
1884
|
+
if (quote) {
|
|
1885
|
+
out += ch;
|
|
1886
|
+
if (ch === quote && cmd[i - 1] !== "\\")
|
|
1887
|
+
quote = null;
|
|
1888
|
+
i++;
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
if (ch === '"' || ch === "'") {
|
|
1892
|
+
quote = ch;
|
|
1893
|
+
out += ch;
|
|
1894
|
+
i++;
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
const rest = cmd.slice(i);
|
|
1898
|
+
const hit = flags.find((f) => rest.startsWith(f) && /^(\s|$)/.test(rest.slice(f.length)));
|
|
1899
|
+
if (hit) {
|
|
1900
|
+
out = out.replace(/[ \t]+$/, "");
|
|
1901
|
+
i += hit.length;
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
out += ch;
|
|
1905
|
+
i++;
|
|
1906
|
+
}
|
|
1907
|
+
return out;
|
|
1908
|
+
}
|
|
1909
|
+
function rewriteNoVerify(cmd) {
|
|
1910
|
+
if (!isSingleCommand(cmd) || !/^\s*git\s/.test(cmd))
|
|
1911
|
+
return null;
|
|
1912
|
+
if (!/--no-(verify|gpg-sign)\b/.test(cmd))
|
|
1913
|
+
return null;
|
|
1914
|
+
const out = stripFlagsOutsideQuotes(cmd, ["--no-verify", "--no-gpg-sign"]);
|
|
1915
|
+
return out === cmd ? null : out;
|
|
1916
|
+
}
|
|
1917
|
+
function rewriteDryRun(cmd) {
|
|
1918
|
+
if (!isSingleCommand(cmd))
|
|
1919
|
+
return null;
|
|
1920
|
+
const key = cmd.replace(/\s+/g, " ").trim();
|
|
1921
|
+
if (/^\s*terraform\s+apply\b/.test(cmd)) {
|
|
1922
|
+
const tail = cmd.replace(/^\s*terraform\s+apply\b/, "").trim();
|
|
1923
|
+
if (tail.length > 0 && tail.split(/\s+/).some((a) => !a.startsWith("-")))
|
|
1924
|
+
return null;
|
|
1925
|
+
return {
|
|
1926
|
+
key,
|
|
1927
|
+
command: cmd.replace(/^(\s*)terraform\s+apply\b/, "$1terraform plan").replace(/\s+-auto-approve\b/g, "").replace(/ {2,}/g, " ").trimEnd()
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
if (/^\s*kubectl\s+delete\b/.test(cmd) && !/--dry-run\b/.test(cmd))
|
|
1931
|
+
return { key, command: `${cmd.trimEnd()} --dry-run=client` };
|
|
1932
|
+
if (/^\s*helm\s+(uninstall|delete)\b/.test(cmd) && !/--dry-run\b/.test(cmd))
|
|
1933
|
+
return { key, command: `${cmd.trimEnd()} --dry-run` };
|
|
1934
|
+
return null;
|
|
1935
|
+
}
|
|
1936
|
+
var customRe = new WeakMap;
|
|
1937
|
+
function compiled(rule) {
|
|
1938
|
+
const hit = customRe.get(rule);
|
|
1939
|
+
if (hit !== undefined)
|
|
1940
|
+
return hit;
|
|
1941
|
+
let re = null;
|
|
1942
|
+
try {
|
|
1943
|
+
re = new RegExp(rule.match, rule.action === "rewrite" ? "g" : "");
|
|
1944
|
+
} catch {
|
|
1945
|
+
re = null;
|
|
1946
|
+
}
|
|
1947
|
+
customRe.set(rule, re);
|
|
1948
|
+
return re;
|
|
1949
|
+
}
|
|
1950
|
+
function applyCustomRule(rule, cmd) {
|
|
1951
|
+
const re = compiled(rule);
|
|
1952
|
+
if (!re)
|
|
1953
|
+
return null;
|
|
1954
|
+
if (rule.action === "off" || !re.test(cmd))
|
|
1955
|
+
return null;
|
|
1956
|
+
const id = `custom:${rule.name}`;
|
|
1957
|
+
const reason = rule.reason ?? `matches custom rule "${rule.name}" (${rule.match})`;
|
|
1958
|
+
if (rule.action !== "rewrite")
|
|
1959
|
+
return { action: rule.action, rule: id, reason };
|
|
1960
|
+
if (!isSingleCommand(cmd))
|
|
1961
|
+
return {
|
|
1962
|
+
action: "ask",
|
|
1963
|
+
rule: id,
|
|
1964
|
+
reason: `${reason} \u2014 not rewritten: the command chains or redirects`
|
|
1965
|
+
};
|
|
1966
|
+
re.lastIndex = 0;
|
|
1967
|
+
const out = cmd.replace(re, rule.replace ?? "").trim();
|
|
1968
|
+
return out === cmd ? null : { action: "rewrite", rule: id, reason, command: out };
|
|
1969
|
+
}
|
|
1970
|
+
function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES, ctx = {}) {
|
|
1725
1971
|
const other = () => otherLiveInSameTree(current, sessions, now);
|
|
1726
1972
|
const hit = (rule, reason) => {
|
|
1727
1973
|
const mode = modes[rule];
|
|
1728
1974
|
return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
|
|
1729
1975
|
};
|
|
1976
|
+
const fix = (rule, reason, command, key) => {
|
|
1977
|
+
const mode = modes[rule] ?? "off";
|
|
1978
|
+
if (mode === "off")
|
|
1979
|
+
return { action: "allow" };
|
|
1980
|
+
if (mode === "rewrite")
|
|
1981
|
+
return { action: "rewrite", rule, reason, command, ...key ? { key } : {} };
|
|
1982
|
+
return { action: mode, rule, reason };
|
|
1983
|
+
};
|
|
1730
1984
|
if (modes.protected_ports !== "off" && modes.protected.ports.length) {
|
|
1731
1985
|
const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
|
|
1732
1986
|
if (target.length) {
|
|
@@ -1756,6 +2010,23 @@ function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
|
|
|
1756
2010
|
return d;
|
|
1757
2011
|
}
|
|
1758
2012
|
}
|
|
2013
|
+
for (const rule of modes.custom ?? []) {
|
|
2014
|
+
const d = applyCustomRule(rule, cmd);
|
|
2015
|
+
if (d)
|
|
2016
|
+
return d;
|
|
2017
|
+
}
|
|
2018
|
+
const nv = rewriteNoVerify(cmd);
|
|
2019
|
+
if (nv) {
|
|
2020
|
+
const d = fix("no_verify", "Hooks and signing exist for a reason: `--no-verify` / `--no-gpg-sign` was dropped. If a hook is wrong, fix the hook.", nv);
|
|
2021
|
+
if (d.action !== "allow")
|
|
2022
|
+
return d;
|
|
2023
|
+
}
|
|
2024
|
+
const dr = rewriteDryRun(cmd);
|
|
2025
|
+
if (dr && !ctx.rewritesDone?.has(dr.key)) {
|
|
2026
|
+
const d = fix("dry_run_first", `The first \`${dr.key}\` in a session runs as a dry run so you can read what it would change; run the real one next.`, dr.command, dr.key);
|
|
2027
|
+
if (d.action !== "allow")
|
|
2028
|
+
return d;
|
|
2029
|
+
}
|
|
1759
2030
|
return { action: "allow" };
|
|
1760
2031
|
}
|
|
1761
2032
|
function norm(p) {
|
|
@@ -1818,7 +2089,9 @@ var RULE_IDS = [
|
|
|
1818
2089
|
"destructive_git",
|
|
1819
2090
|
"protected_ports",
|
|
1820
2091
|
"no_foreign_worktree",
|
|
1821
|
-
"claim_required_to_write"
|
|
2092
|
+
"claim_required_to_write",
|
|
2093
|
+
"no_verify",
|
|
2094
|
+
"dry_run_first"
|
|
1822
2095
|
];
|
|
1823
2096
|
function normalizeDisplay(s) {
|
|
1824
2097
|
return s.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
@@ -1828,7 +2101,7 @@ function dryRunRules(calls, modes, ctx) {
|
|
|
1828
2101
|
const minRepeat = ctx.minRepeat ?? 3;
|
|
1829
2102
|
const maxHits = ctx.maxHits ?? 200;
|
|
1830
2103
|
const live = new Map;
|
|
1831
|
-
const byRule = Object.fromEntries(RULE_IDS.map((r) => [r, { ask: 0, deny: 0 }]));
|
|
2104
|
+
const byRule = Object.fromEntries(RULE_IDS.map((r) => [r, { ask: 0, deny: 0, rewrite: 0 }]));
|
|
1832
2105
|
const hits = [];
|
|
1833
2106
|
const groups = new Map;
|
|
1834
2107
|
let evaluated = 0;
|
|
@@ -1861,7 +2134,9 @@ function dryRunRules(calls, modes, ctx) {
|
|
|
1861
2134
|
continue;
|
|
1862
2135
|
if (d.action === "allow")
|
|
1863
2136
|
continue;
|
|
1864
|
-
byRule[d.rule]
|
|
2137
|
+
const tally = byRule[d.rule] ?? { ask: 0, deny: 0, rewrite: 0 };
|
|
2138
|
+
tally[d.action]++;
|
|
2139
|
+
byRule[d.rule] = tally;
|
|
1865
2140
|
const norm = normalizeDisplay(display);
|
|
1866
2141
|
if (hits.length < maxHits)
|
|
1867
2142
|
hits.push({
|
|
@@ -2596,6 +2871,18 @@ function portsIn(cmd) {
|
|
|
2596
2871
|
var RECURRING = 3;
|
|
2597
2872
|
function suggestFromIncident(inc) {
|
|
2598
2873
|
const n = inc.count ?? 1;
|
|
2874
|
+
if (inc.rule.startsWith("custom:")) {
|
|
2875
|
+
const name = inc.rule.slice("custom:".length);
|
|
2876
|
+
const escaped = inc.command.slice(0, 160).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/"/g, "\\\"");
|
|
2877
|
+
return {
|
|
2878
|
+
title: n >= RECURRING ? `Deny "${name}" outright (recurring)` : `Tighten custom rule "${name}"`,
|
|
2879
|
+
toml: `[[rules.custom]]
|
|
2880
|
+
name = "${name}"
|
|
2881
|
+
match = "${escaped}"
|
|
2882
|
+
action = "${n >= RECURRING ? "deny" : "ask"}"`,
|
|
2883
|
+
lesson: `Custom rule "${name}" fired on \`${inc.command.slice(0, 120)}\` \u2014 don't run that here; see .swarm.toml [[rules.custom]] for why.`
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2599
2886
|
switch (inc.rule) {
|
|
2600
2887
|
case "protected_ports": {
|
|
2601
2888
|
const ports = portsIn(inc.command);
|
|
@@ -2644,6 +2931,20 @@ no_foreign_worktree = "deny"`,
|
|
|
2644
2931
|
claim_required_to_write = "deny"`,
|
|
2645
2932
|
lesson: "Claim a task (`swarm claim`) and work in the worktree it creates before editing this repo."
|
|
2646
2933
|
};
|
|
2934
|
+
case "no_verify":
|
|
2935
|
+
return {
|
|
2936
|
+
title: "Always drop --no-verify",
|
|
2937
|
+
toml: `[rules]
|
|
2938
|
+
no_verify = "rewrite"`,
|
|
2939
|
+
lesson: "Never pass `--no-verify` / `--no-gpg-sign` to git \u2014 the hooks are the repo's checks. Fix the hook if it is wrong."
|
|
2940
|
+
};
|
|
2941
|
+
case "dry_run_first":
|
|
2942
|
+
return {
|
|
2943
|
+
title: "Dry-run infrastructure changes first",
|
|
2944
|
+
toml: `[rules]
|
|
2945
|
+
dry_run_first = "rewrite"`,
|
|
2946
|
+
lesson: "Run `terraform plan` / `kubectl \u2026 --dry-run=client` / `helm \u2026 --dry-run` and read it before the real apply, delete or uninstall."
|
|
2947
|
+
};
|
|
2647
2948
|
case "orphaned_claim":
|
|
2648
2949
|
return {
|
|
2649
2950
|
title: "A claim expired with unfinished work",
|
|
@@ -2669,6 +2970,143 @@ function incidentKey(inc) {
|
|
|
2669
2970
|
return `protected_ports:${portsIn(inc.command).join(",")}`;
|
|
2670
2971
|
return inc.rule;
|
|
2671
2972
|
}
|
|
2973
|
+
// packages/core/src/lessons-apply.ts
|
|
2974
|
+
var LESSONS_HEADING = "## Lessons from Swarm";
|
|
2975
|
+
function nextHeading(text) {
|
|
2976
|
+
let fence = null;
|
|
2977
|
+
let at = 0;
|
|
2978
|
+
for (const line of text.split(`
|
|
2979
|
+
`)) {
|
|
2980
|
+
const open = line.match(/^\s*(```+|~~~+)/);
|
|
2981
|
+
if (fence) {
|
|
2982
|
+
if (open && line.trim().startsWith(fence))
|
|
2983
|
+
fence = null;
|
|
2984
|
+
} else if (open) {
|
|
2985
|
+
fence = open[1];
|
|
2986
|
+
} else if (at > 0 && /^#{1,6} /.test(line)) {
|
|
2987
|
+
return at - 1;
|
|
2988
|
+
}
|
|
2989
|
+
at += line.length + 1;
|
|
2990
|
+
}
|
|
2991
|
+
return -1;
|
|
2992
|
+
}
|
|
2993
|
+
function mergeLesson(existing, lesson) {
|
|
2994
|
+
const line = `- ${lesson.trim()}`;
|
|
2995
|
+
const text = existing ?? "";
|
|
2996
|
+
if (text.split(`
|
|
2997
|
+
`).some((l) => l.trim() === line))
|
|
2998
|
+
return text;
|
|
2999
|
+
const idx = text.indexOf(LESSONS_HEADING);
|
|
3000
|
+
if (idx < 0) {
|
|
3001
|
+
const base = text.trimEnd();
|
|
3002
|
+
return `${base ? `${base}
|
|
3003
|
+
|
|
3004
|
+
` : `# CLAUDE.md
|
|
3005
|
+
|
|
3006
|
+
`}${LESSONS_HEADING}
|
|
3007
|
+
|
|
3008
|
+
${line}
|
|
3009
|
+
`;
|
|
3010
|
+
}
|
|
3011
|
+
const after = idx + LESSONS_HEADING.length;
|
|
3012
|
+
const rest = text.slice(after);
|
|
3013
|
+
const next = nextHeading(rest);
|
|
3014
|
+
const cut = next < 0 ? text.length : after + next;
|
|
3015
|
+
const section = text.slice(after, cut).trimEnd();
|
|
3016
|
+
return `${text.slice(0, after)}${section}
|
|
3017
|
+
${line}
|
|
3018
|
+
${next < 0 ? "" : text.slice(cut)}`;
|
|
3019
|
+
}
|
|
3020
|
+
var SECTION_HEADER = /^\s*\[\[?[A-Za-z0-9_.-]+\]\]?\s*(#.*)?$/;
|
|
3021
|
+
function parseSnippet(snippet) {
|
|
3022
|
+
const out = [];
|
|
3023
|
+
for (const raw of snippet.split(`
|
|
3024
|
+
`)) {
|
|
3025
|
+
const line = raw.trimEnd();
|
|
3026
|
+
const h = SECTION_HEADER.test(line) ? line.match(/^\[\[?([^\]]+)\]\]?$/) : null;
|
|
3027
|
+
if (h) {
|
|
3028
|
+
out.push({ header: line.trim(), array: line.startsWith("[["), lines: [] });
|
|
3029
|
+
continue;
|
|
3030
|
+
}
|
|
3031
|
+
if (!line.trim())
|
|
3032
|
+
continue;
|
|
3033
|
+
const cur = out.at(-1);
|
|
3034
|
+
if (cur)
|
|
3035
|
+
cur.lines.push(line);
|
|
3036
|
+
}
|
|
3037
|
+
return out;
|
|
3038
|
+
}
|
|
3039
|
+
function sectionBounds(lines, header) {
|
|
3040
|
+
const start = lines.findIndex((l) => l.trim() === header);
|
|
3041
|
+
if (start < 0)
|
|
3042
|
+
return null;
|
|
3043
|
+
let end = lines.length;
|
|
3044
|
+
for (let i = start + 1;i < lines.length; i++) {
|
|
3045
|
+
if (SECTION_HEADER.test(lines[i])) {
|
|
3046
|
+
end = i;
|
|
3047
|
+
break;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
return { start, end };
|
|
3051
|
+
}
|
|
3052
|
+
function setKey(lines, start, end, key, value) {
|
|
3053
|
+
for (let i = start + 1;i < end; i++) {
|
|
3054
|
+
if (new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`).test(lines[i])) {
|
|
3055
|
+
lines[i] = `${key} = ${value}`;
|
|
3056
|
+
return end;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
let at = end;
|
|
3060
|
+
while (at > start + 1 && !lines[at - 1].trim())
|
|
3061
|
+
at--;
|
|
3062
|
+
lines.splice(at, 0, `${key} = ${value}`);
|
|
3063
|
+
return end + 1;
|
|
3064
|
+
}
|
|
3065
|
+
function unionPorts(existing, incoming) {
|
|
3066
|
+
const nums = (s) => [...s.replace(/#.*$/, "").matchAll(/\d+/g)].map((m) => Number(m[0])).filter((n) => Number.isInteger(n));
|
|
3067
|
+
const set = new Set([...nums(existing), ...nums(incoming)]);
|
|
3068
|
+
return `[${[...set].sort((a, b) => a - b).join(", ")}]`;
|
|
3069
|
+
}
|
|
3070
|
+
function mergeToml(existing, snippet) {
|
|
3071
|
+
const lines = (existing ?? "").replace(/\r\n/g, `
|
|
3072
|
+
`).split(`
|
|
3073
|
+
`);
|
|
3074
|
+
if (lines.length === 1 && lines[0] === "")
|
|
3075
|
+
lines.length = 0;
|
|
3076
|
+
for (const block of parseSnippet(snippet)) {
|
|
3077
|
+
if (block.array) {
|
|
3078
|
+
if (lines.length && lines.at(-1).trim() !== "")
|
|
3079
|
+
lines.push("");
|
|
3080
|
+
lines.push(block.header, ...block.lines);
|
|
3081
|
+
continue;
|
|
3082
|
+
}
|
|
3083
|
+
let bounds = sectionBounds(lines, block.header);
|
|
3084
|
+
if (!bounds) {
|
|
3085
|
+
if (lines.length && lines.at(-1).trim() !== "")
|
|
3086
|
+
lines.push("");
|
|
3087
|
+
lines.push(block.header);
|
|
3088
|
+
bounds = { start: lines.length - 1, end: lines.length };
|
|
3089
|
+
}
|
|
3090
|
+
let end = bounds.end;
|
|
3091
|
+
for (const l of block.lines) {
|
|
3092
|
+
const m = l.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*(.+?)\s*$/);
|
|
3093
|
+
if (!m)
|
|
3094
|
+
continue;
|
|
3095
|
+
const [, key, value] = m;
|
|
3096
|
+
let v = value;
|
|
3097
|
+
if (block.header === "[rules.protected]" && key === "ports") {
|
|
3098
|
+
const cur = lines.slice(bounds.start + 1, end).find((x) => /^\s*ports\s*=/.test(x))?.split("=")[1];
|
|
3099
|
+
v = unionPorts(cur ?? "", value);
|
|
3100
|
+
}
|
|
3101
|
+
end = setKey(lines, bounds.start, end, key, v);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
const out = lines.join(`
|
|
3105
|
+
`);
|
|
3106
|
+
return out.endsWith(`
|
|
3107
|
+
`) ? out : `${out}
|
|
3108
|
+
`;
|
|
3109
|
+
}
|
|
2672
3110
|
// packages/core/src/lineage.ts
|
|
2673
3111
|
var LINEAGE_MAX_NODES = 40;
|
|
2674
3112
|
var LINEAGE_FANOUT = 4;
|
|
@@ -3125,6 +3563,21 @@ function outcomeReport(sessions, prs, revertedShas) {
|
|
|
3125
3563
|
function parseReverts(gitLog) {
|
|
3126
3564
|
return new Set([...gitLog.matchAll(/This reverts commit ([0-9a-f]{7,40})/gi)].map((m) => m[1].toLowerCase()));
|
|
3127
3565
|
}
|
|
3566
|
+
// packages/core/src/permissions.ts
|
|
3567
|
+
function permissionHookOutput(a, input) {
|
|
3568
|
+
if (!a.behavior)
|
|
3569
|
+
return {};
|
|
3570
|
+
return {
|
|
3571
|
+
hookSpecificOutput: {
|
|
3572
|
+
hookEventName: "PermissionRequest",
|
|
3573
|
+
decision: {
|
|
3574
|
+
behavior: a.behavior,
|
|
3575
|
+
message: a.message ?? `[swarm] ${a.behavior === "allow" ? "allowed" : "denied"} from the dashboard`,
|
|
3576
|
+
...a.behavior === "allow" ? { updatedInput: input } : {}
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3128
3581
|
// packages/core/src/policy.ts
|
|
3129
3582
|
import { createHash } from "crypto";
|
|
3130
3583
|
var HOOK_MARK = "swarm-hook";
|
|
@@ -3473,6 +3926,143 @@ function formatOpenQuestions(qs) {
|
|
|
3473
3926
|
return null;
|
|
3474
3927
|
return `[swarm] waiting on a human for: ${open.map((q) => `#${q.id} "${q.text.slice(0, 120)}"`).join("; ")} \u2014 the answer arrives as context on a later tool call, or via swarm_inbox`;
|
|
3475
3928
|
}
|
|
3929
|
+
// packages/core/src/quota.ts
|
|
3930
|
+
var QUOTA_WINDOWS = ["five_hour", "seven_day", "spend_limit"];
|
|
3931
|
+
var QUOTA_LABEL = {
|
|
3932
|
+
five_hour: "5-hour window",
|
|
3933
|
+
seven_day: "7-day window",
|
|
3934
|
+
spend_limit: "spend limit"
|
|
3935
|
+
};
|
|
3936
|
+
function quotaSamples(payload, at) {
|
|
3937
|
+
const out = [];
|
|
3938
|
+
const rl = payload.rate_limits;
|
|
3939
|
+
if (!rl || typeof rl !== "object")
|
|
3940
|
+
return out;
|
|
3941
|
+
for (const w of QUOTA_WINDOWS) {
|
|
3942
|
+
const v = rl[w];
|
|
3943
|
+
if (!v || typeof v.used_percentage !== "number" || !Number.isFinite(v.used_percentage))
|
|
3944
|
+
continue;
|
|
3945
|
+
out.push({
|
|
3946
|
+
window: w,
|
|
3947
|
+
usedPct: v.used_percentage,
|
|
3948
|
+
resetsAt: typeof v.resets_at === "number" && Number.isFinite(v.resets_at) ? v.resets_at : null,
|
|
3949
|
+
at
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
return out;
|
|
3953
|
+
}
|
|
3954
|
+
var BURN_MIN_SPAN_MS = 10 * 60000;
|
|
3955
|
+
function quotaLevel(usedPct, warnAt) {
|
|
3956
|
+
if (usedPct >= 100)
|
|
3957
|
+
return "exceeded";
|
|
3958
|
+
if (warnAt != null && warnAt > 0 && usedPct >= warnAt * 100)
|
|
3959
|
+
return "warn";
|
|
3960
|
+
return "ok";
|
|
3961
|
+
}
|
|
3962
|
+
function quotaReport(samples, now, warnAt) {
|
|
3963
|
+
const windows = [];
|
|
3964
|
+
let newest = null;
|
|
3965
|
+
for (const w of QUOTA_WINDOWS) {
|
|
3966
|
+
const all = samples.filter((s) => s.window === w).sort((a, b) => a.at - b.at);
|
|
3967
|
+
const last = all.at(-1);
|
|
3968
|
+
if (!last)
|
|
3969
|
+
continue;
|
|
3970
|
+
newest = newest === null ? last.at : Math.max(newest, last.at);
|
|
3971
|
+
const period = all.filter((s) => s.resetsAt === last.resetsAt);
|
|
3972
|
+
const first = period.find((s) => last.at - s.at >= BURN_MIN_SPAN_MS);
|
|
3973
|
+
let burn = null;
|
|
3974
|
+
if (first) {
|
|
3975
|
+
burn = (last.usedPct - first.usedPct) / ((last.at - first.at) / 3600000);
|
|
3976
|
+
if (!Number.isFinite(burn))
|
|
3977
|
+
burn = null;
|
|
3978
|
+
}
|
|
3979
|
+
const hoursToLimit = burn != null && burn > 0 && last.usedPct < 100 ? (100 - last.usedPct) / burn : null;
|
|
3980
|
+
const hoursToReset = last.resetsAt != null ? Math.max(0, (last.resetsAt * 1000 - now) / 3600000) : null;
|
|
3981
|
+
windows.push({
|
|
3982
|
+
window: w,
|
|
3983
|
+
usedPct: last.usedPct,
|
|
3984
|
+
resetsAt: last.resetsAt,
|
|
3985
|
+
sampledAt: last.at,
|
|
3986
|
+
burnPctPerHour: burn,
|
|
3987
|
+
hoursToLimit,
|
|
3988
|
+
hoursToReset,
|
|
3989
|
+
limitBeforeReset: hoursToLimit != null && (hoursToReset == null || hoursToLimit < hoursToReset),
|
|
3990
|
+
level: quotaLevel(last.usedPct, warnAt)
|
|
3991
|
+
});
|
|
3992
|
+
}
|
|
3993
|
+
return { windows, sampledAt: newest };
|
|
3994
|
+
}
|
|
3995
|
+
function tightestWindow(report) {
|
|
3996
|
+
let best = null;
|
|
3997
|
+
for (const w of report.windows) {
|
|
3998
|
+
if (w.level === "exceeded")
|
|
3999
|
+
return w;
|
|
4000
|
+
if (!w.limitBeforeReset || w.hoursToLimit == null)
|
|
4001
|
+
continue;
|
|
4002
|
+
if (!best || (best.hoursToLimit ?? Number.POSITIVE_INFINITY) > w.hoursToLimit)
|
|
4003
|
+
best = w;
|
|
4004
|
+
}
|
|
4005
|
+
return best;
|
|
4006
|
+
}
|
|
4007
|
+
function hoursText(h) {
|
|
4008
|
+
const minutes = Math.round(h * 60);
|
|
4009
|
+
if (minutes < 1)
|
|
4010
|
+
return "<1m";
|
|
4011
|
+
if (minutes < 60)
|
|
4012
|
+
return `${minutes}m`;
|
|
4013
|
+
if (minutes < 24 * 60) {
|
|
4014
|
+
const m = minutes % 60;
|
|
4015
|
+
return m ? `${Math.floor(minutes / 60)}h ${m}m` : `${Math.floor(minutes / 60)}h`;
|
|
4016
|
+
}
|
|
4017
|
+
const hours = Math.round(h);
|
|
4018
|
+
const d = Math.floor(hours / 24);
|
|
4019
|
+
const rest = hours % 24;
|
|
4020
|
+
return rest ? `${d}d ${rest}h` : `${d}d`;
|
|
4021
|
+
}
|
|
4022
|
+
function quotaMessage(w) {
|
|
4023
|
+
const label = QUOTA_LABEL[w.window];
|
|
4024
|
+
const used = `${Math.round(w.usedPct)}% of the ${label}`;
|
|
4025
|
+
if (w.level === "exceeded")
|
|
4026
|
+
return `${used} used \u2014 the plan is out of budget until it resets${w.hoursToReset != null ? ` in ${hoursText(w.hoursToReset)}` : ""}`;
|
|
4027
|
+
const pace = w.hoursToLimit != null ? `, at the current pace the limit lands in ${hoursText(w.hoursToLimit)}${w.hoursToReset != null ? ` and the window resets in ${hoursText(w.hoursToReset)}` : ""}` : w.hoursToReset != null ? `, resets in ${hoursText(w.hoursToReset)}` : "";
|
|
4028
|
+
return `${used} used${pace}`;
|
|
4029
|
+
}
|
|
4030
|
+
// packages/core/src/repair.ts
|
|
4031
|
+
var REASON_TAIL_CHARS = 1200;
|
|
4032
|
+
function repairDecision(input) {
|
|
4033
|
+
if (input.onStop !== "block")
|
|
4034
|
+
return { kind: "allow", why: "record" };
|
|
4035
|
+
if (input.maxBlocks <= 0)
|
|
4036
|
+
return { kind: "allow", why: "disabled" };
|
|
4037
|
+
if (!input.runs.length)
|
|
4038
|
+
return { kind: "allow", why: "nothing-to-run" };
|
|
4039
|
+
const failed = input.runs.filter((r) => r.verdict !== "pass");
|
|
4040
|
+
if (!failed.length)
|
|
4041
|
+
return { kind: "allow", why: "passed" };
|
|
4042
|
+
if (input.blocksSoFar >= input.maxBlocks)
|
|
4043
|
+
return {
|
|
4044
|
+
kind: "exhausted",
|
|
4045
|
+
failed,
|
|
4046
|
+
reason: `${failed.map((r) => r.gate).join(", ")} still failing after ${input.maxBlocks} refusal${input.maxBlocks === 1 ? "" : "s"} \u2014 letting the session stop`
|
|
4047
|
+
};
|
|
4048
|
+
const attempt = input.blocksSoFar + 1;
|
|
4049
|
+
return { kind: "block", attempt, failed, reason: blockReason(failed, attempt, input.maxBlocks) };
|
|
4050
|
+
}
|
|
4051
|
+
function blockReason(failed, attempt, maxBlocks) {
|
|
4052
|
+
const parts = failed.map((r) => {
|
|
4053
|
+
const tail = (r.evidence ?? "").trim();
|
|
4054
|
+
const clipped = tail.length > REASON_TAIL_CHARS ? `\u2026${tail.slice(tail.length - REASON_TAIL_CHARS)}` : tail;
|
|
4055
|
+
return `gate "${r.gate}" failed (${r.rubric})${clipped ? `:
|
|
4056
|
+
${clipped}` : ""}`;
|
|
4057
|
+
});
|
|
4058
|
+
const left = maxBlocks - attempt;
|
|
4059
|
+
return [
|
|
4060
|
+
`[swarm] not done yet \u2014 ${failed.length === 1 ? "a required gate is" : `${failed.length} required gates are`} failing in this worktree. Fix the cause, run it again, then finish.`,
|
|
4061
|
+
...parts,
|
|
4062
|
+
left > 0 ? `(refusal ${attempt} of ${maxBlocks}; after ${left} more the stop goes through and an incident opens)` : `(refusal ${attempt} of ${maxBlocks}; the next stop goes through and an incident opens)`
|
|
4063
|
+
].join(`
|
|
4064
|
+
`);
|
|
4065
|
+
}
|
|
3476
4066
|
// packages/core/src/resourcegraph.ts
|
|
3477
4067
|
var nodeId = (kind, name, projectId) => `${kind}:${projectId ?? ""}:${name}`;
|
|
3478
4068
|
function resourceGraph(held, wanted = [], now = Date.now()) {
|
|
@@ -4134,6 +4724,145 @@ function clusterProjectKey(remoteUrl) {
|
|
|
4134
4724
|
return null;
|
|
4135
4725
|
return `${host}/${m[2]}`;
|
|
4136
4726
|
}
|
|
4727
|
+
// packages/core/src/teamsetup.ts
|
|
4728
|
+
var DEFAULT_TEAM_SETUP = {
|
|
4729
|
+
name: null,
|
|
4730
|
+
host: "0.0.0.0",
|
|
4731
|
+
port: 7878,
|
|
4732
|
+
mode: "token",
|
|
4733
|
+
token: null,
|
|
4734
|
+
issuer: null,
|
|
4735
|
+
clientId: null,
|
|
4736
|
+
db: null
|
|
4737
|
+
};
|
|
4738
|
+
function mintTeamSecret() {
|
|
4739
|
+
return `swt_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
4740
|
+
}
|
|
4741
|
+
var str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
|
|
4742
|
+
function parseTeamSetup(text) {
|
|
4743
|
+
const out = { ...DEFAULT_TEAM_SETUP };
|
|
4744
|
+
if (!text)
|
|
4745
|
+
return out;
|
|
4746
|
+
for (const raw of text.split(`
|
|
4747
|
+
`)) {
|
|
4748
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
4749
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
4750
|
+
if (!m)
|
|
4751
|
+
continue;
|
|
4752
|
+
const key = m[1];
|
|
4753
|
+
const value = m[2].trim().replace(/^["']|["']$/g, "");
|
|
4754
|
+
switch (key) {
|
|
4755
|
+
case "name":
|
|
4756
|
+
out.name = str(value);
|
|
4757
|
+
break;
|
|
4758
|
+
case "host":
|
|
4759
|
+
out.host = str(value) ?? out.host;
|
|
4760
|
+
break;
|
|
4761
|
+
case "port": {
|
|
4762
|
+
const n = Number(value);
|
|
4763
|
+
if (Number.isInteger(n) && n > 0 && n < 65536)
|
|
4764
|
+
out.port = n;
|
|
4765
|
+
break;
|
|
4766
|
+
}
|
|
4767
|
+
case "mode":
|
|
4768
|
+
if (value === "oidc" || value === "token" || value === "open")
|
|
4769
|
+
out.mode = value;
|
|
4770
|
+
break;
|
|
4771
|
+
case "token":
|
|
4772
|
+
out.token = str(value);
|
|
4773
|
+
break;
|
|
4774
|
+
case "issuer":
|
|
4775
|
+
out.issuer = str(value);
|
|
4776
|
+
break;
|
|
4777
|
+
case "client_id":
|
|
4778
|
+
out.clientId = str(value);
|
|
4779
|
+
break;
|
|
4780
|
+
case "db":
|
|
4781
|
+
out.db = str(value);
|
|
4782
|
+
break;
|
|
4783
|
+
default:
|
|
4784
|
+
break;
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
return out;
|
|
4788
|
+
}
|
|
4789
|
+
function renderTeamSetup(s) {
|
|
4790
|
+
const lines = [
|
|
4791
|
+
"# swarm-teamd \u2014 written by `swarm-teamd setup` or the dashboard's Team panel.",
|
|
4792
|
+
"# Environment variables still win: SWARM_TEAM_PORT / _HOST / _TOKEN / _OIDC_ISSUER /",
|
|
4793
|
+
"# _OIDC_CLIENT_ID / _DB override anything here.",
|
|
4794
|
+
"",
|
|
4795
|
+
`name = ${JSON.stringify(s.name ?? "")}`,
|
|
4796
|
+
`host = ${JSON.stringify(s.host)}`,
|
|
4797
|
+
`port = ${s.port}`,
|
|
4798
|
+
`mode = ${JSON.stringify(s.mode)}`
|
|
4799
|
+
];
|
|
4800
|
+
if (s.mode === "token")
|
|
4801
|
+
lines.push(`token = ${JSON.stringify(s.token ?? "")}`);
|
|
4802
|
+
if (s.mode === "oidc") {
|
|
4803
|
+
lines.push(`issuer = ${JSON.stringify(s.issuer ?? "")}`);
|
|
4804
|
+
lines.push(`client_id = ${JSON.stringify(s.clientId ?? "")}`);
|
|
4805
|
+
}
|
|
4806
|
+
if (s.db)
|
|
4807
|
+
lines.push(`db = ${JSON.stringify(s.db)}`);
|
|
4808
|
+
return `${lines.join(`
|
|
4809
|
+
`)}
|
|
4810
|
+
`;
|
|
4811
|
+
}
|
|
4812
|
+
function inviteLink(url, token) {
|
|
4813
|
+
const q = new URLSearchParams({ url: url.replace(/\/+$/, "") });
|
|
4814
|
+
if (token)
|
|
4815
|
+
q.set("token", token);
|
|
4816
|
+
return `swarm+team://join?${q}`;
|
|
4817
|
+
}
|
|
4818
|
+
function parseInvite(text) {
|
|
4819
|
+
const raw = text.trim();
|
|
4820
|
+
if (!raw)
|
|
4821
|
+
return null;
|
|
4822
|
+
const invite = raw.match(/^swarm\+team:\/\/join\?(.*)$/i);
|
|
4823
|
+
if (invite) {
|
|
4824
|
+
const q = new URLSearchParams(invite[1]);
|
|
4825
|
+
const url = q.get("url");
|
|
4826
|
+
return url && /^https?:\/\//i.test(url) ? { url: url.replace(/\/+$/, ""), token: q.get("token") || null } : null;
|
|
4827
|
+
}
|
|
4828
|
+
const hash = raw.match(/^(https?:\/\/[^\s#]+)#(.+)$/i);
|
|
4829
|
+
if (hash)
|
|
4830
|
+
return {
|
|
4831
|
+
url: hash[1].replace(/\/+$/, ""),
|
|
4832
|
+
token: hash[2].trim() || null
|
|
4833
|
+
};
|
|
4834
|
+
if (/^https?:\/\/\S+$/i.test(raw))
|
|
4835
|
+
return { url: raw.replace(/\/+$/, ""), token: null };
|
|
4836
|
+
if (/^[A-Za-z0-9][A-Za-z0-9.-]*(:\d{2,5})?$/.test(raw))
|
|
4837
|
+
return {
|
|
4838
|
+
url: `http://${raw.includes(":") ? raw : `${raw}:${DEFAULT_TEAM_SETUP.port}`}`,
|
|
4839
|
+
token: null
|
|
4840
|
+
};
|
|
4841
|
+
return null;
|
|
4842
|
+
}
|
|
4843
|
+
function hostedUrl(address, port) {
|
|
4844
|
+
return `http://${address}:${port}`;
|
|
4845
|
+
}
|
|
4846
|
+
function withTeamUrl(text, url) {
|
|
4847
|
+
const section = text.match(/(^|\n)\[team\]([\s\S]*?)(?=\n\[|$)/);
|
|
4848
|
+
if (!section) {
|
|
4849
|
+
if (!url)
|
|
4850
|
+
return text;
|
|
4851
|
+
return `${text}${text && !text.endsWith(`
|
|
4852
|
+
`) ? `
|
|
4853
|
+
` : ""}
|
|
4854
|
+
[team]
|
|
4855
|
+
url = ${JSON.stringify(url)}
|
|
4856
|
+
`;
|
|
4857
|
+
}
|
|
4858
|
+
const body = section[2] ?? "";
|
|
4859
|
+
const line = url ? `url = ${JSON.stringify(url)}` : "";
|
|
4860
|
+
const next = /^[ \t]*url[ \t]*=/m.test(body) ? body.replace(/^[ \t]*url[ \t]*=.*$/m, line).replace(/\n{3,}/g, `
|
|
4861
|
+
|
|
4862
|
+
`) : url ? `
|
|
4863
|
+
${line}${body}` : body;
|
|
4864
|
+
return text.replace(section[0], `${section[1]}[team]${next}`);
|
|
4865
|
+
}
|
|
4137
4866
|
// packages/core/src/transitions.ts
|
|
4138
4867
|
function transitionGraph(steps, { minWeight = 1 } = {}) {
|
|
4139
4868
|
const calls = new Map;
|
|
@@ -4312,9 +5041,9 @@ function waitingReport(episodes) {
|
|
|
4312
5041
|
};
|
|
4313
5042
|
}
|
|
4314
5043
|
// packages/daemon/src/app.ts
|
|
4315
|
-
import { existsSync as
|
|
5044
|
+
import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync2 } from "fs";
|
|
4316
5045
|
import { homedir as homedir4 } from "os";
|
|
4317
|
-
import { dirname as dirname4, join as
|
|
5046
|
+
import { dirname as dirname4, join as join12 } from "path";
|
|
4318
5047
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4319
5048
|
|
|
4320
5049
|
// node_modules/.bun/hono@4.13.3/node_modules/hono/dist/compose.js
|
|
@@ -5934,738 +6663,853 @@ var streamSSE = (c, cb, onError) => {
|
|
|
5934
6663
|
return c.newResponse(stream.responseReadable);
|
|
5935
6664
|
};
|
|
5936
6665
|
|
|
5937
|
-
// packages/daemon/src/
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
6666
|
+
// packages/daemon/src/codify.ts
|
|
6667
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
6668
|
+
import { join as join5 } from "path";
|
|
6669
|
+
|
|
6670
|
+
// packages/daemon/src/git.ts
|
|
6671
|
+
import { realpathSync } from "fs";
|
|
6672
|
+
import { join as join4 } from "path";
|
|
6673
|
+
function git(cwd, args) {
|
|
6674
|
+
try {
|
|
6675
|
+
const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
6676
|
+
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
6677
|
+
} catch {
|
|
6678
|
+
return null;
|
|
5949
6679
|
}
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
return
|
|
6680
|
+
}
|
|
6681
|
+
function gitCommonDir(cwd) {
|
|
6682
|
+
const out = git(cwd, ["rev-parse", "--git-common-dir"])?.trim();
|
|
6683
|
+
if (!out)
|
|
6684
|
+
return null;
|
|
6685
|
+
try {
|
|
6686
|
+
return realpathSync(out.startsWith("/") ? out : join4(cwd, out));
|
|
6687
|
+
} catch {
|
|
6688
|
+
return null;
|
|
5957
6689
|
}
|
|
5958
|
-
|
|
5959
|
-
|
|
6690
|
+
}
|
|
6691
|
+
function gitToplevel(cwd) {
|
|
6692
|
+
const out = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
6693
|
+
if (!out)
|
|
6694
|
+
return null;
|
|
6695
|
+
try {
|
|
6696
|
+
return realpathSync(out);
|
|
6697
|
+
} catch {
|
|
6698
|
+
return null;
|
|
5960
6699
|
}
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
this.opts.set(projectId, opts);
|
|
5977
|
-
const m = this.project(projectId);
|
|
5978
|
-
const running = [...m.values()].filter((e) => e.state === "running").length;
|
|
5979
|
-
const plan = planDispatch(board.tasks, wanted, {
|
|
5980
|
-
maxParallel: opts.maxParallel,
|
|
5981
|
-
running,
|
|
5982
|
-
max: o.max,
|
|
5983
|
-
alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
|
|
5984
|
-
});
|
|
5985
|
-
const now = new Date().toISOString();
|
|
5986
|
-
for (const t of [...plan.start, ...plan.queued]) {
|
|
5987
|
-
m.set(t.id, {
|
|
5988
|
-
task: t.id,
|
|
5989
|
-
title: t.title,
|
|
5990
|
-
state: "queued",
|
|
5991
|
-
runId: null,
|
|
5992
|
-
sessionId: null,
|
|
5993
|
-
queuedAt: now,
|
|
5994
|
-
startedAt: null,
|
|
5995
|
-
endedAt: null,
|
|
5996
|
-
outcome: null,
|
|
5997
|
-
detail: null,
|
|
5998
|
-
costUsd: null
|
|
5999
|
-
});
|
|
6000
|
-
}
|
|
6001
|
-
if (plan.start.length || plan.queued.length)
|
|
6002
|
-
this.store.append({
|
|
6003
|
-
ts: now,
|
|
6004
|
-
type: "dispatch.queued",
|
|
6005
|
-
projectId,
|
|
6006
|
-
sessionId: null,
|
|
6007
|
-
payload: {
|
|
6008
|
-
tasks: [...plan.start, ...plan.queued].map((t) => t.id),
|
|
6009
|
-
maxParallel: opts.maxParallel,
|
|
6010
|
-
summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
|
|
6011
|
-
}
|
|
6012
|
-
});
|
|
6013
|
-
const started = [];
|
|
6014
|
-
const failed = [];
|
|
6015
|
-
for (const t of plan.start) {
|
|
6016
|
-
const r = await this.startOne(projectId, t);
|
|
6017
|
-
if (r.ok)
|
|
6018
|
-
started.push(t.id);
|
|
6019
|
-
else
|
|
6020
|
-
failed.push({ id: t.id, reason: r.reason });
|
|
6021
|
-
}
|
|
6022
|
-
await this.fill(projectId);
|
|
6023
|
-
return {
|
|
6024
|
-
ok: true,
|
|
6025
|
-
started,
|
|
6026
|
-
queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
|
|
6027
|
-
rejected: [...plan.rejected, ...failed]
|
|
6028
|
-
};
|
|
6029
|
-
}
|
|
6030
|
-
async startOne(projectId, t) {
|
|
6031
|
-
const m = this.project(projectId);
|
|
6032
|
-
const e = m.get(t.id);
|
|
6033
|
-
const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
|
|
6034
|
-
const cfg = this.store.config(projectId);
|
|
6035
|
-
const gates = cfg.gates;
|
|
6036
|
-
const prompt = taskPrompt(t, {
|
|
6037
|
-
requiredGates: gates.required,
|
|
6038
|
-
executableGates: gates.required.filter((g) => gates.defs[g]),
|
|
6039
|
-
openPr: cfg.dispatch.require_pr
|
|
6040
|
-
});
|
|
6041
|
-
const r = await this.runner.start({
|
|
6042
|
-
projectId,
|
|
6043
|
-
task: t.id,
|
|
6044
|
-
prompt,
|
|
6045
|
-
owner: opts.owner,
|
|
6046
|
-
permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
|
|
6047
|
-
model: opts.model ?? cfg.dispatch.model ?? undefined,
|
|
6048
|
-
maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
|
|
6049
|
-
profile: opts.profile ?? cfg.dispatch.profile ?? undefined
|
|
6050
|
-
});
|
|
6051
|
-
if (!r.ok) {
|
|
6052
|
-
if (e) {
|
|
6053
|
-
e.state = "finished";
|
|
6054
|
-
e.endedAt = new Date().toISOString();
|
|
6055
|
-
e.outcome = "crashed";
|
|
6056
|
-
e.detail = r.reason;
|
|
6057
|
-
}
|
|
6058
|
-
this.store.append({
|
|
6059
|
-
ts: new Date().toISOString(),
|
|
6060
|
-
type: "dispatch.finished",
|
|
6061
|
-
projectId,
|
|
6062
|
-
sessionId: null,
|
|
6063
|
-
payload: {
|
|
6064
|
-
task: t.id,
|
|
6065
|
-
outcome: "crashed",
|
|
6066
|
-
detail: r.reason,
|
|
6067
|
-
summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
|
|
6068
|
-
}
|
|
6700
|
+
}
|
|
6701
|
+
function parseWorktreeList(out) {
|
|
6702
|
+
const wts = [];
|
|
6703
|
+
let cur = null;
|
|
6704
|
+
const flush = () => {
|
|
6705
|
+
if (cur?.path) {
|
|
6706
|
+
wts.push({
|
|
6707
|
+
path: cur.path,
|
|
6708
|
+
branch: cur.branch ?? null,
|
|
6709
|
+
head: (cur.head ?? "").slice(0, 7),
|
|
6710
|
+
main: wts.length === 0,
|
|
6711
|
+
dirty: -1,
|
|
6712
|
+
ahead: -1,
|
|
6713
|
+
behind: -1,
|
|
6714
|
+
merged: false
|
|
6069
6715
|
});
|
|
6070
|
-
return { ok: false, reason: r.reason };
|
|
6071
|
-
}
|
|
6072
|
-
if (e) {
|
|
6073
|
-
e.state = "running";
|
|
6074
|
-
e.runId = r.run.id;
|
|
6075
|
-
e.sessionId = r.run.sessionId;
|
|
6076
|
-
e.startedAt = r.run.startedAt;
|
|
6077
|
-
}
|
|
6078
|
-
this.store.append({
|
|
6079
|
-
ts: r.run.startedAt,
|
|
6080
|
-
type: "dispatch.started",
|
|
6081
|
-
projectId,
|
|
6082
|
-
sessionId: r.run.sessionId,
|
|
6083
|
-
payload: {
|
|
6084
|
-
task: t.id,
|
|
6085
|
-
runId: r.run.id,
|
|
6086
|
-
worktree: r.run.worktree,
|
|
6087
|
-
by: this.opts.get(projectId)?.owner ?? null,
|
|
6088
|
-
summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
|
|
6089
|
-
}
|
|
6090
|
-
});
|
|
6091
|
-
return { ok: true };
|
|
6092
|
-
}
|
|
6093
|
-
async fill(projectId) {
|
|
6094
|
-
const m = this.project(projectId);
|
|
6095
|
-
const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
|
|
6096
|
-
for (const e of m.values()) {
|
|
6097
|
-
const running = [...m.values()].filter((x) => x.state === "running").length;
|
|
6098
|
-
if (running >= cap)
|
|
6099
|
-
return;
|
|
6100
|
-
if (e.state !== "queued")
|
|
6101
|
-
continue;
|
|
6102
|
-
await this.startOne(projectId, { id: e.task, title: e.title });
|
|
6103
6716
|
}
|
|
6717
|
+
cur = null;
|
|
6718
|
+
};
|
|
6719
|
+
for (const line of out.split(`
|
|
6720
|
+
`)) {
|
|
6721
|
+
if (line.startsWith("worktree ")) {
|
|
6722
|
+
flush();
|
|
6723
|
+
cur = { path: line.slice(9) };
|
|
6724
|
+
} else if (line.startsWith("HEAD ") && cur)
|
|
6725
|
+
cur.head = line.slice(5);
|
|
6726
|
+
else if (line.startsWith("branch ") && cur)
|
|
6727
|
+
cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
6728
|
+
else if (line === "")
|
|
6729
|
+
flush();
|
|
6104
6730
|
}
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
required.length ? `gates: ${verdicts}` : null,
|
|
6138
|
-
pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
|
|
6139
|
-
].filter(Boolean).join(" \xB7 ");
|
|
6140
|
-
e.state = "finished";
|
|
6141
|
-
e.endedAt = run.endedAt;
|
|
6142
|
-
e.outcome = outcome;
|
|
6143
|
-
e.detail = detail;
|
|
6144
|
-
e.costUsd = run.result?.costUsd ?? null;
|
|
6145
|
-
const ts = run.endedAt ?? new Date().toISOString();
|
|
6146
|
-
this.store.append({
|
|
6147
|
-
ts,
|
|
6148
|
-
type: "dispatch.finished",
|
|
6149
|
-
projectId: run.projectId,
|
|
6150
|
-
sessionId: run.sessionId,
|
|
6151
|
-
payload: {
|
|
6152
|
-
task: run.task,
|
|
6153
|
-
runId: run.id,
|
|
6154
|
-
outcome,
|
|
6155
|
-
detail,
|
|
6156
|
-
costUsd: e.costUsd,
|
|
6157
|
-
summary: `dispatch ${run.task}: ${outcome} \u2014 ${detail}`
|
|
6158
|
-
}
|
|
6159
|
-
});
|
|
6160
|
-
if (outcome !== "done" && outcome !== "stopped")
|
|
6161
|
-
this.store.append({
|
|
6162
|
-
ts,
|
|
6163
|
-
type: "incident.opened",
|
|
6164
|
-
projectId: run.projectId,
|
|
6165
|
-
sessionId: run.sessionId,
|
|
6166
|
-
payload: {
|
|
6167
|
-
rule: "dispatch_failed",
|
|
6168
|
-
action: outcome,
|
|
6169
|
-
command: run.task,
|
|
6170
|
-
reason: `dispatched run on ${run.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
|
|
6171
|
-
}
|
|
6172
|
-
});
|
|
6173
|
-
this.store.touch();
|
|
6174
|
-
await this.fill(run.projectId);
|
|
6731
|
+
flush();
|
|
6732
|
+
return wts;
|
|
6733
|
+
}
|
|
6734
|
+
function applyStatus(w, st, ah) {
|
|
6735
|
+
w.dirty = st === null ? -1 : st.split(`
|
|
6736
|
+
`).filter(Boolean).length;
|
|
6737
|
+
const a = ah?.trim();
|
|
6738
|
+
w.ahead = a === undefined || a === "" ? -1 : Number(a);
|
|
6739
|
+
}
|
|
6740
|
+
function applyDrift(w, behind, ancestor, firstParents, cherry = null) {
|
|
6741
|
+
const b = behind?.trim();
|
|
6742
|
+
w.behind = b === undefined || b === "" ? -1 : Number(b);
|
|
6743
|
+
const onLine = firstParents?.split(`
|
|
6744
|
+
`).some((sha) => sha.startsWith(w.head)) ?? true;
|
|
6745
|
+
w.merged = (ancestor || squashed(cherry)) && !onLine;
|
|
6746
|
+
}
|
|
6747
|
+
function squashed(cherry) {
|
|
6748
|
+
if (!cherry)
|
|
6749
|
+
return false;
|
|
6750
|
+
const lines = cherry.split(`
|
|
6751
|
+
`).filter((l) => l.trim());
|
|
6752
|
+
return lines.length > 0 && lines.every((l) => l.startsWith("-"));
|
|
6753
|
+
}
|
|
6754
|
+
var FIRST_PARENT_DEPTH = "5000";
|
|
6755
|
+
var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
|
|
6756
|
+
async function gitAsync(cwd, args) {
|
|
6757
|
+
try {
|
|
6758
|
+
const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
6759
|
+
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
6760
|
+
return code === 0 ? out : null;
|
|
6761
|
+
} catch {
|
|
6762
|
+
return null;
|
|
6175
6763
|
}
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6764
|
+
}
|
|
6765
|
+
async function listWorktreesAsync(root) {
|
|
6766
|
+
const out = await gitAsync(root, ["worktree", "list", "--porcelain"]);
|
|
6767
|
+
if (!out)
|
|
6768
|
+
return [];
|
|
6769
|
+
const wts = parseWorktreeList(out);
|
|
6770
|
+
const base = baseOf(wts);
|
|
6771
|
+
const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
|
|
6772
|
+
await Promise.all(wts.map(async (w) => {
|
|
6773
|
+
const drift = base && !w.main;
|
|
6774
|
+
const [st, ah, be, mg, ch] = await Promise.all([
|
|
6775
|
+
gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
|
|
6776
|
+
gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
|
|
6777
|
+
drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
|
|
6778
|
+
drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null,
|
|
6779
|
+
drift ? gitAsync(w.path, ["cherry", base, "HEAD"]) : null
|
|
6780
|
+
]);
|
|
6781
|
+
applyStatus(w, st, ah);
|
|
6782
|
+
if (drift)
|
|
6783
|
+
applyDrift(w, be, mg !== null, line, ch);
|
|
6784
|
+
}));
|
|
6785
|
+
return wts;
|
|
6786
|
+
}
|
|
6787
|
+
var branchCache = new Map;
|
|
6788
|
+
function currentBranch(cwd) {
|
|
6789
|
+
const hit = branchCache.get(cwd);
|
|
6790
|
+
const now = Date.now();
|
|
6791
|
+
if (hit && now - hit.t < 5000)
|
|
6792
|
+
return hit.v;
|
|
6793
|
+
const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
|
|
6794
|
+
branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
|
|
6795
|
+
return branchCache.get(cwd)?.v ?? null;
|
|
6796
|
+
}
|
|
6797
|
+
var originCache = new Map;
|
|
6798
|
+
function originUrl(root) {
|
|
6799
|
+
const hit = originCache.get(root);
|
|
6800
|
+
const now = Date.now();
|
|
6801
|
+
if (hit && now - hit.t < 300000)
|
|
6802
|
+
return hit.v;
|
|
6803
|
+
const v = git(root, ["config", "--get", "remote.origin.url"])?.trim() || null;
|
|
6804
|
+
originCache.set(root, { v, t: now });
|
|
6805
|
+
return v;
|
|
6806
|
+
}
|
|
6807
|
+
function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
|
|
6808
|
+
const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
6809
|
+
const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
|
|
6810
|
+
if (git(repoRoot, args) === null)
|
|
6811
|
+
return null;
|
|
6812
|
+
try {
|
|
6813
|
+
return realpathSync(path);
|
|
6814
|
+
} catch {
|
|
6815
|
+
return path;
|
|
6188
6816
|
}
|
|
6189
6817
|
}
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
import { existsSync as existsSync4 } from "fs";
|
|
6193
|
-
import { homedir as homedir2 } from "os";
|
|
6194
|
-
import { join as join4 } from "path";
|
|
6195
|
-
var EXTRA_BIN_DIRS = [
|
|
6196
|
-
"/opt/homebrew/bin",
|
|
6197
|
-
"/usr/local/bin",
|
|
6198
|
-
"/home/linuxbrew/.linuxbrew/bin",
|
|
6199
|
-
join4(homedir2(), ".local", "bin"),
|
|
6200
|
-
join4(homedir2(), "bin")
|
|
6201
|
-
];
|
|
6202
|
-
function findBin(name) {
|
|
6203
|
-
if (!name)
|
|
6818
|
+
function commitPaths(cwd, paths, message) {
|
|
6819
|
+
if (!paths.length || git(cwd, ["add", "--", ...paths]) === null)
|
|
6204
6820
|
return null;
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6821
|
+
if (git(cwd, ["diff", "--cached", "--quiet"]) !== null)
|
|
6822
|
+
return null;
|
|
6823
|
+
if (git(cwd, [
|
|
6824
|
+
"-c",
|
|
6825
|
+
"user.email=swarm@localhost",
|
|
6826
|
+
"-c",
|
|
6827
|
+
"user.name=Swarm",
|
|
6828
|
+
"commit",
|
|
6829
|
+
"-q",
|
|
6830
|
+
"-m",
|
|
6831
|
+
message
|
|
6832
|
+
]) === null)
|
|
6833
|
+
return null;
|
|
6834
|
+
return git(cwd, ["rev-parse", "HEAD"])?.trim() ?? null;
|
|
6835
|
+
}
|
|
6836
|
+
function worktreeRemove(repoRoot, path, force) {
|
|
6837
|
+
const args = ["worktree", "remove", path];
|
|
6838
|
+
if (force)
|
|
6839
|
+
args.push("--force");
|
|
6840
|
+
return git(repoRoot, args) !== null;
|
|
6841
|
+
}
|
|
6842
|
+
function heldWork(path) {
|
|
6843
|
+
const status = git(path, ["status", "--porcelain"]);
|
|
6844
|
+
const dirty = status !== null && status.trim().length > 0;
|
|
6845
|
+
const count = (args) => {
|
|
6846
|
+
const out = git(path, ["rev-list", "--count", ...args])?.trim();
|
|
6847
|
+
return out !== undefined && out !== "" ? Number(out) : 0;
|
|
6848
|
+
};
|
|
6849
|
+
let unpushed;
|
|
6850
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", "@{upstream}"]) !== null) {
|
|
6851
|
+
unpushed = count(["@{upstream}..HEAD"]) > 0;
|
|
6852
|
+
} else {
|
|
6853
|
+
const baselines = ["--remotes"];
|
|
6854
|
+
for (const b of ["main", "master"]) {
|
|
6855
|
+
if (git(path, ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`]) !== null)
|
|
6856
|
+
baselines.push(b);
|
|
6857
|
+
}
|
|
6858
|
+
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
6212
6859
|
}
|
|
6213
|
-
return
|
|
6860
|
+
return { dirty, unpushed };
|
|
6214
6861
|
}
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6862
|
+
async function worktreeDiff(root, path) {
|
|
6863
|
+
const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
|
|
6864
|
+
const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
|
|
6865
|
+
const isMain = wts[0]?.path === path;
|
|
6866
|
+
const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
|
|
6867
|
+
const from = mb || "HEAD";
|
|
6868
|
+
const [numstat, names, log, status] = await Promise.all([
|
|
6869
|
+
gitAsync(path, ["diff", "--numstat", from]),
|
|
6870
|
+
gitAsync(path, ["diff", "--name-status", from]),
|
|
6871
|
+
mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
|
|
6872
|
+
gitAsync(path, ["status", "--porcelain"])
|
|
6873
|
+
]);
|
|
6874
|
+
const files = parseNumstat(numstat ?? "", names ?? "");
|
|
6875
|
+
for (const line of (status ?? "").split(`
|
|
6876
|
+
`)) {
|
|
6877
|
+
if (line.startsWith("?? "))
|
|
6878
|
+
files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
|
|
6223
6879
|
}
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6880
|
+
return {
|
|
6881
|
+
base: mb ?? null,
|
|
6882
|
+
baseRef,
|
|
6883
|
+
files,
|
|
6884
|
+
commits: (log ?? "").split(`
|
|
6885
|
+
`).filter(Boolean),
|
|
6886
|
+
dirty: (status ?? "").trim().length > 0
|
|
6887
|
+
};
|
|
6888
|
+
}
|
|
6889
|
+
async function worktreePatch(path, base, file) {
|
|
6890
|
+
const from = base ?? "HEAD";
|
|
6891
|
+
if (file) {
|
|
6892
|
+
const tracked = await gitAsync(path, ["ls-files", "--error-unmatch", "--", file]) !== null;
|
|
6893
|
+
if (!tracked) {
|
|
6894
|
+
const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
|
|
6895
|
+
stdout: "pipe",
|
|
6896
|
+
stderr: "ignore"
|
|
6897
|
+
});
|
|
6898
|
+
const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
6899
|
+
return out;
|
|
6900
|
+
}
|
|
6901
|
+
return await gitAsync(path, ["diff", from, "--", file]) ?? "";
|
|
6228
6902
|
}
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
return;
|
|
6237
|
-
this.inflight.add(p.id);
|
|
6238
|
-
try {
|
|
6239
|
-
const prs = await this.poll(p.id, p.root);
|
|
6240
|
-
this.cache.set(p.id, { at: Date.now(), prs });
|
|
6241
|
-
} catch {
|
|
6242
|
-
this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
|
|
6243
|
-
} finally {
|
|
6244
|
-
this.inflight.delete(p.id);
|
|
6245
|
-
}
|
|
6246
|
-
}));
|
|
6903
|
+
return await gitAsync(path, ["diff", from]) ?? "";
|
|
6904
|
+
}
|
|
6905
|
+
function realpathOr(p) {
|
|
6906
|
+
try {
|
|
6907
|
+
return realpathSync(p);
|
|
6908
|
+
} catch {
|
|
6909
|
+
return p;
|
|
6247
6910
|
}
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6911
|
+
}
|
|
6912
|
+
|
|
6913
|
+
// packages/daemon/src/codify.ts
|
|
6914
|
+
async function applyCodify(store, forge, projectId, seq, target) {
|
|
6915
|
+
const project = store.project(projectId);
|
|
6916
|
+
if (!project)
|
|
6917
|
+
return { ok: false, error: "unknown project" };
|
|
6918
|
+
const incident = store.incident(seq);
|
|
6919
|
+
if (!incident || incident.projectId !== projectId)
|
|
6920
|
+
return { ok: false, error: `no incident #${seq} in ${project.name}` };
|
|
6921
|
+
const s = incident.suggestion;
|
|
6922
|
+
if (!s)
|
|
6923
|
+
return { ok: false, error: "this incident carries no suggestion to apply" };
|
|
6924
|
+
const wantToml = target !== "claude-md" && Boolean(s.toml);
|
|
6925
|
+
const wantLesson = target !== "swarm-toml";
|
|
6926
|
+
if (!wantToml && !wantLesson)
|
|
6927
|
+
return { ok: false, error: "nothing to write for this target (the rule has no config form)" };
|
|
6928
|
+
const name = `codify-${seq}`;
|
|
6929
|
+
const branch = `swarm/${name}`;
|
|
6930
|
+
const created = store.createWorktree(projectId, name, "HEAD", branch);
|
|
6931
|
+
if (!created.ok)
|
|
6932
|
+
return { ok: false, error: created.error };
|
|
6933
|
+
const wt = created.worktree;
|
|
6934
|
+
const files = [];
|
|
6935
|
+
const read = (f) => existsSync4(join5(wt, f)) ? readFileSync3(join5(wt, f), "utf8") : null;
|
|
6936
|
+
if (wantLesson) {
|
|
6937
|
+
writeFileSync2(join5(wt, "CLAUDE.md"), mergeLesson(read("CLAUDE.md"), s.lesson));
|
|
6938
|
+
files.push("CLAUDE.md");
|
|
6256
6939
|
}
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
return hit;
|
|
6261
|
-
const inflight = this.outcomeInflight.get(projectId);
|
|
6262
|
-
if (inflight)
|
|
6263
|
-
return inflight;
|
|
6264
|
-
const run = this.fetchMerged(projectId, root).finally(() => this.outcomeInflight.delete(projectId));
|
|
6265
|
-
this.outcomeInflight.set(projectId, run);
|
|
6266
|
-
return run;
|
|
6940
|
+
if (wantToml && s.toml) {
|
|
6941
|
+
writeFileSync2(join5(wt, ".swarm.toml"), mergeToml(read(".swarm.toml"), s.toml));
|
|
6942
|
+
files.push(".swarm.toml");
|
|
6267
6943
|
}
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
}));
|
|
6293
|
-
} else if (remote?.forge === "gitlab") {
|
|
6294
|
-
const out = await this.run(["glab", "mr", "list", "--merged", "--output", "json"], root);
|
|
6295
|
-
if (out)
|
|
6296
|
-
merged = JSON.parse(out).map((r) => ({
|
|
6297
|
-
branch: String(r.source_branch ?? ""),
|
|
6298
|
-
number: Number(r.iid ?? 0),
|
|
6299
|
-
title: String(r.title ?? ""),
|
|
6300
|
-
url: String(r.web_url ?? ""),
|
|
6301
|
-
createdAt: r.created_at ?? null,
|
|
6302
|
-
mergedAt: r.merged_at ?? null,
|
|
6303
|
-
mergeSha: (r.merge_commit_sha ?? null)?.toLowerCase() ?? null
|
|
6304
|
-
}));
|
|
6944
|
+
const rule = String(incident.rule ?? "rule");
|
|
6945
|
+
const message = `${s.title}
|
|
6946
|
+
|
|
6947
|
+
Codified from Swarm incident #${seq} (${rule}):
|
|
6948
|
+
${String(incident.command ?? "").slice(0, 200)}
|
|
6949
|
+
|
|
6950
|
+
${s.lesson}`;
|
|
6951
|
+
const commit = commitPaths(wt, files, message);
|
|
6952
|
+
if (!commit) {
|
|
6953
|
+
await store.removeWorktree(projectId, wt, true);
|
|
6954
|
+
return { ok: false, error: "nothing changed \u2014 the lesson and the rule were already in place" };
|
|
6955
|
+
}
|
|
6956
|
+
store.append({
|
|
6957
|
+
ts: new Date().toISOString(),
|
|
6958
|
+
type: "codify.applied",
|
|
6959
|
+
projectId,
|
|
6960
|
+
sessionId: null,
|
|
6961
|
+
payload: {
|
|
6962
|
+
seq,
|
|
6963
|
+
rule,
|
|
6964
|
+
branch,
|
|
6965
|
+
files,
|
|
6966
|
+
commit,
|
|
6967
|
+
summary: `codified incident #${seq} onto ${branch}`
|
|
6305
6968
|
}
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6969
|
+
});
|
|
6970
|
+
const pr = await forge.openPR(projectId, { path: wt, branch, dirty: 0, main: false }, {
|
|
6971
|
+
title: s.title,
|
|
6972
|
+
body: `Codified from Swarm incident #${seq} (\`${rule}\`).
|
|
6973
|
+
|
|
6974
|
+
\`\`\`
|
|
6975
|
+
${String(incident.command ?? "").slice(0, 400)}
|
|
6976
|
+
\`\`\`
|
|
6977
|
+
|
|
6978
|
+
${s.lesson}${s.toml ? `
|
|
6979
|
+
|
|
6980
|
+
\`\`\`toml
|
|
6981
|
+
${s.toml}
|
|
6982
|
+
\`\`\`` : ""}`,
|
|
6983
|
+
isDraft: false
|
|
6984
|
+
});
|
|
6985
|
+
if (pr.ok) {
|
|
6986
|
+
store.recordPrOpened(projectId, name, wt, pr.url);
|
|
6987
|
+
await store.removeWorktree(projectId, wt, false);
|
|
6988
|
+
return {
|
|
6989
|
+
ok: true,
|
|
6990
|
+
branch,
|
|
6991
|
+
files,
|
|
6992
|
+
commit,
|
|
6993
|
+
pr: { url: pr.url, ...pr.number ? { number: pr.number } : {} }
|
|
6994
|
+
};
|
|
6311
6995
|
}
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6996
|
+
return {
|
|
6997
|
+
ok: true,
|
|
6998
|
+
branch,
|
|
6999
|
+
files,
|
|
7000
|
+
commit,
|
|
7001
|
+
worktree: wt,
|
|
7002
|
+
gitLine: `git -C ${JSON.stringify(wt)} push -u origin ${branch}`
|
|
7003
|
+
};
|
|
7004
|
+
}
|
|
7005
|
+
|
|
7006
|
+
// packages/daemon/src/dispatcher.ts
|
|
7007
|
+
class Dispatcher {
|
|
7008
|
+
store;
|
|
7009
|
+
runner;
|
|
7010
|
+
forge;
|
|
7011
|
+
entries = new Map;
|
|
7012
|
+
opts = new Map;
|
|
7013
|
+
constructor(store, runner, forge) {
|
|
7014
|
+
this.store = store;
|
|
7015
|
+
this.runner = runner;
|
|
7016
|
+
this.forge = forge;
|
|
7017
|
+
runner.onEnd((run) => void this.onRunEnd(run));
|
|
6321
7018
|
}
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
if (!
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
try {
|
|
6328
|
-
proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
6329
|
-
} catch {
|
|
6330
|
-
return null;
|
|
6331
|
-
}
|
|
6332
|
-
const killer = setTimeout(() => proc.kill(), timeoutMs);
|
|
6333
|
-
try {
|
|
6334
|
-
const out = await new Response(proc.stdout).text();
|
|
6335
|
-
return await proc.exited === 0 ? out : null;
|
|
6336
|
-
} catch {
|
|
6337
|
-
return null;
|
|
6338
|
-
} finally {
|
|
6339
|
-
clearTimeout(killer);
|
|
7019
|
+
project(projectId) {
|
|
7020
|
+
let m = this.entries.get(projectId);
|
|
7021
|
+
if (!m) {
|
|
7022
|
+
m = new Map;
|
|
7023
|
+
this.entries.set(projectId, m);
|
|
6340
7024
|
}
|
|
7025
|
+
return m;
|
|
6341
7026
|
}
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
if (!remote)
|
|
6345
|
-
return [];
|
|
6346
|
-
let prs = [];
|
|
6347
|
-
if (remote.forge === "github") {
|
|
6348
|
-
const out = await this.run(["gh", "pr", "list", "--json", GH_FIELDS], root);
|
|
6349
|
-
if (out)
|
|
6350
|
-
prs = normalizeGithub(JSON.parse(out), remote.repo);
|
|
6351
|
-
} else {
|
|
6352
|
-
const out = await this.run(["glab", "mr", "list", "--output", "json"], root);
|
|
6353
|
-
if (out)
|
|
6354
|
-
prs = normalizeGitlab(JSON.parse(out), remote.repo);
|
|
6355
|
-
}
|
|
6356
|
-
return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
|
|
7027
|
+
status(projectId) {
|
|
7028
|
+
return [...this.entries.get(projectId)?.values() ?? []];
|
|
6357
7029
|
}
|
|
6358
|
-
async
|
|
6359
|
-
const
|
|
6360
|
-
if (!
|
|
6361
|
-
return { ok: false, error: "unknown project" };
|
|
6362
|
-
if (worktree.main)
|
|
6363
|
-
return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
|
|
6364
|
-
if (!worktree.branch)
|
|
6365
|
-
return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
|
|
6366
|
-
if (worktree.dirty > 0)
|
|
7030
|
+
async dispatch(projectId, wanted, o = {}) {
|
|
7031
|
+
const board = this.store.tasks(projectId);
|
|
7032
|
+
if (!board)
|
|
6367
7033
|
return {
|
|
6368
7034
|
ok: false,
|
|
6369
|
-
error:
|
|
7035
|
+
error: "this repo has no task source ([tasks] source in .swarm.toml)"
|
|
6370
7036
|
};
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
const
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
7037
|
+
if (board.error)
|
|
7038
|
+
return { ok: false, error: `task source: ${board.error}` };
|
|
7039
|
+
const cfg = this.store.config(projectId).dispatch;
|
|
7040
|
+
const opts = {
|
|
7041
|
+
owner: o.owner ?? "dispatch",
|
|
7042
|
+
...o,
|
|
7043
|
+
maxParallel: o.maxParallel ?? cfg.max_parallel
|
|
7044
|
+
};
|
|
7045
|
+
this.opts.set(projectId, opts);
|
|
7046
|
+
const m = this.project(projectId);
|
|
7047
|
+
const running = [...m.values()].filter((e) => e.state === "running").length;
|
|
7048
|
+
const plan = planDispatch(board.tasks, wanted, {
|
|
7049
|
+
maxParallel: opts.maxParallel,
|
|
7050
|
+
running,
|
|
7051
|
+
max: o.max,
|
|
7052
|
+
alreadyQueued: [...m.values()].filter((e) => e.state !== "finished").map((e) => e.task)
|
|
7053
|
+
});
|
|
7054
|
+
const now = new Date().toISOString();
|
|
7055
|
+
for (const t of [...plan.start, ...plan.queued]) {
|
|
7056
|
+
m.set(t.id, {
|
|
7057
|
+
task: t.id,
|
|
7058
|
+
title: t.title,
|
|
7059
|
+
state: "queued",
|
|
7060
|
+
runId: null,
|
|
7061
|
+
sessionId: null,
|
|
7062
|
+
queuedAt: now,
|
|
7063
|
+
startedAt: null,
|
|
7064
|
+
endedAt: null,
|
|
7065
|
+
outcome: null,
|
|
7066
|
+
detail: null,
|
|
7067
|
+
costUsd: null
|
|
7068
|
+
});
|
|
7069
|
+
}
|
|
7070
|
+
if (plan.start.length || plan.queued.length)
|
|
7071
|
+
this.store.append({
|
|
7072
|
+
ts: now,
|
|
7073
|
+
type: "dispatch.queued",
|
|
7074
|
+
projectId,
|
|
7075
|
+
sessionId: null,
|
|
7076
|
+
payload: {
|
|
7077
|
+
tasks: [...plan.start, ...plan.queued].map((t) => t.id),
|
|
7078
|
+
maxParallel: opts.maxParallel,
|
|
7079
|
+
summary: `dispatch ${[...plan.start, ...plan.queued].map((t) => t.id).join(", ")}`
|
|
7080
|
+
}
|
|
7081
|
+
});
|
|
7082
|
+
const started = [];
|
|
7083
|
+
const failed = [];
|
|
7084
|
+
for (const t of plan.start) {
|
|
7085
|
+
const r = await this.startOne(projectId, t);
|
|
7086
|
+
if (r.ok)
|
|
7087
|
+
started.push(t.id);
|
|
7088
|
+
else
|
|
7089
|
+
failed.push({ id: t.id, reason: r.reason });
|
|
7090
|
+
}
|
|
7091
|
+
await this.fill(projectId);
|
|
7092
|
+
return {
|
|
7093
|
+
ok: true,
|
|
7094
|
+
started,
|
|
7095
|
+
queued: plan.queued.map((t) => t.id).filter((id) => m.get(id)?.state === "queued"),
|
|
7096
|
+
rejected: [...plan.rejected, ...failed]
|
|
6382
7097
|
};
|
|
6383
|
-
const push = await sh(["git", "push", "-u", "origin", worktree.branch], worktree.path);
|
|
6384
|
-
if (!push.ok)
|
|
6385
|
-
return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
|
|
6386
|
-
const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree.branch);
|
|
6387
|
-
if (existing)
|
|
6388
|
-
return { ok: true, url: existing.url, number: existing.number };
|
|
6389
|
-
const cmd = remote.forge === "github" ? [
|
|
6390
|
-
bin,
|
|
6391
|
-
"pr",
|
|
6392
|
-
"create",
|
|
6393
|
-
"--head",
|
|
6394
|
-
worktree.branch,
|
|
6395
|
-
"--title",
|
|
6396
|
-
draft.title,
|
|
6397
|
-
"--body",
|
|
6398
|
-
draft.body,
|
|
6399
|
-
...draft.isDraft ? ["--draft"] : []
|
|
6400
|
-
] : [
|
|
6401
|
-
bin,
|
|
6402
|
-
"mr",
|
|
6403
|
-
"create",
|
|
6404
|
-
"--source-branch",
|
|
6405
|
-
worktree.branch,
|
|
6406
|
-
"--title",
|
|
6407
|
-
draft.title,
|
|
6408
|
-
"--description",
|
|
6409
|
-
draft.body,
|
|
6410
|
-
"--yes",
|
|
6411
|
-
...draft.isDraft ? ["--draft"] : []
|
|
6412
|
-
];
|
|
6413
|
-
const r = await sh(cmd, worktree.path);
|
|
6414
|
-
if (!r.ok)
|
|
6415
|
-
return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
|
|
6416
|
-
const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
|
|
6417
|
-
const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
|
|
6418
|
-
this.cache.delete(projectId);
|
|
6419
|
-
return { ok: true, url, number: Number.isFinite(num) ? num : null };
|
|
6420
|
-
}
|
|
6421
|
-
async merge(projectId, number) {
|
|
6422
|
-
const p = this.store.projects().find((x) => x.id === projectId);
|
|
6423
|
-
if (!p)
|
|
6424
|
-
return { ok: false, output: "unknown project" };
|
|
6425
|
-
const remote = await this.remote(p.root);
|
|
6426
|
-
if (!remote)
|
|
6427
|
-
return { ok: false, output: "no forge remote" };
|
|
6428
|
-
const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
|
|
6429
|
-
const bin = findBin(cmd[0]);
|
|
6430
|
-
if (!bin)
|
|
6431
|
-
return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
|
|
6432
|
-
const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
|
|
6433
|
-
const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
|
|
6434
|
-
const ok = await proc.exited === 0;
|
|
6435
|
-
if (ok)
|
|
6436
|
-
this.cache.delete(projectId);
|
|
6437
|
-
return { ok, output: out.trim().slice(0, 800) };
|
|
6438
|
-
}
|
|
6439
|
-
}
|
|
6440
|
-
|
|
6441
|
-
// packages/daemon/src/git.ts
|
|
6442
|
-
import { realpathSync } from "fs";
|
|
6443
|
-
import { join as join5 } from "path";
|
|
6444
|
-
function git(cwd, args) {
|
|
6445
|
-
try {
|
|
6446
|
-
const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
6447
|
-
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
6448
|
-
} catch {
|
|
6449
|
-
return null;
|
|
6450
7098
|
}
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
7099
|
+
async startOne(projectId, t) {
|
|
7100
|
+
const m = this.project(projectId);
|
|
7101
|
+
const e = m.get(t.id);
|
|
7102
|
+
const opts = this.opts.get(projectId) ?? { owner: "dispatch" };
|
|
7103
|
+
const cfg = this.store.config(projectId);
|
|
7104
|
+
const gates = cfg.gates;
|
|
7105
|
+
const prompt = taskPrompt(t, {
|
|
7106
|
+
requiredGates: gates.required,
|
|
7107
|
+
executableGates: gates.required.filter((g) => gates.defs[g]),
|
|
7108
|
+
openPr: cfg.dispatch.require_pr
|
|
7109
|
+
});
|
|
7110
|
+
const r = await this.runner.start({
|
|
7111
|
+
projectId,
|
|
7112
|
+
task: t.id,
|
|
7113
|
+
prompt,
|
|
7114
|
+
owner: opts.owner,
|
|
7115
|
+
permissionMode: opts.permissionMode ?? cfg.dispatch.permission_mode ?? "acceptEdits",
|
|
7116
|
+
model: opts.model ?? cfg.dispatch.model ?? undefined,
|
|
7117
|
+
maxTurns: opts.maxTurns ?? cfg.dispatch.max_turns ?? undefined,
|
|
7118
|
+
profile: opts.profile ?? cfg.dispatch.profile ?? undefined
|
|
7119
|
+
});
|
|
7120
|
+
if (!r.ok) {
|
|
7121
|
+
if (e) {
|
|
7122
|
+
e.state = "finished";
|
|
7123
|
+
e.endedAt = new Date().toISOString();
|
|
7124
|
+
e.outcome = "crashed";
|
|
7125
|
+
e.detail = r.reason;
|
|
7126
|
+
}
|
|
7127
|
+
this.store.append({
|
|
7128
|
+
ts: new Date().toISOString(),
|
|
7129
|
+
type: "dispatch.finished",
|
|
7130
|
+
projectId,
|
|
7131
|
+
sessionId: null,
|
|
7132
|
+
payload: {
|
|
7133
|
+
task: t.id,
|
|
7134
|
+
outcome: "crashed",
|
|
7135
|
+
detail: r.reason,
|
|
7136
|
+
summary: `dispatch ${t.id}: could not start \u2014 ${r.reason}`
|
|
7137
|
+
}
|
|
7138
|
+
});
|
|
7139
|
+
return { ok: false, reason: r.reason };
|
|
7140
|
+
}
|
|
7141
|
+
if (e) {
|
|
7142
|
+
e.state = "running";
|
|
7143
|
+
e.runId = r.run.id;
|
|
7144
|
+
e.sessionId = r.run.sessionId;
|
|
7145
|
+
e.startedAt = r.run.startedAt;
|
|
7146
|
+
}
|
|
7147
|
+
this.store.append({
|
|
7148
|
+
ts: r.run.startedAt,
|
|
7149
|
+
type: "dispatch.started",
|
|
7150
|
+
projectId,
|
|
7151
|
+
sessionId: r.run.sessionId,
|
|
7152
|
+
payload: {
|
|
7153
|
+
task: t.id,
|
|
7154
|
+
runId: r.run.id,
|
|
7155
|
+
worktree: r.run.worktree,
|
|
7156
|
+
by: this.opts.get(projectId)?.owner ?? null,
|
|
7157
|
+
summary: `dispatch ${t.id} \u2192 run ${r.run.id}`
|
|
7158
|
+
}
|
|
7159
|
+
});
|
|
7160
|
+
return { ok: true };
|
|
6460
7161
|
}
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
7162
|
+
async fill(projectId) {
|
|
7163
|
+
const m = this.project(projectId);
|
|
7164
|
+
const cap = this.opts.get(projectId)?.maxParallel ?? this.store.config(projectId).dispatch.max_parallel;
|
|
7165
|
+
for (const e of m.values()) {
|
|
7166
|
+
const running = [...m.values()].filter((x) => x.state === "running").length;
|
|
7167
|
+
if (running >= cap)
|
|
7168
|
+
return;
|
|
7169
|
+
if (e.state !== "queued")
|
|
7170
|
+
continue;
|
|
7171
|
+
await this.startOne(projectId, { id: e.task, title: e.title });
|
|
7172
|
+
}
|
|
6470
7173
|
}
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
merged: false
|
|
7174
|
+
async onRunEnd(run) {
|
|
7175
|
+
const m = this.entries.get(run.projectId);
|
|
7176
|
+
const e = m?.get(run.task);
|
|
7177
|
+
if (!e || e.runId !== run.id)
|
|
7178
|
+
return;
|
|
7179
|
+
const cfg = this.store.config(run.projectId);
|
|
7180
|
+
const required = cfg.gates.required;
|
|
7181
|
+
let runs = this.store.gateRuns(run.projectId, run.task);
|
|
7182
|
+
const status = this.store.gateStatusFor(runs, required);
|
|
7183
|
+
const missing = required.filter((g) => cfg.gates.defs[g] && status.find((s) => s.gate === g)?.verdict !== "pass");
|
|
7184
|
+
if (missing.length && !run.stopped) {
|
|
7185
|
+
await this.store.runGates(run.projectId, run.task, missing, {
|
|
7186
|
+
sessionId: run.sessionId,
|
|
7187
|
+
owner: "dispatch"
|
|
6486
7188
|
});
|
|
7189
|
+
runs = this.store.gateRuns(run.projectId, run.task);
|
|
6487
7190
|
}
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
7191
|
+
const satisfied = gatesSatisfied(runs, required);
|
|
7192
|
+
await this.forge.refresh(0).catch(() => {});
|
|
7193
|
+
const branch = `task/${run.task}`;
|
|
7194
|
+
const pr = this.forge.prs().find((p) => p.projectId === run.projectId && p.branch === branch);
|
|
7195
|
+
const outcome = dispatchOutcome({
|
|
7196
|
+
exitCode: run.exitCode,
|
|
7197
|
+
isError: run.result?.isError ?? false,
|
|
7198
|
+
gatesSatisfied: satisfied,
|
|
7199
|
+
prOpen: Boolean(pr),
|
|
7200
|
+
requirePr: cfg.dispatch.require_pr,
|
|
7201
|
+
stopped: run.stopped ?? false
|
|
7202
|
+
});
|
|
7203
|
+
const verdicts = this.store.gateStatusFor(runs, required).map((s) => `${s.gate} ${s.verdict ?? "\u2014"}`).join(", ");
|
|
7204
|
+
const detail = [
|
|
7205
|
+
`exit ${run.exitCode}${run.result?.isError ? " (error)" : ""}`,
|
|
7206
|
+
required.length ? `gates: ${verdicts}` : null,
|
|
7207
|
+
pr ? `PR ${pr.url}` : cfg.dispatch.require_pr ? "no PR" : null
|
|
7208
|
+
].filter(Boolean).join(" \xB7 ");
|
|
7209
|
+
e.state = "finished";
|
|
7210
|
+
e.endedAt = run.endedAt;
|
|
7211
|
+
e.outcome = outcome;
|
|
7212
|
+
e.detail = detail;
|
|
7213
|
+
e.costUsd = run.result?.costUsd ?? null;
|
|
7214
|
+
const ts = run.endedAt ?? new Date().toISOString();
|
|
7215
|
+
this.store.append({
|
|
7216
|
+
ts,
|
|
7217
|
+
type: "dispatch.finished",
|
|
7218
|
+
projectId: run.projectId,
|
|
7219
|
+
sessionId: run.sessionId,
|
|
7220
|
+
payload: {
|
|
7221
|
+
task: run.task,
|
|
7222
|
+
runId: run.id,
|
|
7223
|
+
outcome,
|
|
7224
|
+
detail,
|
|
7225
|
+
costUsd: e.costUsd,
|
|
7226
|
+
summary: `dispatch ${run.task}: ${outcome} \u2014 ${detail}`
|
|
7227
|
+
}
|
|
7228
|
+
});
|
|
7229
|
+
if (outcome !== "done" && outcome !== "stopped")
|
|
7230
|
+
this.store.append({
|
|
7231
|
+
ts,
|
|
7232
|
+
type: "incident.opened",
|
|
7233
|
+
projectId: run.projectId,
|
|
7234
|
+
sessionId: run.sessionId,
|
|
7235
|
+
payload: {
|
|
7236
|
+
rule: "dispatch_failed",
|
|
7237
|
+
action: outcome,
|
|
7238
|
+
command: run.task,
|
|
7239
|
+
reason: `dispatched run on ${run.task} ended ${outcome}: ${detail}. The worktree and claim are kept; resume it from the session page or release it.`
|
|
7240
|
+
}
|
|
7241
|
+
});
|
|
7242
|
+
this.store.touch();
|
|
7243
|
+
await this.fill(run.projectId);
|
|
7244
|
+
}
|
|
7245
|
+
clear(projectId, task) {
|
|
7246
|
+
const m = this.project(projectId);
|
|
7247
|
+
let n = 0;
|
|
7248
|
+
for (const [id, e] of m) {
|
|
7249
|
+
if (task && id !== task)
|
|
7250
|
+
continue;
|
|
7251
|
+
if (e.state === "queued" || e.state === "finished" && !task) {
|
|
7252
|
+
m.delete(id);
|
|
7253
|
+
n++;
|
|
7254
|
+
}
|
|
7255
|
+
}
|
|
7256
|
+
return n;
|
|
6501
7257
|
}
|
|
6502
|
-
flush();
|
|
6503
|
-
return wts;
|
|
6504
|
-
}
|
|
6505
|
-
function applyStatus(w, st, ah) {
|
|
6506
|
-
w.dirty = st === null ? -1 : st.split(`
|
|
6507
|
-
`).filter(Boolean).length;
|
|
6508
|
-
const a = ah?.trim();
|
|
6509
|
-
w.ahead = a === undefined || a === "" ? -1 : Number(a);
|
|
6510
|
-
}
|
|
6511
|
-
function applyDrift(w, behind, ancestor, firstParents, cherry = null) {
|
|
6512
|
-
const b = behind?.trim();
|
|
6513
|
-
w.behind = b === undefined || b === "" ? -1 : Number(b);
|
|
6514
|
-
const onLine = firstParents?.split(`
|
|
6515
|
-
`).some((sha) => sha.startsWith(w.head)) ?? true;
|
|
6516
|
-
w.merged = (ancestor || squashed(cherry)) && !onLine;
|
|
6517
|
-
}
|
|
6518
|
-
function squashed(cherry) {
|
|
6519
|
-
if (!cherry)
|
|
6520
|
-
return false;
|
|
6521
|
-
const lines = cherry.split(`
|
|
6522
|
-
`).filter((l) => l.trim());
|
|
6523
|
-
return lines.length > 0 && lines.every((l) => l.startsWith("-"));
|
|
6524
7258
|
}
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
7259
|
+
|
|
7260
|
+
// packages/daemon/src/forge.ts
|
|
7261
|
+
import { existsSync as existsSync5 } from "fs";
|
|
7262
|
+
import { homedir as homedir2 } from "os";
|
|
7263
|
+
import { join as join6 } from "path";
|
|
7264
|
+
var EXTRA_BIN_DIRS = [
|
|
7265
|
+
"/opt/homebrew/bin",
|
|
7266
|
+
"/usr/local/bin",
|
|
7267
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
7268
|
+
join6(homedir2(), ".local", "bin"),
|
|
7269
|
+
join6(homedir2(), "bin")
|
|
7270
|
+
];
|
|
7271
|
+
function findBin(name) {
|
|
7272
|
+
if (!name)
|
|
6533
7273
|
return null;
|
|
7274
|
+
const onPath = Bun.which(name, { PATH: process.env.PATH ?? "" });
|
|
7275
|
+
if (onPath)
|
|
7276
|
+
return onPath;
|
|
7277
|
+
for (const d of EXTRA_BIN_DIRS) {
|
|
7278
|
+
const p = join6(d, name);
|
|
7279
|
+
if (existsSync5(p))
|
|
7280
|
+
return p;
|
|
6534
7281
|
}
|
|
7282
|
+
return null;
|
|
6535
7283
|
}
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
const drift = base && !w.main;
|
|
6545
|
-
const [st, ah, be, mg, ch] = await Promise.all([
|
|
6546
|
-
gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
|
|
6547
|
-
gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
|
|
6548
|
-
drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
|
|
6549
|
-
drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null,
|
|
6550
|
-
drift ? gitAsync(w.path, ["cherry", base, "HEAD"]) : null
|
|
6551
|
-
]);
|
|
6552
|
-
applyStatus(w, st, ah);
|
|
6553
|
-
if (drift)
|
|
6554
|
-
applyDrift(w, be, mg !== null, line, ch);
|
|
6555
|
-
}));
|
|
6556
|
-
return wts;
|
|
6557
|
-
}
|
|
6558
|
-
var branchCache = new Map;
|
|
6559
|
-
function currentBranch(cwd) {
|
|
6560
|
-
const hit = branchCache.get(cwd);
|
|
6561
|
-
const now = Date.now();
|
|
6562
|
-
if (hit && now - hit.t < 5000)
|
|
6563
|
-
return hit.v;
|
|
6564
|
-
const v = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() ?? null;
|
|
6565
|
-
branchCache.set(cwd, { v: v === "HEAD" ? "(detached)" : v, t: now });
|
|
6566
|
-
return branchCache.get(cwd)?.v ?? null;
|
|
6567
|
-
}
|
|
6568
|
-
var originCache = new Map;
|
|
6569
|
-
function originUrl(root) {
|
|
6570
|
-
const hit = originCache.get(root);
|
|
6571
|
-
const now = Date.now();
|
|
6572
|
-
if (hit && now - hit.t < 300000)
|
|
6573
|
-
return hit.v;
|
|
6574
|
-
const v = git(root, ["config", "--get", "remote.origin.url"])?.trim() || null;
|
|
6575
|
-
originCache.set(root, { v, t: now });
|
|
6576
|
-
return v;
|
|
6577
|
-
}
|
|
6578
|
-
function worktreeAdd(repoRoot, path, branch, baseRef = "HEAD") {
|
|
6579
|
-
const branchExists = git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
6580
|
-
const args = branchExists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, baseRef];
|
|
6581
|
-
if (git(repoRoot, args) === null)
|
|
6582
|
-
return null;
|
|
6583
|
-
try {
|
|
6584
|
-
return realpathSync(path);
|
|
6585
|
-
} catch {
|
|
6586
|
-
return path;
|
|
7284
|
+
var GH_FIELDS = "number,title,headRefName,url,author,isDraft,mergeable,reviewDecision,statusCheckRollup,createdAt";
|
|
7285
|
+
|
|
7286
|
+
class ForgeService {
|
|
7287
|
+
store;
|
|
7288
|
+
cache = new Map;
|
|
7289
|
+
inflight = new Set;
|
|
7290
|
+
constructor(store) {
|
|
7291
|
+
this.store = store;
|
|
6587
7292
|
}
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
7293
|
+
prs() {
|
|
7294
|
+
this.refresh();
|
|
7295
|
+
const all = [...this.cache.values()].flatMap((c) => c.prs);
|
|
7296
|
+
return all.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
|
|
7297
|
+
}
|
|
7298
|
+
async refresh(maxAgeMs = 120000) {
|
|
7299
|
+
const projects = this.store.liveProjects();
|
|
7300
|
+
await Promise.all(projects.map(async (p) => {
|
|
7301
|
+
const hit = this.cache.get(p.id);
|
|
7302
|
+
if (hit && Date.now() - hit.at < maxAgeMs)
|
|
7303
|
+
return;
|
|
7304
|
+
if (this.inflight.has(p.id))
|
|
7305
|
+
return;
|
|
7306
|
+
this.inflight.add(p.id);
|
|
7307
|
+
try {
|
|
7308
|
+
const prs = await this.poll(p.id, p.root);
|
|
7309
|
+
this.cache.set(p.id, { at: Date.now(), prs });
|
|
7310
|
+
} catch {
|
|
7311
|
+
this.cache.set(p.id, { at: Date.now(), prs: this.cache.get(p.id)?.prs ?? [] });
|
|
7312
|
+
} finally {
|
|
7313
|
+
this.inflight.delete(p.id);
|
|
7314
|
+
}
|
|
7315
|
+
}));
|
|
7316
|
+
}
|
|
7317
|
+
outcomeCache = new Map;
|
|
7318
|
+
outcomeInflight = new Map;
|
|
7319
|
+
mergedCached(projectId, root) {
|
|
7320
|
+
const hit = this.outcomeCache.get(projectId);
|
|
7321
|
+
const fresh = !!hit && Date.now() - hit.at < 600000;
|
|
7322
|
+
if (!fresh)
|
|
7323
|
+
this.merged(projectId, root).catch(() => {});
|
|
7324
|
+
return { merged: hit?.merged ?? [], reverted: hit?.reverted ?? [], fresh };
|
|
7325
|
+
}
|
|
7326
|
+
async merged(projectId, root) {
|
|
7327
|
+
const hit = this.outcomeCache.get(projectId);
|
|
7328
|
+
if (hit && Date.now() - hit.at < 600000)
|
|
7329
|
+
return hit;
|
|
7330
|
+
const inflight = this.outcomeInflight.get(projectId);
|
|
7331
|
+
if (inflight)
|
|
7332
|
+
return inflight;
|
|
7333
|
+
const run = this.fetchMerged(projectId, root).finally(() => this.outcomeInflight.delete(projectId));
|
|
7334
|
+
this.outcomeInflight.set(projectId, run);
|
|
7335
|
+
return run;
|
|
7336
|
+
}
|
|
7337
|
+
async fetchMerged(projectId, root) {
|
|
7338
|
+
let merged = [];
|
|
7339
|
+
const remote = await this.remote(root);
|
|
7340
|
+
if (remote?.forge === "github") {
|
|
7341
|
+
const out = await this.run([
|
|
7342
|
+
"gh",
|
|
7343
|
+
"pr",
|
|
7344
|
+
"list",
|
|
7345
|
+
"--state",
|
|
7346
|
+
"merged",
|
|
7347
|
+
"--limit",
|
|
7348
|
+
"200",
|
|
7349
|
+
"--json",
|
|
7350
|
+
"number,title,headRefName,url,createdAt,mergedAt,mergeCommit"
|
|
7351
|
+
], root);
|
|
7352
|
+
if (out)
|
|
7353
|
+
merged = JSON.parse(out).map((r) => ({
|
|
7354
|
+
branch: String(r.headRefName ?? ""),
|
|
7355
|
+
number: Number(r.number ?? 0),
|
|
7356
|
+
title: String(r.title ?? ""),
|
|
7357
|
+
url: String(r.url ?? ""),
|
|
7358
|
+
createdAt: r.createdAt ?? null,
|
|
7359
|
+
mergedAt: r.mergedAt ?? null,
|
|
7360
|
+
mergeSha: (r.mergeCommit?.oid ?? null)?.toLowerCase() ?? null
|
|
7361
|
+
}));
|
|
7362
|
+
} else if (remote?.forge === "gitlab") {
|
|
7363
|
+
const out = await this.run(["glab", "mr", "list", "--merged", "--output", "json"], root);
|
|
7364
|
+
if (out)
|
|
7365
|
+
merged = JSON.parse(out).map((r) => ({
|
|
7366
|
+
branch: String(r.source_branch ?? ""),
|
|
7367
|
+
number: Number(r.iid ?? 0),
|
|
7368
|
+
title: String(r.title ?? ""),
|
|
7369
|
+
url: String(r.web_url ?? ""),
|
|
7370
|
+
createdAt: r.created_at ?? null,
|
|
7371
|
+
mergedAt: r.merged_at ?? null,
|
|
7372
|
+
mergeSha: (r.merge_commit_sha ?? null)?.toLowerCase() ?? null
|
|
7373
|
+
}));
|
|
7374
|
+
}
|
|
7375
|
+
const log = await this.run(["git", "log", "--grep", "This reverts commit", "--format=%B", "-n", "300"], root);
|
|
7376
|
+
const reverted = log ? [...parseReverts(log)] : [];
|
|
7377
|
+
const entry = { at: Date.now(), merged, reverted };
|
|
7378
|
+
this.outcomeCache.set(projectId, entry);
|
|
7379
|
+
return entry;
|
|
7380
|
+
}
|
|
7381
|
+
remoteCache = new Map;
|
|
7382
|
+
async remote(root) {
|
|
7383
|
+
const hit = this.remoteCache.get(root);
|
|
7384
|
+
if (hit && Date.now() - hit.at < (hit.v ? 600000 : 60000))
|
|
7385
|
+
return hit.v;
|
|
7386
|
+
const out = await this.run(["git", "remote", "get-url", "origin"], root);
|
|
7387
|
+
const v = out ? parseRemote(out.trim()) : null;
|
|
7388
|
+
this.remoteCache.set(root, { at: Date.now(), v });
|
|
7389
|
+
return v;
|
|
7390
|
+
}
|
|
7391
|
+
async run(cmd, cwd, timeoutMs = 20000) {
|
|
7392
|
+
const bin = findBin(cmd[0]);
|
|
7393
|
+
if (!bin)
|
|
7394
|
+
return null;
|
|
7395
|
+
let proc;
|
|
7396
|
+
try {
|
|
7397
|
+
proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
7398
|
+
} catch {
|
|
7399
|
+
return null;
|
|
7400
|
+
}
|
|
7401
|
+
const killer = setTimeout(() => proc.kill(), timeoutMs);
|
|
7402
|
+
try {
|
|
7403
|
+
const out = await new Response(proc.stdout).text();
|
|
7404
|
+
return await proc.exited === 0 ? out : null;
|
|
7405
|
+
} catch {
|
|
7406
|
+
return null;
|
|
7407
|
+
} finally {
|
|
7408
|
+
clearTimeout(killer);
|
|
6610
7409
|
}
|
|
6611
|
-
unpushed = baselines.length > 1 ? count(["HEAD", "--not", ...baselines]) > 0 : false;
|
|
6612
|
-
}
|
|
6613
|
-
return { dirty, unpushed };
|
|
6614
|
-
}
|
|
6615
|
-
async function worktreeDiff(root, path) {
|
|
6616
|
-
const wts = parseWorktreeList(await gitAsync(root, ["worktree", "list", "--porcelain"]) ?? "");
|
|
6617
|
-
const baseRef = wts[0]?.path === realpathOr(root) || wts[0]?.main ? wts[0]?.branch ?? null : null;
|
|
6618
|
-
const isMain = wts[0]?.path === path;
|
|
6619
|
-
const mb = baseRef && !isMain ? (await gitAsync(path, ["merge-base", baseRef, "HEAD"]))?.trim() : null;
|
|
6620
|
-
const from = mb || "HEAD";
|
|
6621
|
-
const [numstat, names, log, status] = await Promise.all([
|
|
6622
|
-
gitAsync(path, ["diff", "--numstat", from]),
|
|
6623
|
-
gitAsync(path, ["diff", "--name-status", from]),
|
|
6624
|
-
mb ? gitAsync(path, ["log", "--format=%s", `${mb}..HEAD`]) : Promise.resolve(""),
|
|
6625
|
-
gitAsync(path, ["status", "--porcelain"])
|
|
6626
|
-
]);
|
|
6627
|
-
const files = parseNumstat(numstat ?? "", names ?? "");
|
|
6628
|
-
for (const line of (status ?? "").split(`
|
|
6629
|
-
`)) {
|
|
6630
|
-
if (line.startsWith("?? "))
|
|
6631
|
-
files.push({ path: line.slice(3), added: -1, deleted: -1, status: "?" });
|
|
6632
7410
|
}
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
if (!tracked) {
|
|
6647
|
-
const p = Bun.spawn(["git", "-C", path, "diff", "--no-index", "--", "/dev/null", file], {
|
|
6648
|
-
stdout: "pipe",
|
|
6649
|
-
stderr: "ignore"
|
|
6650
|
-
});
|
|
6651
|
-
const [out] = await Promise.all([new Response(p.stdout).text(), p.exited]);
|
|
6652
|
-
return out;
|
|
7411
|
+
async poll(projectId, root) {
|
|
7412
|
+
const remote = await this.remote(root);
|
|
7413
|
+
if (!remote)
|
|
7414
|
+
return [];
|
|
7415
|
+
let prs = [];
|
|
7416
|
+
if (remote.forge === "github") {
|
|
7417
|
+
const out = await this.run(["gh", "pr", "list", "--json", GH_FIELDS], root);
|
|
7418
|
+
if (out)
|
|
7419
|
+
prs = normalizeGithub(JSON.parse(out), remote.repo);
|
|
7420
|
+
} else {
|
|
7421
|
+
const out = await this.run(["glab", "mr", "list", "--output", "json"], root);
|
|
7422
|
+
if (out)
|
|
7423
|
+
prs = normalizeGitlab(JSON.parse(out), remote.repo);
|
|
6653
7424
|
}
|
|
6654
|
-
return
|
|
7425
|
+
return prs.map((pr) => ({ ...pr, projectId, projectRoot: root }));
|
|
6655
7426
|
}
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
7427
|
+
async openPR(projectId, worktree, draft) {
|
|
7428
|
+
const p = this.store.projects().find((x) => x.id === projectId);
|
|
7429
|
+
if (!p)
|
|
7430
|
+
return { ok: false, error: "unknown project" };
|
|
7431
|
+
if (worktree.main)
|
|
7432
|
+
return { ok: false, error: "that is the main checkout \u2014 open the PR from a task worktree" };
|
|
7433
|
+
if (!worktree.branch)
|
|
7434
|
+
return { ok: false, error: "detached HEAD \u2014 check out a branch first" };
|
|
7435
|
+
if (worktree.dirty > 0)
|
|
7436
|
+
return {
|
|
7437
|
+
ok: false,
|
|
7438
|
+
error: `${worktree.path} has uncommitted changes \u2014 commit them first (Swarm never commits for you)`
|
|
7439
|
+
};
|
|
7440
|
+
const remote = await this.remote(p.root);
|
|
7441
|
+
if (!remote)
|
|
7442
|
+
return { ok: false, error: "no GitHub/GitLab remote on origin" };
|
|
7443
|
+
const cli = remote.forge === "github" ? "gh" : "glab";
|
|
7444
|
+
const bin = findBin(cli);
|
|
7445
|
+
if (!bin)
|
|
7446
|
+
return { ok: false, error: `${cli} is not installed` };
|
|
7447
|
+
const sh = async (cmd, cwd) => {
|
|
7448
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
7449
|
+
const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
|
|
7450
|
+
return { ok: await proc.exited === 0, out: out.trim() };
|
|
7451
|
+
};
|
|
7452
|
+
const push = await sh(["git", "push", "-u", "origin", worktree.branch], worktree.path);
|
|
7453
|
+
if (!push.ok)
|
|
7454
|
+
return { ok: false, error: `git push failed: ${push.out.slice(0, 400)}` };
|
|
7455
|
+
const existing = this.prs().find((x) => x.projectId === projectId && x.branch === worktree.branch);
|
|
7456
|
+
if (existing)
|
|
7457
|
+
return { ok: true, url: existing.url, number: existing.number };
|
|
7458
|
+
const cmd = remote.forge === "github" ? [
|
|
7459
|
+
bin,
|
|
7460
|
+
"pr",
|
|
7461
|
+
"create",
|
|
7462
|
+
"--head",
|
|
7463
|
+
worktree.branch,
|
|
7464
|
+
"--title",
|
|
7465
|
+
draft.title,
|
|
7466
|
+
"--body",
|
|
7467
|
+
draft.body,
|
|
7468
|
+
...draft.isDraft ? ["--draft"] : []
|
|
7469
|
+
] : [
|
|
7470
|
+
bin,
|
|
7471
|
+
"mr",
|
|
7472
|
+
"create",
|
|
7473
|
+
"--source-branch",
|
|
7474
|
+
worktree.branch,
|
|
7475
|
+
"--title",
|
|
7476
|
+
draft.title,
|
|
7477
|
+
"--description",
|
|
7478
|
+
draft.body,
|
|
7479
|
+
"--yes",
|
|
7480
|
+
...draft.isDraft ? ["--draft"] : []
|
|
7481
|
+
];
|
|
7482
|
+
const r = await sh(cmd, worktree.path);
|
|
7483
|
+
if (!r.ok)
|
|
7484
|
+
return { ok: false, error: `${cli} failed: ${r.out.slice(0, 400)}` };
|
|
7485
|
+
const url = r.out.match(/https?:\/\/\S+/)?.[0] ?? r.out;
|
|
7486
|
+
const num = Number(url.match(/\/(\d+)\s*$/)?.[1]);
|
|
7487
|
+
this.cache.delete(projectId);
|
|
7488
|
+
return { ok: true, url, number: Number.isFinite(num) ? num : null };
|
|
7489
|
+
}
|
|
7490
|
+
async merge(projectId, number) {
|
|
7491
|
+
const p = this.store.projects().find((x) => x.id === projectId);
|
|
7492
|
+
if (!p)
|
|
7493
|
+
return { ok: false, output: "unknown project" };
|
|
7494
|
+
const remote = await this.remote(p.root);
|
|
7495
|
+
if (!remote)
|
|
7496
|
+
return { ok: false, output: "no forge remote" };
|
|
7497
|
+
const cmd = remote.forge === "github" ? ["gh", "pr", "merge", String(number), "--squash"] : ["glab", "mr", "merge", String(number), "--squash", "--yes"];
|
|
7498
|
+
const bin = findBin(cmd[0]);
|
|
7499
|
+
if (!bin)
|
|
7500
|
+
return { ok: false, output: `${cmd[0] ?? "forge CLI"} is not installed` };
|
|
7501
|
+
const proc = Bun.spawn([bin, ...cmd.slice(1)], { cwd: p.root, stdout: "pipe", stderr: "pipe" });
|
|
7502
|
+
const out = await new Response(proc.stdout).text() + await new Response(proc.stderr).text();
|
|
7503
|
+
const ok = await proc.exited === 0;
|
|
7504
|
+
if (ok)
|
|
7505
|
+
this.cache.delete(projectId);
|
|
7506
|
+
return { ok, output: out.trim().slice(0, 800) };
|
|
6663
7507
|
}
|
|
6664
7508
|
}
|
|
6665
7509
|
|
|
6666
7510
|
// packages/daemon/src/runner.ts
|
|
6667
7511
|
import { appendFileSync, mkdirSync as mkdirSync2, openSync } from "fs";
|
|
6668
|
-
import { join as
|
|
7512
|
+
import { join as join7 } from "path";
|
|
6669
7513
|
var PERMISSION_MODES = [
|
|
6670
7514
|
"acceptEdits",
|
|
6671
7515
|
"auto",
|
|
@@ -6732,9 +7576,9 @@ class Runner {
|
|
|
6732
7576
|
await this.store.awaitBootstrap(worktree);
|
|
6733
7577
|
const sessionId = crypto.randomUUID();
|
|
6734
7578
|
const id = sessionId.slice(0, 8);
|
|
6735
|
-
const logDir =
|
|
7579
|
+
const logDir = join7(this.home, "logs", project.id);
|
|
6736
7580
|
mkdirSync2(logDir, { recursive: true });
|
|
6737
|
-
const log =
|
|
7581
|
+
const log = join7(logDir, `run-${input.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}-${id}.log`);
|
|
6738
7582
|
const logFd = openSync(log, "a");
|
|
6739
7583
|
const args = [
|
|
6740
7584
|
bin,
|
|
@@ -6917,6 +7761,18 @@ class Runner {
|
|
|
6917
7761
|
this.answerPermission(run.id, requestId, true);
|
|
6918
7762
|
return;
|
|
6919
7763
|
}
|
|
7764
|
+
if (decision.action === "rewrite") {
|
|
7765
|
+
const second = this.store.evaluateTool(tool, { command: decision.command }, run.sessionId, run.worktree, false).decision;
|
|
7766
|
+
if (second.action === "deny" || second.action === "ask") {
|
|
7767
|
+
this.answerPermission(run.id, requestId, false, `[swarm] ${second.reason}`);
|
|
7768
|
+
return;
|
|
7769
|
+
}
|
|
7770
|
+
this.answerPermission(run.id, requestId, true, undefined, {
|
|
7771
|
+
...input,
|
|
7772
|
+
command: decision.command
|
|
7773
|
+
});
|
|
7774
|
+
return;
|
|
7775
|
+
}
|
|
6920
7776
|
run.pending.push({
|
|
6921
7777
|
requestId,
|
|
6922
7778
|
tool,
|
|
@@ -6941,7 +7797,7 @@ class Runner {
|
|
|
6941
7797
|
});
|
|
6942
7798
|
this.store.touch();
|
|
6943
7799
|
}
|
|
6944
|
-
answerPermission(runId, requestId, allow, message) {
|
|
7800
|
+
answerPermission(runId, requestId, allow, message, updatedInput) {
|
|
6945
7801
|
const entry = this.live.get(runId);
|
|
6946
7802
|
if (!entry)
|
|
6947
7803
|
return { ok: false, reason: "no live run" };
|
|
@@ -6949,7 +7805,7 @@ class Runner {
|
|
|
6949
7805
|
if (!stdin || typeof stdin === "number")
|
|
6950
7806
|
return { ok: false, reason: "stdin not available" };
|
|
6951
7807
|
const pend = entry.run.pending.find((p) => p.requestId === requestId);
|
|
6952
|
-
const response = allow ? { behavior: "allow", updatedInput: pend?.input ?? {} } : { behavior: "deny", message: message ?? "Denied from the Swarm dashboard" };
|
|
7808
|
+
const response = allow ? { behavior: "allow", updatedInput: updatedInput ?? pend?.input ?? {} } : { behavior: "deny", message: message ?? "Denied from the Swarm dashboard" };
|
|
6953
7809
|
stdin.write(`${JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: requestId, response } })}
|
|
6954
7810
|
`);
|
|
6955
7811
|
stdin.flush();
|
|
@@ -7013,33 +7869,33 @@ import { Database } from "bun:sqlite";
|
|
|
7013
7869
|
import {
|
|
7014
7870
|
closeSync,
|
|
7015
7871
|
copyFileSync,
|
|
7016
|
-
existsSync as
|
|
7872
|
+
existsSync as existsSync7,
|
|
7017
7873
|
mkdirSync as mkdirSync4,
|
|
7018
7874
|
openSync as openSync3,
|
|
7019
7875
|
readdirSync,
|
|
7020
|
-
readFileSync as
|
|
7876
|
+
readFileSync as readFileSync4,
|
|
7021
7877
|
readSync,
|
|
7022
7878
|
realpathSync as realpathSync2,
|
|
7023
7879
|
renameSync,
|
|
7024
7880
|
rmSync as rmSync2,
|
|
7025
7881
|
statSync,
|
|
7026
7882
|
unlinkSync,
|
|
7027
|
-
writeFileSync as
|
|
7883
|
+
writeFileSync as writeFileSync3
|
|
7028
7884
|
} from "fs";
|
|
7029
7885
|
import { homedir as homedir3, hostname, tmpdir, userInfo } from "os";
|
|
7030
|
-
import { basename, dirname as dirname3, join as
|
|
7886
|
+
import { basename, dirname as dirname3, join as join9 } from "path";
|
|
7031
7887
|
|
|
7032
7888
|
// packages/daemon/src/bootstrap.ts
|
|
7033
|
-
import { cpSync, existsSync as
|
|
7034
|
-
import { dirname as dirname2, join as
|
|
7889
|
+
import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, openSync as openSync2 } from "fs";
|
|
7890
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
7035
7891
|
function runBootstrap(plan, opts) {
|
|
7036
|
-
const logDir =
|
|
7892
|
+
const logDir = join8(opts.home, "logs", opts.projectId);
|
|
7037
7893
|
mkdirSync3(logDir, { recursive: true });
|
|
7038
|
-
const log =
|
|
7894
|
+
const log = join8(logDir, `bootstrap-${opts.task.replace(/[^a-zA-Z0-9_.-]+/g, "-")}.log`);
|
|
7039
7895
|
const copied = [];
|
|
7040
7896
|
const skipped = [];
|
|
7041
7897
|
for (const c of plan.copies) {
|
|
7042
|
-
if (!
|
|
7898
|
+
if (!existsSync6(c.from)) {
|
|
7043
7899
|
skipped.push(c.rel);
|
|
7044
7900
|
continue;
|
|
7045
7901
|
}
|
|
@@ -7209,6 +8065,15 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
7209
8065
|
answer TEXT, answered_by TEXT, answered_at TEXT, delivered_at TEXT
|
|
7210
8066
|
);
|
|
7211
8067
|
CREATE INDEX IF NOT EXISTS messages_open ON messages(project_id, answered_at, delivered_at);
|
|
8068
|
+
-- the statusline asks per session after every assistant message; messages_open cannot serve that
|
|
8069
|
+
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, kind, answered_at);
|
|
8070
|
+
CREATE TABLE IF NOT EXISTS quota (
|
|
8071
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER, window TEXT, used_pct REAL, resets_at INTEGER,
|
|
8072
|
+
session_id TEXT, project_id TEXT
|
|
8073
|
+
);
|
|
8074
|
+
CREATE INDEX IF NOT EXISTS quota_window_at ON quota(window, at);
|
|
8075
|
+
-- the report reads a time range across every window; (window, at) cannot serve a bare at >= ?
|
|
8076
|
+
CREATE INDEX IF NOT EXISTS quota_at ON quota(at);
|
|
7212
8077
|
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
7213
8078
|
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, task TEXT, workflow TEXT,
|
|
7214
8079
|
step INTEGER, step_label TEXT, steps TEXT, state TEXT, detail TEXT, run_id TEXT,
|
|
@@ -7237,7 +8102,7 @@ class Store {
|
|
|
7237
8102
|
constructor(home = swarmHome()) {
|
|
7238
8103
|
mkdirSync4(home, { recursive: true });
|
|
7239
8104
|
this.home = home;
|
|
7240
|
-
this.db = new Database(
|
|
8105
|
+
this.db = new Database(join9(home, "swarm.db"));
|
|
7241
8106
|
this.loadPricing();
|
|
7242
8107
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size=268435456; PRAGMA cache_size=-32000;");
|
|
7243
8108
|
this.db.exec(SCHEMA);
|
|
@@ -7250,7 +8115,7 @@ class Store {
|
|
|
7250
8115
|
this.ensureColumn("messages", "to_kind", "TEXT");
|
|
7251
8116
|
this.ensureColumn("messages", "from_session", "TEXT");
|
|
7252
8117
|
this.migrate();
|
|
7253
|
-
this.migrateProjectsJson(
|
|
8118
|
+
this.migrateProjectsJson(join9(home, "projects.json"));
|
|
7254
8119
|
this.reconcileMovedProjects();
|
|
7255
8120
|
this.slimExistingEvents();
|
|
7256
8121
|
this.retypeNotificationIncidents();
|
|
@@ -7303,13 +8168,22 @@ class Store {
|
|
|
7303
8168
|
}
|
|
7304
8169
|
reconcileMovedProjects() {
|
|
7305
8170
|
const all = this.projects();
|
|
8171
|
+
const byId = new Map(all.map((p) => [p.id, p]));
|
|
7306
8172
|
for (const stale of all) {
|
|
7307
|
-
if (
|
|
8173
|
+
if (!byId.has(stale.id))
|
|
7308
8174
|
continue;
|
|
7309
|
-
|
|
8175
|
+
if (existsSync7(stale.root)) {
|
|
8176
|
+
const current = projectIdentity({ root: stale.root, commonDir: gitCommonDir(stale.root) });
|
|
8177
|
+
const live = current.id !== stale.id ? byId.get(current.id) : undefined;
|
|
8178
|
+
if (live && this.mergeProject(stale.id, live.id))
|
|
8179
|
+
byId.delete(stale.id);
|
|
8180
|
+
continue;
|
|
8181
|
+
}
|
|
8182
|
+
const live = all.filter((p) => p.id !== stale.id && p.name === stale.name && existsSync7(p.root));
|
|
7310
8183
|
if (live.length !== 1)
|
|
7311
8184
|
continue;
|
|
7312
|
-
this.mergeProject(stale.id, live[0].id)
|
|
8185
|
+
if (this.mergeProject(stale.id, live[0].id))
|
|
8186
|
+
byId.delete(stale.id);
|
|
7313
8187
|
}
|
|
7314
8188
|
}
|
|
7315
8189
|
mergeProject(from, into) {
|
|
@@ -7400,10 +8274,10 @@ class Store {
|
|
|
7400
8274
|
return actorFrom(owner, sessionId, { user: osUser(), runId });
|
|
7401
8275
|
}
|
|
7402
8276
|
migrateProjectsJson(file) {
|
|
7403
|
-
if (!
|
|
8277
|
+
if (!existsSync7(file))
|
|
7404
8278
|
return;
|
|
7405
8279
|
try {
|
|
7406
|
-
const list = JSON.parse(
|
|
8280
|
+
const list = JSON.parse(readFileSync4(file, "utf8"));
|
|
7407
8281
|
const ins = this.db.query("INSERT OR IGNORE INTO projects (id, root, common_dir, name, discovered, created_at) VALUES (?, ?, ?, ?, ?, ?)");
|
|
7408
8282
|
for (const p of list)
|
|
7409
8283
|
ins.run(p.id, p.root, p.commonDir, p.name, p.discovered ? 1 : 0, p.createdAt);
|
|
@@ -7415,7 +8289,7 @@ class Store {
|
|
|
7415
8289
|
const hit = this.topCache.get(cwd);
|
|
7416
8290
|
if (hit && Date.now() - hit.t < 1e4)
|
|
7417
8291
|
return hit.v;
|
|
7418
|
-
const v = cwd &&
|
|
8292
|
+
const v = cwd && existsSync7(cwd) ? gitToplevel(cwd) : null;
|
|
7419
8293
|
this.topCache.set(cwd, { v, t: Date.now() });
|
|
7420
8294
|
return v;
|
|
7421
8295
|
}
|
|
@@ -7621,7 +8495,7 @@ class Store {
|
|
|
7621
8495
|
}));
|
|
7622
8496
|
}
|
|
7623
8497
|
sessionContext(cwd) {
|
|
7624
|
-
if (!cwd || !
|
|
8498
|
+
if (!cwd || !existsSync7(cwd))
|
|
7625
8499
|
return null;
|
|
7626
8500
|
const toplevel = this.toplevel(cwd);
|
|
7627
8501
|
const project = this.resolveProject(cwd);
|
|
@@ -7749,6 +8623,7 @@ class Store {
|
|
|
7749
8623
|
payload: { id, task: q.task, answer: a, by, summary: `answer to #${id}: ${a.slice(0, 120)}` }
|
|
7750
8624
|
});
|
|
7751
8625
|
this.touch();
|
|
8626
|
+
this.wakeAll();
|
|
7752
8627
|
return { ok: true, question: this.question(id) };
|
|
7753
8628
|
}
|
|
7754
8629
|
inbox(sessionId, opts = {}) {
|
|
@@ -7857,6 +8732,7 @@ class Store {
|
|
|
7857
8732
|
summary: `message to ${String(input.to)}: ${v.text.slice(0, 120)}`
|
|
7858
8733
|
}
|
|
7859
8734
|
});
|
|
8735
|
+
this.wakeAll();
|
|
7860
8736
|
return { ok: true, message };
|
|
7861
8737
|
}
|
|
7862
8738
|
message(id) {
|
|
@@ -7898,48 +8774,334 @@ class Store {
|
|
|
7898
8774
|
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));
|
|
7899
8775
|
return ms;
|
|
7900
8776
|
}
|
|
7901
|
-
markMessageDelivered(id, sessionId) {
|
|
7902
|
-
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);
|
|
8777
|
+
markMessageDelivered(id, sessionId) {
|
|
8778
|
+
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);
|
|
8779
|
+
}
|
|
8780
|
+
leadSession(projectId) {
|
|
8781
|
+
const r = this.db.query("SELECT id FROM sessions WHERE project_id = ? AND kind = 'interactive' AND state != 'ended' ORDER BY last_seen_at DESC LIMIT 1").get(projectId);
|
|
8782
|
+
return r?.id ?? null;
|
|
8783
|
+
}
|
|
8784
|
+
sessionByPrefix(prefix) {
|
|
8785
|
+
const rows = this.db.query("SELECT id FROM sessions WHERE id LIKE ? ORDER BY last_seen_at DESC LIMIT 2").all(`${prefix}%`);
|
|
8786
|
+
return rows.length === 1 ? rows[0]?.id ?? null : null;
|
|
8787
|
+
}
|
|
8788
|
+
sessionForTask(projectId, task) {
|
|
8789
|
+
const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
|
|
8790
|
+
if (!claim?.worktree)
|
|
8791
|
+
return null;
|
|
8792
|
+
const rows = this.db.query("SELECT id, cwd FROM sessions WHERE project_id = ? AND state != 'ended' ORDER BY last_seen_at DESC").all(projectId);
|
|
8793
|
+
return rows.find((r) => isInside(r.cwd, claim.worktree))?.id ?? null;
|
|
8794
|
+
}
|
|
8795
|
+
questionContext(task, projectId) {
|
|
8796
|
+
if (!task)
|
|
8797
|
+
return null;
|
|
8798
|
+
const qs = this.db.query("SELECT * FROM messages WHERE kind = 'question' AND project_id = ? AND task = ? AND (answered_at IS NULL OR delivered_at IS NULL) ORDER BY id").all(projectId, task);
|
|
8799
|
+
const list = qs.map((r) => this.rowToQuestion(r));
|
|
8800
|
+
const parts = [formatAnswers(list), formatOpenQuestions(list)].filter(Boolean);
|
|
8801
|
+
if (list.some((q) => q.answer !== null))
|
|
8802
|
+
this.db.query("UPDATE messages SET delivered_at = ? WHERE kind = 'question' AND project_id = ? AND task = ? AND answered_at IS NOT NULL AND delivered_at IS NULL").run(new Date().toISOString(), projectId, task);
|
|
8803
|
+
return parts.length ? parts.join(`
|
|
8804
|
+
`) : null;
|
|
8805
|
+
}
|
|
8806
|
+
contextFor(cwd, sessionId) {
|
|
8807
|
+
const parts = [];
|
|
8808
|
+
const base = this.sessionContext(cwd);
|
|
8809
|
+
if (base)
|
|
8810
|
+
parts.push(base);
|
|
8811
|
+
const answers = this.answerContext(sessionId);
|
|
8812
|
+
if (answers)
|
|
8813
|
+
parts.push(answers);
|
|
8814
|
+
const open = formatOpenQuestions(this.questions({ sessionId: sessionId ?? undefined, open: true }));
|
|
8815
|
+
if (open && !base?.includes(open))
|
|
8816
|
+
parts.push(open);
|
|
8817
|
+
return { text: parts.length ? parts.join(`
|
|
8818
|
+
`) : null, parts };
|
|
8819
|
+
}
|
|
8820
|
+
dashboardSeenAt = 0;
|
|
8821
|
+
touchDashboard() {
|
|
8822
|
+
this.dashboardSeenAt = Date.now();
|
|
8823
|
+
}
|
|
8824
|
+
dashboardWatching(withinMs = 12000) {
|
|
8825
|
+
return Date.now() - this.dashboardSeenAt < withinMs;
|
|
8826
|
+
}
|
|
8827
|
+
interactive = new Map;
|
|
8828
|
+
interactiveSeq = 0;
|
|
8829
|
+
pendingPermissions() {
|
|
8830
|
+
return [...this.interactive.values()].map((x) => x.p);
|
|
8831
|
+
}
|
|
8832
|
+
askInteractive(raw, waitMs) {
|
|
8833
|
+
const sessionId = typeof raw.session_id === "string" ? raw.session_id : "";
|
|
8834
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
8835
|
+
const tool = typeof raw.tool_name === "string" ? raw.tool_name : "tool";
|
|
8836
|
+
const input = raw.tool_input ?? {};
|
|
8837
|
+
const id = typeof raw.tool_use_id === "string" && raw.tool_use_id ? raw.tool_use_id : `perm_${++this.interactiveSeq}`;
|
|
8838
|
+
const project = cwd && existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
8839
|
+
const verdict = this.evaluateTool(tool, input, sessionId, cwd, false).decision;
|
|
8840
|
+
const rule = verdict.action === "ask" || verdict.action === "deny" ? verdict.rule : null;
|
|
8841
|
+
const now = Date.now();
|
|
8842
|
+
const p = {
|
|
8843
|
+
id,
|
|
8844
|
+
sessionId,
|
|
8845
|
+
projectId: project?.id ?? null,
|
|
8846
|
+
tool,
|
|
8847
|
+
display: summarizeToolInput(tool, input),
|
|
8848
|
+
input,
|
|
8849
|
+
reason: verdict.action === "ask" || verdict.action === "deny" ? verdict.reason : permissionReason(raw),
|
|
8850
|
+
rule,
|
|
8851
|
+
askedAt: new Date(now).toISOString(),
|
|
8852
|
+
terminalAt: new Date(now + waitMs).toISOString()
|
|
8853
|
+
};
|
|
8854
|
+
return new Promise((resolve) => {
|
|
8855
|
+
const done = (a) => {
|
|
8856
|
+
const cur = this.interactive.get(id);
|
|
8857
|
+
if (!cur || cur.resolve !== done)
|
|
8858
|
+
return;
|
|
8859
|
+
clearTimeout(cur.timer);
|
|
8860
|
+
this.interactive.delete(id);
|
|
8861
|
+
this.append({
|
|
8862
|
+
ts: new Date().toISOString(),
|
|
8863
|
+
type: "permission.resolved",
|
|
8864
|
+
projectId: p.projectId ?? "p_unknown",
|
|
8865
|
+
sessionId: sessionId || null,
|
|
8866
|
+
payload: {
|
|
8867
|
+
requestId: id,
|
|
8868
|
+
tool,
|
|
8869
|
+
display: p.display,
|
|
8870
|
+
decision: a?.behavior ?? "terminal",
|
|
8871
|
+
by: a?.by ?? "terminal",
|
|
8872
|
+
source: "interactive",
|
|
8873
|
+
summary: `${tool} ${a?.behavior ?? "handed to the terminal"} (${a?.by ?? "no answer in time"})`
|
|
8874
|
+
}
|
|
8875
|
+
});
|
|
8876
|
+
this.touch();
|
|
8877
|
+
resolve(a);
|
|
8878
|
+
};
|
|
8879
|
+
const timer = setTimeout(() => done(null), waitMs);
|
|
8880
|
+
this.interactive.set(id, { p, resolve: done, timer });
|
|
8881
|
+
this.touch();
|
|
8882
|
+
});
|
|
8883
|
+
}
|
|
8884
|
+
answerInteractive(id, a) {
|
|
8885
|
+
const cur = this.interactive.get(id);
|
|
8886
|
+
if (!cur)
|
|
8887
|
+
return {
|
|
8888
|
+
ok: false,
|
|
8889
|
+
reason: "no such pending permission (answered, or the terminal took over)"
|
|
8890
|
+
};
|
|
8891
|
+
cur.resolve(a);
|
|
8892
|
+
return { ok: true };
|
|
8893
|
+
}
|
|
8894
|
+
recentEdits = new Map;
|
|
8895
|
+
collisionWarned = new Map;
|
|
8896
|
+
static EDIT_CAP = 5000;
|
|
8897
|
+
lastEditPrune = 0;
|
|
8898
|
+
noteEdit(sessionId, path, at = Date.now()) {
|
|
8899
|
+
const m = this.recentEdits.get(path) ?? new Map;
|
|
8900
|
+
m.set(sessionId, at);
|
|
8901
|
+
this.recentEdits.delete(path);
|
|
8902
|
+
this.recentEdits.set(path, m);
|
|
8903
|
+
if (this.recentEdits.size > Store.EDIT_CAP && at - this.lastEditPrune > 60000)
|
|
8904
|
+
this.pruneEdits(at);
|
|
8905
|
+
}
|
|
8906
|
+
pruneEdits(now) {
|
|
8907
|
+
this.lastEditPrune = now;
|
|
8908
|
+
const keep = 4 * 60 * 60000;
|
|
8909
|
+
for (const [path, m] of this.recentEdits) {
|
|
8910
|
+
for (const [sid, at] of m)
|
|
8911
|
+
if (now - at > keep)
|
|
8912
|
+
m.delete(sid);
|
|
8913
|
+
if (!m.size)
|
|
8914
|
+
this.recentEdits.delete(path);
|
|
8915
|
+
}
|
|
8916
|
+
for (const [k, at] of this.collisionWarned)
|
|
8917
|
+
if (now - at > keep)
|
|
8918
|
+
this.collisionWarned.delete(k);
|
|
8919
|
+
for (const path of this.recentEdits.keys()) {
|
|
8920
|
+
if (this.recentEdits.size <= Store.EDIT_CAP)
|
|
8921
|
+
break;
|
|
8922
|
+
this.recentEdits.delete(path);
|
|
8923
|
+
}
|
|
8924
|
+
}
|
|
8925
|
+
collisionContext(sessionId, cwd, path) {
|
|
8926
|
+
const modes = this.rulesFor(this.toplevel(cwd));
|
|
8927
|
+
if (modes.collision_context === false)
|
|
8928
|
+
return null;
|
|
8929
|
+
const windowMs = (modes.collision_window ?? DEFAULT_COLLISION_WINDOW_MIN) * 60000;
|
|
8930
|
+
const now = Date.now();
|
|
8931
|
+
const edits = [...this.recentEdits.get(path) ?? []].map(([sid, at]) => ({
|
|
8932
|
+
sessionId: sid,
|
|
8933
|
+
at
|
|
8934
|
+
}));
|
|
8935
|
+
if (edits.length < 2)
|
|
8936
|
+
return null;
|
|
8937
|
+
const cutoff = new Date(now - LIVE_WINDOW_MS - 1e4).toISOString();
|
|
8938
|
+
const rows = this.db.query("SELECT id, cwd, branch, title FROM sessions WHERE state != 'ended' AND last_seen_at > ? AND id != ?").all(cutoff, sessionId);
|
|
8939
|
+
const held = this.heldClaimsWithWorktree();
|
|
8940
|
+
const live = new Map(rows.map((r) => [
|
|
8941
|
+
r.id,
|
|
8942
|
+
{
|
|
8943
|
+
sessionId: r.id,
|
|
8944
|
+
task: r.cwd ? held.find((c) => isInside(r.cwd, c.worktree))?.task ?? null : null,
|
|
8945
|
+
branch: r.branch,
|
|
8946
|
+
title: r.title
|
|
8947
|
+
}
|
|
8948
|
+
]));
|
|
8949
|
+
const w = collisionWarning(path, sessionId, edits, live, now, windowMs);
|
|
8950
|
+
if (!w)
|
|
8951
|
+
return null;
|
|
8952
|
+
const fresh = w.others.filter((o) => {
|
|
8953
|
+
const k = `${sessionId}|${o.sessionId}|${path}`;
|
|
8954
|
+
const at = this.collisionWarned.get(k);
|
|
8955
|
+
if (at && now - at < windowMs)
|
|
8956
|
+
return false;
|
|
8957
|
+
this.collisionWarned.set(k, now);
|
|
8958
|
+
return true;
|
|
8959
|
+
});
|
|
8960
|
+
if (!fresh.length)
|
|
8961
|
+
return null;
|
|
8962
|
+
const project = cwd && existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
8963
|
+
this.append({
|
|
8964
|
+
ts: new Date(now).toISOString(),
|
|
8965
|
+
type: "collision.warned",
|
|
8966
|
+
projectId: project?.id ?? "p_unknown",
|
|
8967
|
+
sessionId,
|
|
8968
|
+
payload: {
|
|
8969
|
+
path,
|
|
8970
|
+
others: fresh.map((o) => ({ sessionId: o.sessionId, task: o.task, branch: o.branch })),
|
|
8971
|
+
summary: `also edited by ${fresh.map((o) => o.title ?? o.sessionId.slice(0, 8)).join(", ")}: ${path}`
|
|
8972
|
+
}
|
|
8973
|
+
});
|
|
8974
|
+
return w.text;
|
|
8975
|
+
}
|
|
8976
|
+
wakers = new Map;
|
|
8977
|
+
waitForWake(sessionId, maxMs) {
|
|
8978
|
+
this.wakers.get(sessionId)?.resolve({ wake: false, reason: "replaced" });
|
|
8979
|
+
if (!this.policyFor(null).config.messages.wake)
|
|
8980
|
+
return Promise.resolve({ wake: false, reason: "off" });
|
|
8981
|
+
const already = this.answerContext(sessionId);
|
|
8982
|
+
if (already)
|
|
8983
|
+
return Promise.resolve({ wake: true, text: already });
|
|
8984
|
+
return new Promise((resolve) => {
|
|
8985
|
+
const done = (r) => {
|
|
8986
|
+
const cur = this.wakers.get(sessionId);
|
|
8987
|
+
if (cur?.resolve !== done)
|
|
8988
|
+
return;
|
|
8989
|
+
clearTimeout(cur.timer);
|
|
8990
|
+
this.wakers.delete(sessionId);
|
|
8991
|
+
resolve(r);
|
|
8992
|
+
};
|
|
8993
|
+
const timer = setTimeout(() => done({ wake: false, reason: "timeout" }), maxMs);
|
|
8994
|
+
this.wakers.set(sessionId, { resolve: done, timer });
|
|
8995
|
+
});
|
|
8996
|
+
}
|
|
8997
|
+
wakeAll() {
|
|
8998
|
+
for (const [sid, w] of [...this.wakers]) {
|
|
8999
|
+
if (!this.hasPending(sid))
|
|
9000
|
+
continue;
|
|
9001
|
+
const text = this.answerContext(sid);
|
|
9002
|
+
if (text) {
|
|
9003
|
+
w.resolve({ wake: true, text });
|
|
9004
|
+
this.append({
|
|
9005
|
+
ts: new Date().toISOString(),
|
|
9006
|
+
type: "message.delivered",
|
|
9007
|
+
projectId: this.sessionProject(sid) ?? "p_unknown",
|
|
9008
|
+
sessionId: sid,
|
|
9009
|
+
payload: { by: "wake", summary: "woke the session with its inbox" }
|
|
9010
|
+
});
|
|
9011
|
+
}
|
|
9012
|
+
}
|
|
9013
|
+
}
|
|
9014
|
+
hasPending(sessionId) {
|
|
9015
|
+
return this.inbox(sessionId, { peek: true }).length > 0 || this.messageInbox(sessionId, { peek: true }).length > 0;
|
|
7903
9016
|
}
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
return r?.id ?? null;
|
|
9017
|
+
cancelWake(sessionId, reason) {
|
|
9018
|
+
this.wakers.get(sessionId)?.resolve({ wake: false, reason });
|
|
7907
9019
|
}
|
|
7908
|
-
|
|
7909
|
-
const
|
|
7910
|
-
return
|
|
9020
|
+
sessionProject(sessionId) {
|
|
9021
|
+
const r = this.db.query("SELECT project_id FROM sessions WHERE id = ?").get(sessionId);
|
|
9022
|
+
return r?.project_id ?? null;
|
|
7911
9023
|
}
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
9024
|
+
statuslines = new Map;
|
|
9025
|
+
statuslineFor(payload) {
|
|
9026
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : null;
|
|
9027
|
+
const cwd = typeof payload.cwd === "string" ? payload.cwd : payload.workspace?.current_dir ?? "";
|
|
9028
|
+
if (sessionId)
|
|
9029
|
+
this.statuslines.set(sessionId, { at: Date.now(), payload });
|
|
9030
|
+
const held = cwd ? this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree)) : null;
|
|
9031
|
+
const project = held ? this.project(held.projectId) : cwd && existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
9032
|
+
const budget = project ? this.budgetFor(project.id) : null;
|
|
9033
|
+
this.recordQuota(payload, sessionId, project?.id ?? null);
|
|
9034
|
+
const tight = tightestWindow(this.quota());
|
|
9035
|
+
return {
|
|
9036
|
+
quota: tight ? { window: tight.window, hoursToLimit: tight.hoursToLimit } : null,
|
|
9037
|
+
task: held ? {
|
|
9038
|
+
id: held.task,
|
|
9039
|
+
leftMin: Math.max(0, Math.round((new Date(held.expiresAt).getTime() - Date.now()) / 60000))
|
|
9040
|
+
} : null,
|
|
9041
|
+
budget: budget?.status.limit != null ? { level: budget.status.level, pct: budget.status.pct } : null,
|
|
9042
|
+
incidents: project ? this.openIncidents(project.id) : 0,
|
|
9043
|
+
waitingOn: sessionId ? this.questions({ sessionId, open: true }).length : 0,
|
|
9044
|
+
inbox: sessionId ? this.inbox(sessionId, { peek: true }).length : 0
|
|
9045
|
+
};
|
|
7918
9046
|
}
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
9047
|
+
recordQuota(payload, sessionId, projectId) {
|
|
9048
|
+
const now = Date.now();
|
|
9049
|
+
for (const q of quotaSamples(payload, now)) {
|
|
9050
|
+
const last = this.db.query("SELECT at, used_pct, resets_at FROM quota WHERE window = ? ORDER BY at DESC LIMIT 1").get(q.window);
|
|
9051
|
+
const moved = !last || Math.round(last.used_pct * 10) !== Math.round(q.usedPct * 10);
|
|
9052
|
+
const reset = !last || last.resets_at !== q.resetsAt;
|
|
9053
|
+
if (last && !reset && !moved && now - last.at < BURN_MIN_SPAN_MS)
|
|
9054
|
+
continue;
|
|
9055
|
+
this.db.query("INSERT INTO quota (at, window, used_pct, resets_at, session_id, project_id) VALUES (?, ?, ?, ?, ?, ?)").run(now, q.window, q.usedPct, q.resetsAt, sessionId, projectId);
|
|
9056
|
+
this.quotaDirty = true;
|
|
9057
|
+
this.quotaMemo = null;
|
|
9058
|
+
}
|
|
7929
9059
|
}
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
9060
|
+
quotaDirty = true;
|
|
9061
|
+
quotaMemo = null;
|
|
9062
|
+
quota() {
|
|
9063
|
+
const now = Date.now();
|
|
9064
|
+
if (!this.quotaDirty && this.quotaMemo && now - this.quotaMemo.at < 30000)
|
|
9065
|
+
return this.quotaMemo.report;
|
|
9066
|
+
const rows = this.db.query("SELECT at, window, used_pct, resets_at FROM quota WHERE at >= ? ORDER BY at").all(now - 8 * 86400000);
|
|
9067
|
+
const report = quotaReport(rows.map((r) => ({ window: r.window, usedPct: r.used_pct, resetsAt: r.resets_at, at: r.at })), now, this.policyFor(null).config.budget.window_warn_at);
|
|
9068
|
+
this.quotaMemo = { at: now, report };
|
|
9069
|
+
this.quotaDirty = false;
|
|
9070
|
+
return report;
|
|
9071
|
+
}
|
|
9072
|
+
quotaNotified = new Map;
|
|
9073
|
+
checkQuota() {
|
|
9074
|
+
const out = [];
|
|
9075
|
+
for (const w of this.quota().windows) {
|
|
9076
|
+
if (w.level === "ok")
|
|
9077
|
+
continue;
|
|
9078
|
+
out.push(w);
|
|
9079
|
+
const key = `${w.resetsAt ?? "none"}:${w.level}`;
|
|
9080
|
+
if (this.quotaNotified.get(w.window) === key)
|
|
9081
|
+
continue;
|
|
9082
|
+
this.quotaNotified.set(w.window, key);
|
|
9083
|
+
const src = this.db.query("SELECT project_id FROM quota WHERE window = ? ORDER BY at DESC LIMIT 1").get(w.window);
|
|
9084
|
+
const projectId = src?.project_id ?? this.projects()[0]?.id ?? null;
|
|
9085
|
+
if (!projectId)
|
|
9086
|
+
continue;
|
|
9087
|
+
this.append({
|
|
9088
|
+
ts: new Date().toISOString(),
|
|
9089
|
+
type: "incident.opened",
|
|
9090
|
+
projectId,
|
|
9091
|
+
sessionId: null,
|
|
9092
|
+
payload: {
|
|
9093
|
+
rule: "budget",
|
|
9094
|
+
action: "warn",
|
|
9095
|
+
command: `${QUOTA_LABEL[w.window]} (plan quota)`,
|
|
9096
|
+
reason: w.level === "exceeded" ? `${quotaMessage(w)}. Sessions on this plan will be rate-limited; API-key sessions are unaffected.` : `${quotaMessage(w)} \u2014 approaching the plan limit ([budget] window_warn_at)`
|
|
9097
|
+
}
|
|
9098
|
+
});
|
|
9099
|
+
this.touch();
|
|
9100
|
+
}
|
|
9101
|
+
return out;
|
|
9102
|
+
}
|
|
9103
|
+
lastStatusline(sessionId) {
|
|
9104
|
+
return this.statuslines.get(sessionId) ?? null;
|
|
7943
9105
|
}
|
|
7944
9106
|
rowToGate(r) {
|
|
7945
9107
|
return {
|
|
@@ -7992,24 +9154,24 @@ class Store {
|
|
|
7992
9154
|
};
|
|
7993
9155
|
const claim = this.claims(projectId).find((c) => c.task === task && c.state === "held");
|
|
7994
9156
|
const worktree = claim?.worktree;
|
|
7995
|
-
if (!worktree || !
|
|
9157
|
+
if (!worktree || !existsSync7(worktree))
|
|
7996
9158
|
return {
|
|
7997
9159
|
ok: false,
|
|
7998
9160
|
reason: `${task} has no held worktree to run ${gate} in \u2014 claim it first`
|
|
7999
9161
|
};
|
|
8000
|
-
const cwd = def.cwd ?
|
|
8001
|
-
if (!
|
|
9162
|
+
const cwd = def.cwd ? join9(worktree, def.cwd) : worktree;
|
|
9163
|
+
if (!existsSync7(cwd))
|
|
8002
9164
|
return { ok: false, reason: `gate cwd ${cwd} does not exist` };
|
|
8003
9165
|
const key = `${projectId}:${task}:${gate}`;
|
|
8004
9166
|
if (this.gateJobs.has(key))
|
|
8005
9167
|
return { ok: false, reason: `${gate} is already running on ${task}` };
|
|
8006
9168
|
const slug = (x) => x.replace(/[^a-zA-Z0-9_.-]+/g, "-");
|
|
8007
|
-
const logDir =
|
|
9169
|
+
const logDir = join9(this.home, "logs", projectId);
|
|
8008
9170
|
mkdirSync4(logDir, { recursive: true });
|
|
8009
|
-
const log =
|
|
9171
|
+
const log = join9(logDir, `gate-${slug(task)}-${slug(gate)}.log`);
|
|
8010
9172
|
if (def.builtin === "review")
|
|
8011
9173
|
return this.runReviewGate(projectId, task, gate, def, { worktree, cwd, key, log }, opts);
|
|
8012
|
-
|
|
9174
|
+
writeFileSync3(log, `$ ${def.cmd}
|
|
8013
9175
|
# cwd ${cwd} \xB7 ${new Date().toISOString()}
|
|
8014
9176
|
`);
|
|
8015
9177
|
const fd = openSync3(log, "a");
|
|
@@ -8069,7 +9231,7 @@ class Store {
|
|
|
8069
9231
|
closeSync(fd);
|
|
8070
9232
|
let output = "";
|
|
8071
9233
|
try {
|
|
8072
|
-
output =
|
|
9234
|
+
output = readFileSync4(log, "utf8");
|
|
8073
9235
|
} catch {}
|
|
8074
9236
|
const input = executedGateInput(task, gate, def.cmd, {
|
|
8075
9237
|
exitCode: timedOut ? null : code,
|
|
@@ -8130,7 +9292,7 @@ class Store {
|
|
|
8130
9292
|
stat,
|
|
8131
9293
|
patch: diffText
|
|
8132
9294
|
});
|
|
8133
|
-
|
|
9295
|
+
writeFileSync3(where.log, `$ claude -p <review prompt, ${prompt.length} chars> --output-format json (read-only)
|
|
8134
9296
|
# cwd ${where.cwd} \xB7 ${new Date().toISOString()}
|
|
8135
9297
|
`);
|
|
8136
9298
|
let proc;
|
|
@@ -8185,7 +9347,7 @@ class Store {
|
|
|
8185
9347
|
const code = await proc.exited;
|
|
8186
9348
|
clearTimeout(timer);
|
|
8187
9349
|
try {
|
|
8188
|
-
|
|
9350
|
+
writeFileSync3(where.log, `${readFileSync4(where.log, "utf8")}${out}
|
|
8189
9351
|
${err}
|
|
8190
9352
|
# exit ${timedOut ? "timeout" : code} \xB7 ${((Date.now() - started) / 1000).toFixed(0)}s
|
|
8191
9353
|
`);
|
|
@@ -8258,6 +9420,8 @@ ${err}
|
|
|
8258
9420
|
return;
|
|
8259
9421
|
if (cfg.auto === "session-end" && event !== "SessionEnd")
|
|
8260
9422
|
return;
|
|
9423
|
+
if (event === "Stop" && cfg.on_stop === "block")
|
|
9424
|
+
return;
|
|
8261
9425
|
if (!cfg.required.some((g) => cfg.defs[g]))
|
|
8262
9426
|
return;
|
|
8263
9427
|
const key = `${held.projectId}:${held.task}`;
|
|
@@ -8265,13 +9429,119 @@ ${err}
|
|
|
8265
9429
|
if (event === "Stop" && now - (this.autoGateAt.get(key) ?? 0) < 120000)
|
|
8266
9430
|
return;
|
|
8267
9431
|
this.autoGateAt.set(key, now);
|
|
8268
|
-
this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) =>
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
9432
|
+
this.runGates(held.projectId, held.task, undefined, { sessionId, owner: "auto" }).then((r) => this.writeAutoVerify(held.projectId, held.task, sessionId, r.runs));
|
|
9433
|
+
}
|
|
9434
|
+
writeAutoVerify(projectId, task, sessionId, runs) {
|
|
9435
|
+
if (!runs.length)
|
|
9436
|
+
return;
|
|
9437
|
+
const line = runs.map((x) => `${x.gate} ${x.verdict === "pass" ? "\u2713" : "\u2717"} (${x.rubric})`).join("; ");
|
|
9438
|
+
this.db.query("UPDATE handoffs SET verify = ? WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(`auto-gates: ${line}`, projectId, task, sessionId);
|
|
9439
|
+
this.touch();
|
|
9440
|
+
}
|
|
9441
|
+
stopBlocks(sessionId) {
|
|
9442
|
+
const r = this.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'gate.blocked' AND session_id = ?").get(sessionId);
|
|
9443
|
+
return r.n;
|
|
9444
|
+
}
|
|
9445
|
+
stopExhausted = new Set;
|
|
9446
|
+
repairArmed(cwd) {
|
|
9447
|
+
if (!cwd || !existsSync7(cwd))
|
|
9448
|
+
return false;
|
|
9449
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
9450
|
+
if (!held)
|
|
9451
|
+
return false;
|
|
9452
|
+
const cfg = this.gateDefs(held.projectId);
|
|
9453
|
+
return cfg?.on_stop === "block" && cfg.max_blocks > 0 && cfg.required.some((g) => cfg.defs[g]);
|
|
9454
|
+
}
|
|
9455
|
+
async stopDecision(sessionId, cwd) {
|
|
9456
|
+
if (!cwd || !existsSync7(cwd))
|
|
9457
|
+
return null;
|
|
9458
|
+
const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
|
|
9459
|
+
if (!held)
|
|
9460
|
+
return null;
|
|
9461
|
+
const cfg = this.gateDefs(held.projectId);
|
|
9462
|
+
if (cfg?.on_stop !== "block" || cfg.max_blocks <= 0)
|
|
9463
|
+
return null;
|
|
9464
|
+
const executable = cfg.required.filter((g) => cfg.defs[g]);
|
|
9465
|
+
if (!executable.length)
|
|
9466
|
+
return null;
|
|
9467
|
+
const blocksSoFar = this.stopBlocks(sessionId);
|
|
9468
|
+
if (blocksSoFar >= cfg.max_blocks) {
|
|
9469
|
+
if (!this.stopExhausted.has(sessionId)) {
|
|
9470
|
+
this.stopExhausted.add(sessionId);
|
|
9471
|
+
const failed = this.gateStatusFor(this.gateRuns(held.projectId, held.task), executable).filter((g) => g.verdict !== "pass").map((g) => g.gate);
|
|
9472
|
+
this.append({
|
|
9473
|
+
ts: new Date().toISOString(),
|
|
9474
|
+
type: "incident.opened",
|
|
9475
|
+
projectId: held.projectId,
|
|
9476
|
+
sessionId,
|
|
9477
|
+
payload: {
|
|
9478
|
+
rule: "gate_failed",
|
|
9479
|
+
action: "warn",
|
|
9480
|
+
command: `stop on ${held.task}`,
|
|
9481
|
+
reason: `${failed.join(", ") || "a required gate"} still failing after ${cfg.max_blocks} refusal${cfg.max_blocks === 1 ? "" : "s"} \u2014 letting the session stop. The runs are on the Board.`,
|
|
9482
|
+
task: held.task,
|
|
9483
|
+
gates: failed
|
|
9484
|
+
}
|
|
9485
|
+
});
|
|
9486
|
+
}
|
|
9487
|
+
return null;
|
|
9488
|
+
}
|
|
9489
|
+
const batch = this.runGates(held.projectId, held.task, undefined, {
|
|
9490
|
+
sessionId,
|
|
9491
|
+
owner: "stop"
|
|
9492
|
+
});
|
|
9493
|
+
let timer;
|
|
9494
|
+
const timeout = new Promise((resolve) => {
|
|
9495
|
+
timer = setTimeout(() => resolve(null), cfg.stop_timeout * 1000);
|
|
9496
|
+
});
|
|
9497
|
+
const r = await Promise.race([batch, timeout]).finally(() => clearTimeout(timer));
|
|
9498
|
+
if (!r)
|
|
9499
|
+
return null;
|
|
9500
|
+
this.writeAutoVerify(held.projectId, held.task, sessionId, r.runs);
|
|
9501
|
+
const d = repairDecision({
|
|
9502
|
+
onStop: cfg.on_stop,
|
|
9503
|
+
blocksSoFar,
|
|
9504
|
+
maxBlocks: cfg.max_blocks,
|
|
9505
|
+
runs: r.runs,
|
|
9506
|
+
unexecutable: cfg.required.filter((g) => !cfg.defs[g])
|
|
9507
|
+
});
|
|
9508
|
+
if (d.kind === "allow")
|
|
9509
|
+
return null;
|
|
9510
|
+
const failed = d.failed.map((x) => x.gate);
|
|
9511
|
+
if (d.kind === "exhausted") {
|
|
9512
|
+
if (!this.stopExhausted.has(sessionId)) {
|
|
9513
|
+
this.stopExhausted.add(sessionId);
|
|
9514
|
+
this.append({
|
|
9515
|
+
ts: new Date().toISOString(),
|
|
9516
|
+
type: "incident.opened",
|
|
9517
|
+
projectId: held.projectId,
|
|
9518
|
+
sessionId,
|
|
9519
|
+
payload: {
|
|
9520
|
+
rule: "gate_failed",
|
|
9521
|
+
action: "warn",
|
|
9522
|
+
command: `stop on ${held.task}`,
|
|
9523
|
+
reason: `${d.reason}. The session stopped with ${failed.join(", ")} failing; the runs are on the Board.`,
|
|
9524
|
+
task: held.task,
|
|
9525
|
+
gates: failed
|
|
9526
|
+
}
|
|
9527
|
+
});
|
|
9528
|
+
}
|
|
9529
|
+
return null;
|
|
9530
|
+
}
|
|
9531
|
+
this.append({
|
|
9532
|
+
ts: new Date().toISOString(),
|
|
9533
|
+
type: "gate.blocked",
|
|
9534
|
+
projectId: held.projectId,
|
|
9535
|
+
sessionId,
|
|
9536
|
+
payload: {
|
|
9537
|
+
task: held.task,
|
|
9538
|
+
gates: failed,
|
|
9539
|
+
attempt: d.attempt,
|
|
9540
|
+
maxBlocks: cfg.max_blocks,
|
|
9541
|
+
summary: `stop refused (${d.attempt}/${cfg.max_blocks}): ${failed.join(", ")} failing on ${held.task}`
|
|
9542
|
+
}
|
|
8274
9543
|
});
|
|
9544
|
+
return { decision: "block", reason: d.reason };
|
|
8275
9545
|
}
|
|
8276
9546
|
requiredGates(projectId) {
|
|
8277
9547
|
const p = this.project(projectId);
|
|
@@ -8340,8 +9610,8 @@ ${err}
|
|
|
8340
9610
|
error = e.error;
|
|
8341
9611
|
loading = e.at === 0 && e.error === null;
|
|
8342
9612
|
} else {
|
|
8343
|
-
const path =
|
|
8344
|
-
if (!
|
|
9613
|
+
const path = join9(p.root, source);
|
|
9614
|
+
if (!existsSync7(path))
|
|
8345
9615
|
return {
|
|
8346
9616
|
source,
|
|
8347
9617
|
required: this.requiredGates(projectId),
|
|
@@ -8352,7 +9622,7 @@ ${err}
|
|
|
8352
9622
|
const mtime = statSync(path).mtimeMs;
|
|
8353
9623
|
let md = this.taskCache.get(projectId);
|
|
8354
9624
|
if (!md || md.path !== path || md.mtime !== mtime) {
|
|
8355
|
-
md = { path, mtime, tasks: parseMarkdownTasks(
|
|
9625
|
+
md = { path, mtime, tasks: parseMarkdownTasks(readFileSync4(path, "utf8")) };
|
|
8356
9626
|
this.taskCache.set(projectId, md);
|
|
8357
9627
|
}
|
|
8358
9628
|
hit = md;
|
|
@@ -8382,6 +9652,9 @@ ${err}
|
|
|
8382
9652
|
rulesFor(repoRoot) {
|
|
8383
9653
|
return this.policyFor(repoRoot).config.rules;
|
|
8384
9654
|
}
|
|
9655
|
+
invalidateConfig() {
|
|
9656
|
+
this.policyCache.clear();
|
|
9657
|
+
}
|
|
8385
9658
|
policyFor(repoRoot) {
|
|
8386
9659
|
const key = repoRoot ?? "";
|
|
8387
9660
|
const hit = this.policyCache.get(key);
|
|
@@ -8431,15 +9704,15 @@ ${err}
|
|
|
8431
9704
|
});
|
|
8432
9705
|
}
|
|
8433
9706
|
writePolicyCache(loaded) {
|
|
8434
|
-
const file =
|
|
9707
|
+
const file = join9(this.home, POLICY_CACHE_FILE);
|
|
8435
9708
|
try {
|
|
8436
9709
|
if (!hasLockedRules(loaded)) {
|
|
8437
|
-
if (
|
|
9710
|
+
if (existsSync7(file))
|
|
8438
9711
|
unlinkSync(file);
|
|
8439
9712
|
return;
|
|
8440
9713
|
}
|
|
8441
9714
|
const cache = buildPolicyCache(loaded, this.liveSessions(), this.heldWorktrees());
|
|
8442
|
-
|
|
9715
|
+
writeFileSync3(file, JSON.stringify(cache), { mode: 384 });
|
|
8443
9716
|
} catch (e) {
|
|
8444
9717
|
console.error(`swarm: policy cache: ${e.message}`);
|
|
8445
9718
|
}
|
|
@@ -8448,15 +9721,15 @@ ${err}
|
|
|
8448
9721
|
return process.env.SWARM_GUARD === "off" && !hasLockedRules(this.policyFor(repoRoot));
|
|
8449
9722
|
}
|
|
8450
9723
|
claudeSettings() {
|
|
8451
|
-
const p = process.env.CLAUDE_SETTINGS ??
|
|
9724
|
+
const p = process.env.CLAUDE_SETTINGS ?? join9(homedir3(), ".claude", "settings.json");
|
|
8452
9725
|
try {
|
|
8453
|
-
return
|
|
9726
|
+
return existsSync7(p) ? JSON.parse(readFileSync4(p, "utf8")) : null;
|
|
8454
9727
|
} catch {
|
|
8455
9728
|
return null;
|
|
8456
9729
|
}
|
|
8457
9730
|
}
|
|
8458
9731
|
checkPolicy(cwd, sessionId) {
|
|
8459
|
-
const project =
|
|
9732
|
+
const project = existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
8460
9733
|
const repoRoot = project?.root ?? null;
|
|
8461
9734
|
const loaded = this.policyFor(repoRoot);
|
|
8462
9735
|
const settings = this.claudeSettings();
|
|
@@ -8481,7 +9754,7 @@ ${err}
|
|
|
8481
9754
|
return findings;
|
|
8482
9755
|
}
|
|
8483
9756
|
evaluateTool(tool, input, sessionId, cwd, recordIncident = true) {
|
|
8484
|
-
if (BUDGET_ASK_TOOLS.has(tool) && cwd &&
|
|
9757
|
+
if (BUDGET_ASK_TOOLS.has(tool) && cwd && existsSync7(cwd)) {
|
|
8485
9758
|
const project = this.resolveProject(cwd);
|
|
8486
9759
|
const b = this.budgetFor(project.id);
|
|
8487
9760
|
if (b && b.status.level === "exceeded" && b.config.on_exceed === "ask") {
|
|
@@ -8530,7 +9803,7 @@ ${err}
|
|
|
8530
9803
|
const d = guardBash(cmd, current, this.liveSessions(), Date.now(), {
|
|
8531
9804
|
...modes,
|
|
8532
9805
|
protected: { ports: [...new Set([...modes.protected.ports, ...this.heldPorts()])] }
|
|
8533
|
-
});
|
|
9806
|
+
}, this.rewriteCtx(sessionId));
|
|
8534
9807
|
if (d.action !== "allow" && recordIncident)
|
|
8535
9808
|
this.openIncident(d, cwd, sessionId, cmd);
|
|
8536
9809
|
return { decision: d, display: cmd };
|
|
@@ -8578,22 +9851,37 @@ ${err}
|
|
|
8578
9851
|
const d = guardBash(cmd, current, sessions, Date.now(), {
|
|
8579
9852
|
...modes,
|
|
8580
9853
|
protected: { ports: [...new Set([...modes.protected.ports, ...this.heldPorts()])] }
|
|
8581
|
-
});
|
|
9854
|
+
}, this.rewriteCtx(id));
|
|
8582
9855
|
if (d.action === "allow")
|
|
8583
9856
|
return null;
|
|
8584
9857
|
return this.openIncident(d, cwd, id, cmd);
|
|
8585
9858
|
}
|
|
8586
9859
|
openIncident(d, cwd, sessionId, command) {
|
|
8587
|
-
const project = cwd &&
|
|
9860
|
+
const project = cwd && existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
9861
|
+
if (d.action === "rewrite" && d.key && sessionId) {
|
|
9862
|
+
const done = this.rewritesDone.get(sessionId) ?? new Set;
|
|
9863
|
+
done.add(d.key);
|
|
9864
|
+
this.rewritesDone.set(sessionId, done);
|
|
9865
|
+
}
|
|
8588
9866
|
this.append({
|
|
8589
9867
|
ts: new Date().toISOString(),
|
|
8590
9868
|
type: "incident.opened",
|
|
8591
9869
|
projectId: project?.id ?? "p_unknown",
|
|
8592
9870
|
sessionId: sessionId || null,
|
|
8593
|
-
payload: {
|
|
9871
|
+
payload: {
|
|
9872
|
+
rule: d.rule,
|
|
9873
|
+
action: d.action,
|
|
9874
|
+
command: command.slice(0, 400),
|
|
9875
|
+
reason: d.reason,
|
|
9876
|
+
...d.action === "rewrite" ? { rewritten: d.command.slice(0, 400) } : {}
|
|
9877
|
+
}
|
|
8594
9878
|
});
|
|
8595
9879
|
return d;
|
|
8596
9880
|
}
|
|
9881
|
+
rewritesDone = new Map;
|
|
9882
|
+
rewriteCtx(sessionId) {
|
|
9883
|
+
return { rewritesDone: this.rewritesDone.get(sessionId) ?? new Set };
|
|
9884
|
+
}
|
|
8597
9885
|
heldWorktreesCache = null;
|
|
8598
9886
|
dryRun(projectId, overrides = {}, limit = 5000) {
|
|
8599
9887
|
const project = this.project(projectId);
|
|
@@ -8635,7 +9923,7 @@ ${err}
|
|
|
8635
9923
|
}
|
|
8636
9924
|
}
|
|
8637
9925
|
const report = dryRunRules(calls, modes, {
|
|
8638
|
-
toplevel: (cwd) => cwd &&
|
|
9926
|
+
toplevel: (cwd) => cwd && existsSync7(cwd) ? this.toplevel(cwd) : null,
|
|
8639
9927
|
claims: this.heldWorktrees()
|
|
8640
9928
|
});
|
|
8641
9929
|
return { ...report, modes };
|
|
@@ -8650,11 +9938,11 @@ ${err}
|
|
|
8650
9938
|
loadPricing() {
|
|
8651
9939
|
this.prices = { ...PRICES };
|
|
8652
9940
|
for (const f of ["pricing.litellm.json", "pricing.json"]) {
|
|
8653
|
-
const p =
|
|
8654
|
-
if (!
|
|
9941
|
+
const p = join9(this.home, f);
|
|
9942
|
+
if (!existsSync7(p))
|
|
8655
9943
|
continue;
|
|
8656
9944
|
try {
|
|
8657
|
-
const j = JSON.parse(
|
|
9945
|
+
const j = JSON.parse(readFileSync4(p, "utf8"));
|
|
8658
9946
|
const table = f === "pricing.json" ? j : fromLiteLLM(j);
|
|
8659
9947
|
Object.assign(this.prices, table);
|
|
8660
9948
|
} catch {}
|
|
@@ -8666,7 +9954,7 @@ ${err}
|
|
|
8666
9954
|
throw new Error(`pricing fetch ${r.status}`);
|
|
8667
9955
|
const j = await r.json();
|
|
8668
9956
|
const slim = Object.fromEntries(Object.entries(j).filter(([k, v]) => typeof v.input_cost_per_token === "number" && !k.includes("/")));
|
|
8669
|
-
|
|
9957
|
+
writeFileSync3(join9(this.home, "pricing.litellm.json"), JSON.stringify(slim, null, 1));
|
|
8670
9958
|
this.loadPricing();
|
|
8671
9959
|
this.reprice();
|
|
8672
9960
|
}
|
|
@@ -8921,6 +10209,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
8921
10209
|
const cfg = this.policyFor(null).config;
|
|
8922
10210
|
const chatter = days ?? cfg.events.retain_days;
|
|
8923
10211
|
const cutoff = new Date(Date.now() - chatter * 86400000).toISOString();
|
|
10212
|
+
this.db.query("DELETE FROM quota WHERE at < ?").run(Date.now() - 14 * 86400000);
|
|
8924
10213
|
let n = this.db.query(`DELETE FROM events WHERE ts < ? AND type NOT IN (${AUDIT_TYPES_SQL})`).run(cutoff).changes;
|
|
8925
10214
|
if (cfg.audit.retain_days > 0) {
|
|
8926
10215
|
const acut = new Date(Date.now() - cfg.audit.retain_days * 86400000).toISOString();
|
|
@@ -8936,10 +10225,25 @@ ${p.reason ?? ""}`.trim(),
|
|
|
8936
10225
|
if (typeof raw.cwd === "string")
|
|
8937
10226
|
this.autoRenewFor(typeof raw.session_id === "string" ? raw.session_id : null, raw.cwd);
|
|
8938
10227
|
const cwd = typeof raw.cwd === "string" ? raw.cwd : process.cwd();
|
|
8939
|
-
|
|
10228
|
+
if (typeof raw.session_id === "string") {
|
|
10229
|
+
if (event === "UserPromptSubmit" || event === "PreToolUse")
|
|
10230
|
+
this.cancelWake(raw.session_id, "active");
|
|
10231
|
+
else if (event === "SessionEnd") {
|
|
10232
|
+
this.cancelWake(raw.session_id, "ended");
|
|
10233
|
+
this.statuslines.delete(raw.session_id);
|
|
10234
|
+
this.rewritesDone.delete(raw.session_id);
|
|
10235
|
+
this.stopExhausted.delete(raw.session_id);
|
|
10236
|
+
}
|
|
10237
|
+
}
|
|
10238
|
+
if (event === "PreToolUse" && typeof raw.session_id === "string") {
|
|
10239
|
+
const fp = raw.tool_input?.file_path;
|
|
10240
|
+
if (WRITE_TOOLS.has(String(raw.tool_name)) && typeof fp === "string")
|
|
10241
|
+
this.noteEdit(raw.session_id, absolutePath(fp, cwd));
|
|
10242
|
+
}
|
|
10243
|
+
const project = existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
8940
10244
|
const e = this.append(normalizeHook(event, raw, project?.id ?? "p_unknown"));
|
|
8941
10245
|
if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
|
|
8942
|
-
if (
|
|
10246
|
+
if (existsSync7(cwd)) {
|
|
8943
10247
|
this.autoHandoff(e.sessionId, cwd);
|
|
8944
10248
|
this.autoGate(event, e.sessionId, cwd);
|
|
8945
10249
|
}
|
|
@@ -8975,6 +10279,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
8975
10279
|
"dispatch.started",
|
|
8976
10280
|
"dispatch.finished",
|
|
8977
10281
|
"gate.recorded",
|
|
10282
|
+
"gate.blocked",
|
|
8978
10283
|
"handoff.recorded",
|
|
8979
10284
|
"incident.opened",
|
|
8980
10285
|
"incident.acked",
|
|
@@ -8985,7 +10290,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
8985
10290
|
return;
|
|
8986
10291
|
const p = e.payload;
|
|
8987
10292
|
const row = this.db.query("SELECT id, tool_counts FROM sessions WHERE id = ?").get(e.sessionId);
|
|
8988
|
-
const branch = p.cwd &&
|
|
10293
|
+
const branch = p.cwd && existsSync7(p.cwd) ? currentBranch(p.cwd) : null;
|
|
8989
10294
|
if (!row) {
|
|
8990
10295
|
this.db.query("INSERT INTO sessions (id, project_id, kind, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, ?, 'active')").run(e.sessionId, e.projectId, p.cwd ?? "", branch, e.ts, e.ts, p.summary ?? e.type, e.type);
|
|
8991
10296
|
}
|
|
@@ -9057,12 +10362,12 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9057
10362
|
}
|
|
9058
10363
|
tailSession(sessionId) {
|
|
9059
10364
|
const s = this.db.query("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
|
|
9060
|
-
if (!s?.transcript_path || !
|
|
10365
|
+
if (!s?.transcript_path || !existsSync7(s.transcript_path))
|
|
9061
10366
|
return 0;
|
|
9062
10367
|
let n = this.tailFile(s.transcript_path, sessionId, null);
|
|
9063
|
-
const subDir =
|
|
10368
|
+
const subDir = join9(dirname3(s.transcript_path), basename(s.transcript_path, ".jsonl"), "subagents");
|
|
9064
10369
|
for (const f of this.subagentFiles(subDir)) {
|
|
9065
|
-
n += this.tailFile(
|
|
10370
|
+
n += this.tailFile(join9(subDir, f), sessionId, f.replace(/^agent-|\.jsonl$/g, ""));
|
|
9066
10371
|
}
|
|
9067
10372
|
return n;
|
|
9068
10373
|
}
|
|
@@ -9094,7 +10399,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9094
10399
|
return n;
|
|
9095
10400
|
}
|
|
9096
10401
|
codexRoot() {
|
|
9097
|
-
return process.env.SWARM_CODEX_DIR ??
|
|
10402
|
+
return process.env.SWARM_CODEX_DIR ?? join9(homedir3(), ".codex", "sessions");
|
|
9098
10403
|
}
|
|
9099
10404
|
codexRolloutFiles(sinceMs) {
|
|
9100
10405
|
const root = this.codexRoot();
|
|
@@ -9109,18 +10414,18 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9109
10414
|
for (const y of ls(root)) {
|
|
9110
10415
|
if (!/^\d{4}$/.test(y))
|
|
9111
10416
|
continue;
|
|
9112
|
-
for (const m of ls(
|
|
10417
|
+
for (const m of ls(join9(root, y))) {
|
|
9113
10418
|
if (!/^\d\d$/.test(m))
|
|
9114
10419
|
continue;
|
|
9115
|
-
for (const day of ls(
|
|
10420
|
+
for (const day of ls(join9(root, y, m))) {
|
|
9116
10421
|
if (!/^\d\d$/.test(day))
|
|
9117
10422
|
continue;
|
|
9118
10423
|
if (Date.parse(`${y}-${m}-${day}T23:59:59Z`) < sinceMs)
|
|
9119
10424
|
continue;
|
|
9120
|
-
const dir =
|
|
10425
|
+
const dir = join9(root, y, m, day);
|
|
9121
10426
|
for (const f of ls(dir)) {
|
|
9122
10427
|
if (f.startsWith("rollout-") && f.endsWith(".jsonl"))
|
|
9123
|
-
out.push(
|
|
10428
|
+
out.push(join9(dir, f));
|
|
9124
10429
|
}
|
|
9125
10430
|
}
|
|
9126
10431
|
}
|
|
@@ -9128,7 +10433,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9128
10433
|
return out;
|
|
9129
10434
|
}
|
|
9130
10435
|
tailCodex(windowMs = 3 * 24 * 60 * 60000) {
|
|
9131
|
-
if (!
|
|
10436
|
+
if (!existsSync7(this.codexRoot()))
|
|
9132
10437
|
return 0;
|
|
9133
10438
|
let n = 0;
|
|
9134
10439
|
for (const path of this.codexRolloutFiles(Date.now() - windowMs)) {
|
|
@@ -9137,12 +10442,12 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9137
10442
|
return n;
|
|
9138
10443
|
}
|
|
9139
10444
|
grokRoot() {
|
|
9140
|
-
return process.env.SWARM_GROK_DIR ??
|
|
10445
|
+
return process.env.SWARM_GROK_DIR ?? join9(homedir3(), ".grok", "sessions");
|
|
9141
10446
|
}
|
|
9142
10447
|
grokSummary = new Map;
|
|
9143
10448
|
tailGemini(windowMs = 3 * 24 * 60 * 60000) {
|
|
9144
|
-
const root = process.env.SWARM_GEMINI_ROOT ??
|
|
9145
|
-
if (!
|
|
10449
|
+
const root = process.env.SWARM_GEMINI_ROOT ?? join9(homedir3(), ".gemini", "tmp");
|
|
10450
|
+
if (!existsSync7(root))
|
|
9146
10451
|
return 0;
|
|
9147
10452
|
const since = Date.now() - windowMs;
|
|
9148
10453
|
const ls = (p) => {
|
|
@@ -9155,13 +10460,13 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9155
10460
|
let n = 0;
|
|
9156
10461
|
const ingestDir = (dir) => {
|
|
9157
10462
|
for (const f of ls(dir)) {
|
|
9158
|
-
const path =
|
|
10463
|
+
const path = join9(dir, f);
|
|
9159
10464
|
if (!f.endsWith(".jsonl")) {
|
|
9160
10465
|
try {
|
|
9161
10466
|
if (statSync(path).isDirectory()) {
|
|
9162
10467
|
for (const g of ls(path))
|
|
9163
10468
|
if (g.endsWith(".jsonl"))
|
|
9164
|
-
ingestFile(
|
|
10469
|
+
ingestFile(join9(path, g));
|
|
9165
10470
|
}
|
|
9166
10471
|
} catch {}
|
|
9167
10472
|
continue;
|
|
@@ -9179,15 +10484,15 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9179
10484
|
n += this.ingestLog(path, "gemini", parseGeminiChat);
|
|
9180
10485
|
};
|
|
9181
10486
|
for (const hash of ls(root)) {
|
|
9182
|
-
const chats =
|
|
9183
|
-
if (
|
|
10487
|
+
const chats = join9(root, hash, "chats");
|
|
10488
|
+
if (existsSync7(chats))
|
|
9184
10489
|
ingestDir(chats);
|
|
9185
10490
|
}
|
|
9186
10491
|
return n;
|
|
9187
10492
|
}
|
|
9188
10493
|
tailGrok(windowMs = 3 * 24 * 60 * 60000) {
|
|
9189
10494
|
const root = this.grokRoot();
|
|
9190
|
-
if (!
|
|
10495
|
+
if (!existsSync7(root))
|
|
9191
10496
|
return 0;
|
|
9192
10497
|
const since = Date.now() - windowMs;
|
|
9193
10498
|
const ls = (p) => {
|
|
@@ -9207,10 +10512,10 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9207
10512
|
} catch {
|
|
9208
10513
|
cwd = enc;
|
|
9209
10514
|
}
|
|
9210
|
-
const cwdDir =
|
|
10515
|
+
const cwdDir = join9(root, enc);
|
|
9211
10516
|
for (const sid of ls(cwdDir)) {
|
|
9212
|
-
const path =
|
|
9213
|
-
if (!
|
|
10517
|
+
const path = join9(cwdDir, sid, "updates.jsonl");
|
|
10518
|
+
if (!existsSync7(path))
|
|
9214
10519
|
continue;
|
|
9215
10520
|
try {
|
|
9216
10521
|
if (statSync(path).mtimeMs < since)
|
|
@@ -9218,7 +10523,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9218
10523
|
} catch {
|
|
9219
10524
|
continue;
|
|
9220
10525
|
}
|
|
9221
|
-
const sumPath =
|
|
10526
|
+
const sumPath = join9(cwdDir, sid, "summary.json");
|
|
9222
10527
|
let title;
|
|
9223
10528
|
let fresh = false;
|
|
9224
10529
|
try {
|
|
@@ -9227,7 +10532,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9227
10532
|
if (hit && hit.mtime === m)
|
|
9228
10533
|
title = hit.title;
|
|
9229
10534
|
else {
|
|
9230
|
-
const sum = JSON.parse(
|
|
10535
|
+
const sum = JSON.parse(readFileSync4(sumPath, "utf8"));
|
|
9231
10536
|
title = sum.session_summary;
|
|
9232
10537
|
this.grokSummary.set(sumPath, { mtime: m, title });
|
|
9233
10538
|
fresh = true;
|
|
@@ -9302,7 +10607,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9302
10607
|
const roots = this.db.query("SELECT DISTINCT root FROM projects WHERE root IS NOT NULL AND root != ''").all();
|
|
9303
10608
|
let n = 0;
|
|
9304
10609
|
for (const { root } of roots) {
|
|
9305
|
-
const path =
|
|
10610
|
+
const path = join9(root, ".aider.chat.history.md");
|
|
9306
10611
|
let mtime;
|
|
9307
10612
|
try {
|
|
9308
10613
|
mtime = statSync(path).mtimeMs;
|
|
@@ -9336,7 +10641,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9336
10641
|
}
|
|
9337
10642
|
ocDbs = new Map;
|
|
9338
10643
|
tailOpencode(windowMs = 3 * 24 * 60 * 60000) {
|
|
9339
|
-
const dir = process.env.SWARM_OPENCODE_DIR ??
|
|
10644
|
+
const dir = process.env.SWARM_OPENCODE_DIR ?? join9(process.env.XDG_DATA_HOME ?? join9(homedir3(), ".local", "share"), "opencode");
|
|
9340
10645
|
let files;
|
|
9341
10646
|
try {
|
|
9342
10647
|
files = readdirSync(dir).filter((f) => /^opencode[^/]*\.db$/.test(f));
|
|
@@ -9345,7 +10650,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9345
10650
|
}
|
|
9346
10651
|
let n = 0;
|
|
9347
10652
|
for (const f of files) {
|
|
9348
|
-
const path =
|
|
10653
|
+
const path = join9(dir, f);
|
|
9349
10654
|
let db = this.ocDbs.get(path);
|
|
9350
10655
|
if (!db) {
|
|
9351
10656
|
try {
|
|
@@ -9396,12 +10701,12 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9396
10701
|
return n;
|
|
9397
10702
|
}
|
|
9398
10703
|
ingestLog(path, agent, parse, cwdHint, titleHint) {
|
|
9399
|
-
const off = this.db.query("SELECT offset FROM tails WHERE path = ?").get(path) ?? { offset: 0 };
|
|
10704
|
+
const off = this.db.query("SELECT offset, session_id FROM tails WHERE path = ?").get(path) ?? { offset: 0, session_id: null };
|
|
9400
10705
|
const r = this.readFrom(path, off.offset);
|
|
9401
10706
|
if (!r)
|
|
9402
10707
|
return 0;
|
|
9403
|
-
const d = parse(r.chunk);
|
|
9404
|
-
const sid = d.sessionId;
|
|
10708
|
+
const d = parse(r.chunk, off.session_id);
|
|
10709
|
+
const sid = d.sessionId ?? off.session_id;
|
|
9405
10710
|
if (!sid)
|
|
9406
10711
|
return 0;
|
|
9407
10712
|
const mtime = (() => {
|
|
@@ -9423,9 +10728,9 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9423
10728
|
ensureAgentSession(sid, agent, cwd, mtime) {
|
|
9424
10729
|
if (this.db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid))
|
|
9425
10730
|
return;
|
|
9426
|
-
const project = cwd &&
|
|
10731
|
+
const project = cwd && existsSync7(cwd) ? this.resolveProject(cwd) : null;
|
|
9427
10732
|
const ts = new Date(mtime).toISOString();
|
|
9428
|
-
this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd &&
|
|
10733
|
+
this.db.query("INSERT INTO sessions (id, project_id, kind, agent, cwd, branch, started_at, last_seen_at, last, last_type, state) VALUES (?, ?, 'interactive', ?, ?, ?, ?, ?, '', '', 'active')").run(sid, project?.id ?? "p_unknown", agent, cwd, cwd && existsSync7(cwd) ? currentBranch(cwd) : null, ts, ts);
|
|
9429
10734
|
}
|
|
9430
10735
|
claimRows(projectId) {
|
|
9431
10736
|
return this.db.query("SELECT * FROM claims WHERE project_id = ?").all(projectId).map((r) => ({
|
|
@@ -9460,7 +10765,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9460
10765
|
worktreePath(projectId, task) {
|
|
9461
10766
|
const slug = (x) => x.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
9462
10767
|
const p = this.project(projectId);
|
|
9463
|
-
return
|
|
10768
|
+
return join9(this.home, "worktrees", slug(p?.name ?? projectId), slug(task));
|
|
9464
10769
|
}
|
|
9465
10770
|
claim(projectId, task, owner, baseRef = "HEAD", sessionId = null) {
|
|
9466
10771
|
const p = this.project(projectId);
|
|
@@ -9488,7 +10793,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9488
10793
|
}
|
|
9489
10794
|
const branch = `task/${task}`;
|
|
9490
10795
|
const worktree = this.worktreePath(projectId, task);
|
|
9491
|
-
if (
|
|
10796
|
+
if (existsSync7(worktree))
|
|
9492
10797
|
return { ok: false, error: `${worktree} already exists; release ${task} first` };
|
|
9493
10798
|
mkdirSync4(dirname3(worktree), { recursive: true });
|
|
9494
10799
|
const created = worktreeAdd(p.root, worktree, branch, baseRef);
|
|
@@ -9677,7 +10982,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
9677
10982
|
}
|
|
9678
10983
|
static worktreeIdleMs(path) {
|
|
9679
10984
|
let newest = 0;
|
|
9680
|
-
for (const f of [path,
|
|
10985
|
+
for (const f of [path, join9(path, ".git")]) {
|
|
9681
10986
|
try {
|
|
9682
10987
|
newest = Math.max(newest, statSync(f).mtimeMs);
|
|
9683
10988
|
} catch {}
|
|
@@ -10279,7 +11584,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10279
11584
|
for (const c of this.claimRows(p.id)) {
|
|
10280
11585
|
if (c.state !== "held" || isActive(c, now))
|
|
10281
11586
|
continue;
|
|
10282
|
-
const exists = c.worktree ?
|
|
11587
|
+
const exists = c.worktree ? existsSync7(c.worktree) : false;
|
|
10283
11588
|
const work = exists ? heldWork(c.worktree) : null;
|
|
10284
11589
|
if (reapAction(c, now, exists, work) !== "keep-orphaned")
|
|
10285
11590
|
continue;
|
|
@@ -10335,7 +11640,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10335
11640
|
if (!row)
|
|
10336
11641
|
return { ok: false, error: `no claim on ${task}` };
|
|
10337
11642
|
const worktree = row.worktree ?? "";
|
|
10338
|
-
if (worktree &&
|
|
11643
|
+
if (worktree && existsSync7(worktree)) {
|
|
10339
11644
|
const work = heldWork(worktree);
|
|
10340
11645
|
const can = canRelease(work, force);
|
|
10341
11646
|
if (!can.ok)
|
|
@@ -10370,7 +11675,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10370
11675
|
continue;
|
|
10371
11676
|
if (isActive({ ...c, state: "held" }, now))
|
|
10372
11677
|
continue;
|
|
10373
|
-
const exists = c.worktree ?
|
|
11678
|
+
const exists = c.worktree ? existsSync7(c.worktree) : false;
|
|
10374
11679
|
const work = exists ? heldWork(c.worktree) : null;
|
|
10375
11680
|
const action = reapAction({ ...c, state: "held" }, now, exists, work);
|
|
10376
11681
|
if (action === "not-expired")
|
|
@@ -10469,7 +11774,7 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10469
11774
|
if (!slug || slug === "." || slug === "..")
|
|
10470
11775
|
return { ok: false, error: "bad worktree name" };
|
|
10471
11776
|
const path = this.worktreePath(projectId, slug);
|
|
10472
|
-
if (
|
|
11777
|
+
if (existsSync7(path))
|
|
10473
11778
|
return { ok: false, error: `${path} already exists` };
|
|
10474
11779
|
mkdirSync4(dirname3(path), { recursive: true });
|
|
10475
11780
|
const br = branch?.trim() || `wt/${slug}`;
|
|
@@ -10771,8 +12076,8 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10771
12076
|
backupTo(destDir) {
|
|
10772
12077
|
mkdirSync4(destDir, { recursive: true });
|
|
10773
12078
|
const files = [];
|
|
10774
|
-
const dbDest =
|
|
10775
|
-
if (
|
|
12079
|
+
const dbDest = join9(destDir, "swarm.db");
|
|
12080
|
+
if (existsSync7(dbDest))
|
|
10776
12081
|
unlinkSync(dbDest);
|
|
10777
12082
|
this.db.exec(`VACUUM INTO '${dbDest.replaceAll("'", "''")}'`);
|
|
10778
12083
|
files.push("swarm.db");
|
|
@@ -10785,10 +12090,10 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10785
12090
|
"pricing.litellm.json",
|
|
10786
12091
|
"team-token"
|
|
10787
12092
|
]) {
|
|
10788
|
-
const src =
|
|
10789
|
-
if (!
|
|
12093
|
+
const src = join9(this.home, f);
|
|
12094
|
+
if (!existsSync7(src))
|
|
10790
12095
|
continue;
|
|
10791
|
-
copyFileSync(src,
|
|
12096
|
+
copyFileSync(src, join9(destDir, f));
|
|
10792
12097
|
files.push(f);
|
|
10793
12098
|
}
|
|
10794
12099
|
return { dest: destDir, files };
|
|
@@ -10979,6 +12284,38 @@ ${p.reason ?? ""}`.trim(),
|
|
|
10979
12284
|
return { ...i, count: counts.get(key) ?? 1, suggestion };
|
|
10980
12285
|
});
|
|
10981
12286
|
}
|
|
12287
|
+
incident(seq) {
|
|
12288
|
+
const r = this.db.query(`SELECT e.seq, e.ts, e.project_id, e.session_id, e.payload, a.acked_at FROM events e
|
|
12289
|
+
LEFT JOIN incident_acks a ON a.seq = e.seq WHERE e.seq = ? AND e.type = 'incident.opened'`).get(seq);
|
|
12290
|
+
if (!r)
|
|
12291
|
+
return null;
|
|
12292
|
+
const payload = JSON.parse(r.payload || "{}");
|
|
12293
|
+
const base = {
|
|
12294
|
+
seq: r.seq,
|
|
12295
|
+
ts: r.ts,
|
|
12296
|
+
projectId: r.project_id,
|
|
12297
|
+
sessionId: r.session_id,
|
|
12298
|
+
acked: r.acked_at,
|
|
12299
|
+
...payload
|
|
12300
|
+
};
|
|
12301
|
+
if (typeof payload.rule !== "string")
|
|
12302
|
+
return base;
|
|
12303
|
+
const rule = payload.rule;
|
|
12304
|
+
const command = typeof payload.command === "string" ? payload.command : "";
|
|
12305
|
+
const count = this.db.query(`SELECT COUNT(*) AS n FROM events WHERE type = 'incident.opened' AND project_id = ?
|
|
12306
|
+
AND json_extract(payload,'$.rule') = ? AND json_extract(payload,'$.command') = ?`).get(r.project_id, rule, command).n;
|
|
12307
|
+
return {
|
|
12308
|
+
...base,
|
|
12309
|
+
count: Math.max(1, count),
|
|
12310
|
+
suggestion: suggestFromIncident({
|
|
12311
|
+
rule,
|
|
12312
|
+
action: typeof payload.action === "string" ? payload.action : "",
|
|
12313
|
+
command,
|
|
12314
|
+
reason: typeof payload.reason === "string" ? payload.reason : "",
|
|
12315
|
+
count: Math.max(1, count)
|
|
12316
|
+
})
|
|
12317
|
+
};
|
|
12318
|
+
}
|
|
10982
12319
|
openIncidents(projectId) {
|
|
10983
12320
|
const r = this.db.query(`SELECT COUNT(*) AS n FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
|
|
10984
12321
|
WHERE e.type = 'incident.opened' AND a.seq IS NULL${projectId ? " AND e.project_id = ?" : ""}`).get(...projectId ? [projectId] : []);
|
|
@@ -11341,12 +12678,14 @@ ${p.reason ?? ""}`.trim(),
|
|
|
11341
12678
|
sessions: this.sessions(),
|
|
11342
12679
|
spend: this.memoised("spend", 30000, () => this.spend()),
|
|
11343
12680
|
spendSparks: this.memoised("spendSparks", 60000, () => this.spendSparks()),
|
|
12681
|
+
quota: this.quota(),
|
|
11344
12682
|
claims: this.claims(),
|
|
11345
12683
|
processes: this.memoised("processes", 5000, () => this.processes()),
|
|
11346
12684
|
incidents: this.memoised("incidents", 30000, () => this.incidents(20, { open: true })),
|
|
11347
12685
|
openIncidents: this.memoised("openIncidents", 30000, () => this.openIncidents()),
|
|
11348
12686
|
openIncidentsByProject: this.memoised("openIncidentsByProject", 30000, () => this.openIncidentsByProject()),
|
|
11349
12687
|
questions: this.questions({ open: true, limit: 50 }),
|
|
12688
|
+
permissions: this.pendingPermissions(),
|
|
11350
12689
|
resources: this.resources(),
|
|
11351
12690
|
seq: this.seq()
|
|
11352
12691
|
};
|
|
@@ -11487,8 +12826,8 @@ function localDayIso(offsetDays) {
|
|
|
11487
12826
|
}
|
|
11488
12827
|
|
|
11489
12828
|
// packages/daemon/src/team.ts
|
|
11490
|
-
import { writeFileSync as
|
|
11491
|
-
import { join as
|
|
12829
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
12830
|
+
import { join as join10 } from "path";
|
|
11492
12831
|
var SPEND_EVERY_MS = 60000;
|
|
11493
12832
|
var MAX_BACKOFF_MS = 300000;
|
|
11494
12833
|
var POLICY_EVERY_MS = 300000;
|
|
@@ -11656,12 +12995,12 @@ class TeamForwarder {
|
|
|
11656
12995
|
this.store.setMetaValue("team_last_error", "org policy signature invalid \u2014 not installed");
|
|
11657
12996
|
return;
|
|
11658
12997
|
}
|
|
11659
|
-
const file =
|
|
12998
|
+
const file = join10(this.store.home, "policy.toml");
|
|
11660
12999
|
const prev = this.store.metaValue("team_policy_sig");
|
|
11661
13000
|
if (prev === policy.signature)
|
|
11662
13001
|
return;
|
|
11663
|
-
|
|
11664
|
-
|
|
13002
|
+
writeFileSync4(file, policy.toml, { mode: 384 });
|
|
13003
|
+
writeFileSync4(join10(this.store.home, "policy.sig.json"), JSON.stringify({
|
|
11665
13004
|
signature: policy.signature,
|
|
11666
13005
|
publicKey: pinned,
|
|
11667
13006
|
fetchedAt: new Date(now).toISOString(),
|
|
@@ -11672,6 +13011,237 @@ class TeamForwarder {
|
|
|
11672
13011
|
}
|
|
11673
13012
|
}
|
|
11674
13013
|
|
|
13014
|
+
// packages/daemon/src/teamctl.ts
|
|
13015
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
13016
|
+
import { networkInterfaces } from "os";
|
|
13017
|
+
import { join as join11 } from "path";
|
|
13018
|
+
var HOSTED_PID = "team_hosted_pid";
|
|
13019
|
+
function alive(pid) {
|
|
13020
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
13021
|
+
return false;
|
|
13022
|
+
try {
|
|
13023
|
+
process.kill(pid, 0);
|
|
13024
|
+
return true;
|
|
13025
|
+
} catch {
|
|
13026
|
+
return false;
|
|
13027
|
+
}
|
|
13028
|
+
}
|
|
13029
|
+
function hostedPid(store) {
|
|
13030
|
+
const pid = Number(store.metaValue(HOSTED_PID) ?? 0);
|
|
13031
|
+
if (!alive(pid)) {
|
|
13032
|
+
if (pid)
|
|
13033
|
+
store.setMetaValue(HOSTED_PID, "");
|
|
13034
|
+
return null;
|
|
13035
|
+
}
|
|
13036
|
+
return pid;
|
|
13037
|
+
}
|
|
13038
|
+
async function hostedPidChecked(store) {
|
|
13039
|
+
const pid = hostedPid(store);
|
|
13040
|
+
if (!pid)
|
|
13041
|
+
return null;
|
|
13042
|
+
if (await healthy(hostedUrl("127.0.0.1", readSetup(store.home).port)))
|
|
13043
|
+
return pid;
|
|
13044
|
+
store.setMetaValue(HOSTED_PID, "");
|
|
13045
|
+
return null;
|
|
13046
|
+
}
|
|
13047
|
+
function lanAddress() {
|
|
13048
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
13049
|
+
for (const n of list ?? []) {
|
|
13050
|
+
if (n.family === "IPv4" && !n.internal)
|
|
13051
|
+
return n.address;
|
|
13052
|
+
}
|
|
13053
|
+
}
|
|
13054
|
+
return null;
|
|
13055
|
+
}
|
|
13056
|
+
function setupPath(home) {
|
|
13057
|
+
return join11(home, "team.toml");
|
|
13058
|
+
}
|
|
13059
|
+
function readSetup(home) {
|
|
13060
|
+
const p = setupPath(home);
|
|
13061
|
+
return parseTeamSetup(existsSync8(p) ? readFileSync5(p, "utf8") : null);
|
|
13062
|
+
}
|
|
13063
|
+
function writeSetup(home, setup) {
|
|
13064
|
+
mkdirSync5(home, { recursive: true });
|
|
13065
|
+
writeFileSync5(setupPath(home), renderTeamSetup(setup), { mode: 384 });
|
|
13066
|
+
}
|
|
13067
|
+
function setTeamUrl(store, url) {
|
|
13068
|
+
const path = join11(store.home, "config.toml");
|
|
13069
|
+
const text = existsSync8(path) ? readFileSync5(path, "utf8") : "";
|
|
13070
|
+
mkdirSync5(store.home, { recursive: true });
|
|
13071
|
+
writeFileSync5(path, withTeamUrl(text, url));
|
|
13072
|
+
store.invalidateConfig();
|
|
13073
|
+
}
|
|
13074
|
+
async function healthy(url, ms = 500) {
|
|
13075
|
+
try {
|
|
13076
|
+
const r = await fetch(`${url}/t1/health`, { signal: AbortSignal.timeout(ms) });
|
|
13077
|
+
return r.ok;
|
|
13078
|
+
} catch {
|
|
13079
|
+
return false;
|
|
13080
|
+
}
|
|
13081
|
+
}
|
|
13082
|
+
async function registerMachine(store, url, token) {
|
|
13083
|
+
let mode = "open";
|
|
13084
|
+
let policyPublicKey;
|
|
13085
|
+
try {
|
|
13086
|
+
const cfg = await (await fetch(`${url}/t1/auth/config`, { signal: AbortSignal.timeout(4000) })).json();
|
|
13087
|
+
mode = cfg.mode ?? "open";
|
|
13088
|
+
policyPublicKey = cfg.policyPublicKey;
|
|
13089
|
+
} catch {
|
|
13090
|
+
return { ok: false, error: `no team daemon answering at ${url}` };
|
|
13091
|
+
}
|
|
13092
|
+
if (mode === "oidc" && !token)
|
|
13093
|
+
return {
|
|
13094
|
+
ok: false,
|
|
13095
|
+
error: "this team uses your identity provider \u2014 run `swarm login` in a terminal once"
|
|
13096
|
+
};
|
|
13097
|
+
const machine = store.machineIdentity();
|
|
13098
|
+
let reg = {};
|
|
13099
|
+
try {
|
|
13100
|
+
const res = await fetch(`${url}/t1/machines/register`, {
|
|
13101
|
+
method: "POST",
|
|
13102
|
+
headers: {
|
|
13103
|
+
"content-type": "application/json",
|
|
13104
|
+
...token ? { authorization: `Bearer ${token}` } : {}
|
|
13105
|
+
},
|
|
13106
|
+
body: JSON.stringify({ id: machine.id, name: machine.name }),
|
|
13107
|
+
signal: AbortSignal.timeout(6000)
|
|
13108
|
+
});
|
|
13109
|
+
reg = await res.json().catch(() => ({}));
|
|
13110
|
+
if (!res.ok)
|
|
13111
|
+
return {
|
|
13112
|
+
ok: false,
|
|
13113
|
+
error: `registration refused (${res.status}): ${reg.error ?? "check the secret"}`
|
|
13114
|
+
};
|
|
13115
|
+
} catch {
|
|
13116
|
+
return { ok: false, error: `registration failed: ${url} did not answer` };
|
|
13117
|
+
}
|
|
13118
|
+
const machineToken = reg.token ?? (mode === "token" ? token : null);
|
|
13119
|
+
if (mode !== "open" && !machineToken)
|
|
13120
|
+
return { ok: false, error: `registration refused: ${reg.error ?? "check the secret"}` };
|
|
13121
|
+
if (machineToken)
|
|
13122
|
+
store.setMetaValue("team_machine_token", machineToken);
|
|
13123
|
+
if (policyPublicKey)
|
|
13124
|
+
store.setMetaValue("team_policy_pubkey", policyPublicKey);
|
|
13125
|
+
return { ok: true, mode };
|
|
13126
|
+
}
|
|
13127
|
+
async function hostTeam(store, input = {}) {
|
|
13128
|
+
const existing = await hostedPidChecked(store);
|
|
13129
|
+
if (existing)
|
|
13130
|
+
return { ok: false, error: `already hosting a team (pid ${existing})` };
|
|
13131
|
+
const cur = readSetup(store.home);
|
|
13132
|
+
const mode = input.mode ?? cur.mode;
|
|
13133
|
+
const setup = {
|
|
13134
|
+
...DEFAULT_TEAM_SETUP,
|
|
13135
|
+
...cur,
|
|
13136
|
+
mode,
|
|
13137
|
+
port: input.port ?? cur.port,
|
|
13138
|
+
name: input.name ?? cur.name,
|
|
13139
|
+
token: mode === "token" ? input.token ?? cur.token ?? mintTeamSecret() : null
|
|
13140
|
+
};
|
|
13141
|
+
if (mode === "oidc" && (!setup.issuer || !setup.clientId))
|
|
13142
|
+
return {
|
|
13143
|
+
ok: false,
|
|
13144
|
+
error: "an identity-provider team needs an issuer and a client id \u2014 run `swarm-teamd setup` once"
|
|
13145
|
+
};
|
|
13146
|
+
writeSetup(store.home, setup);
|
|
13147
|
+
const loopback = hostedUrl("127.0.0.1", setup.port);
|
|
13148
|
+
if (await healthy(loopback))
|
|
13149
|
+
return { ok: false, error: `something is already listening on port ${setup.port}` };
|
|
13150
|
+
const [cmd, ...args] = resolveBin("swarm-teamd");
|
|
13151
|
+
const logDir = join11(store.home, "logs");
|
|
13152
|
+
mkdirSync5(logDir, { recursive: true });
|
|
13153
|
+
const log = join11(logDir, "teamd.log");
|
|
13154
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
13155
|
+
cwd: store.home,
|
|
13156
|
+
env: { ...process.env, SWARM_HOME: store.home },
|
|
13157
|
+
stdout: Bun.file(log).writer(),
|
|
13158
|
+
stderr: Bun.file(log).writer(),
|
|
13159
|
+
stdin: "ignore"
|
|
13160
|
+
});
|
|
13161
|
+
proc.unref();
|
|
13162
|
+
const until = Date.now() + 15000;
|
|
13163
|
+
while (Date.now() < until) {
|
|
13164
|
+
if (await healthy(loopback))
|
|
13165
|
+
break;
|
|
13166
|
+
if (proc.exitCode !== null)
|
|
13167
|
+
return {
|
|
13168
|
+
ok: false,
|
|
13169
|
+
error: `swarm-teamd exited (${proc.exitCode}) \u2014 see ${log}`
|
|
13170
|
+
};
|
|
13171
|
+
await Bun.sleep(200);
|
|
13172
|
+
}
|
|
13173
|
+
if (!await healthy(loopback)) {
|
|
13174
|
+
proc.kill();
|
|
13175
|
+
return {
|
|
13176
|
+
ok: false,
|
|
13177
|
+
error: `swarm-teamd (${cmd} ${args.join(" ")}) did not come up on ${setup.port} \u2014 see ${log}. The team daemon is source-available and ships separately; run it from a clone, or put swarm-teamd on PATH.`
|
|
13178
|
+
};
|
|
13179
|
+
}
|
|
13180
|
+
store.setMetaValue(HOSTED_PID, String(proc.pid));
|
|
13181
|
+
setTeamUrl(store, loopback);
|
|
13182
|
+
const reg = await registerMachine(store, loopback, setup.token);
|
|
13183
|
+
if (!reg.ok) {
|
|
13184
|
+
setTeamUrl(store, null);
|
|
13185
|
+
store.setMetaValue(HOSTED_PID, "");
|
|
13186
|
+
try {
|
|
13187
|
+
process.kill(proc.pid, "SIGTERM");
|
|
13188
|
+
} catch {}
|
|
13189
|
+
return reg;
|
|
13190
|
+
}
|
|
13191
|
+
const address = lanAddress();
|
|
13192
|
+
return {
|
|
13193
|
+
ok: true,
|
|
13194
|
+
url: loopback,
|
|
13195
|
+
invite: inviteLink(hostedUrl(address ?? "127.0.0.1", setup.port), setup.token),
|
|
13196
|
+
setup,
|
|
13197
|
+
pid: proc.pid,
|
|
13198
|
+
address
|
|
13199
|
+
};
|
|
13200
|
+
}
|
|
13201
|
+
async function joinTeam(store, input) {
|
|
13202
|
+
const parsed = input.invite ? parseInvite(input.invite) : null;
|
|
13203
|
+
const url = (parsed?.url ?? input.url ?? "").replace(/\/+$/, "");
|
|
13204
|
+
if (!url)
|
|
13205
|
+
return { ok: false, error: "paste an invite link or the team daemon's URL" };
|
|
13206
|
+
const token = input.token ?? parsed?.token ?? null;
|
|
13207
|
+
const reg = await registerMachine(store, url, token);
|
|
13208
|
+
if (!reg.ok)
|
|
13209
|
+
return reg;
|
|
13210
|
+
setTeamUrl(store, url);
|
|
13211
|
+
return { ok: true, url, mode: reg.mode };
|
|
13212
|
+
}
|
|
13213
|
+
async function leaveTeam(store, opts = {}) {
|
|
13214
|
+
setTeamUrl(store, null);
|
|
13215
|
+
store.setMetaValue("team_machine_token", "");
|
|
13216
|
+
let stopped = false;
|
|
13217
|
+
const pid = hostedPid(store);
|
|
13218
|
+
if (opts.stopHosted && pid) {
|
|
13219
|
+
try {
|
|
13220
|
+
process.kill(pid, "SIGTERM");
|
|
13221
|
+
stopped = true;
|
|
13222
|
+
} catch {
|
|
13223
|
+
stopped = false;
|
|
13224
|
+
}
|
|
13225
|
+
store.setMetaValue(HOSTED_PID, "");
|
|
13226
|
+
}
|
|
13227
|
+
return { ok: true, stopped };
|
|
13228
|
+
}
|
|
13229
|
+
function hostingStatus(store) {
|
|
13230
|
+
const pid = hostedPid(store);
|
|
13231
|
+
if (!pid)
|
|
13232
|
+
return { hosting: false, pid: null, port: null, invite: null, mode: null, address: null };
|
|
13233
|
+
const setup = readSetup(store.home);
|
|
13234
|
+
const address = lanAddress();
|
|
13235
|
+
return {
|
|
13236
|
+
hosting: true,
|
|
13237
|
+
pid,
|
|
13238
|
+
port: setup.port,
|
|
13239
|
+
invite: inviteLink(hostedUrl(address ?? "127.0.0.1", setup.port), setup.token),
|
|
13240
|
+
mode: setup.mode,
|
|
13241
|
+
address
|
|
13242
|
+
};
|
|
13243
|
+
}
|
|
13244
|
+
|
|
11675
13245
|
// packages/daemon/src/workflow.ts
|
|
11676
13246
|
class WorkflowEngine {
|
|
11677
13247
|
store;
|
|
@@ -11863,13 +13433,13 @@ class WorkflowEngine {
|
|
|
11863
13433
|
}
|
|
11864
13434
|
|
|
11865
13435
|
// packages/daemon/src/app.ts
|
|
11866
|
-
var VERSION = "0.
|
|
13436
|
+
var VERSION = "0.14.0";
|
|
11867
13437
|
var WEB_DIR = (() => {
|
|
11868
13438
|
if (process.env.SWARM_WEB_DIR)
|
|
11869
13439
|
return process.env.SWARM_WEB_DIR;
|
|
11870
13440
|
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
11871
|
-
const dev =
|
|
11872
|
-
return
|
|
13441
|
+
const dev = join12(here, "../../web/public");
|
|
13442
|
+
return existsSync9(join12(dev, "index.html")) ? dev : join12(here, "../web");
|
|
11873
13443
|
})();
|
|
11874
13444
|
var REPLAY_TAIL = 200;
|
|
11875
13445
|
var wireCache = new WeakMap;
|
|
@@ -11883,12 +13453,12 @@ function wireJson(e) {
|
|
|
11883
13453
|
}
|
|
11884
13454
|
function hookRepoRoot(store, raw) {
|
|
11885
13455
|
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
11886
|
-
return cwd &&
|
|
13456
|
+
return cwd && existsSync9(cwd) ? store.resolveProject(cwd)?.root ?? null : null;
|
|
11887
13457
|
}
|
|
11888
13458
|
function claudeSettings() {
|
|
11889
13459
|
try {
|
|
11890
|
-
const p = process.env.CLAUDE_SETTINGS ??
|
|
11891
|
-
return
|
|
13460
|
+
const p = process.env.CLAUDE_SETTINGS ?? join12(homedir4(), ".claude", "settings.json");
|
|
13461
|
+
return existsSync9(p) ? JSON.parse(readFileSync6(p, "utf8")) : null;
|
|
11892
13462
|
} catch {
|
|
11893
13463
|
return null;
|
|
11894
13464
|
}
|
|
@@ -11896,12 +13466,12 @@ function claudeSettings() {
|
|
|
11896
13466
|
function diskVersion() {
|
|
11897
13467
|
try {
|
|
11898
13468
|
const entry = daemonCommand().at(-1);
|
|
11899
|
-
if (!entry || !
|
|
13469
|
+
if (!entry || !existsSync9(entry))
|
|
11900
13470
|
return null;
|
|
11901
|
-
for (const f of [entry,
|
|
11902
|
-
if (!
|
|
13471
|
+
for (const f of [entry, join12(dirname4(entry), "app.ts")]) {
|
|
13472
|
+
if (!existsSync9(f))
|
|
11903
13473
|
continue;
|
|
11904
|
-
const m = /SWARM_VERSION\s*\?\?\s*"(\d+\.\d+\.\d+)"/.exec(
|
|
13474
|
+
const m = /SWARM_VERSION\s*\?\?\s*"(\d+\.\d+\.\d+)"/.exec(readFileSync6(f, "utf8"));
|
|
11905
13475
|
if (m?.[1])
|
|
11906
13476
|
return m[1];
|
|
11907
13477
|
}
|
|
@@ -11914,7 +13484,7 @@ function expandHome(p, home = homedir4()) {
|
|
|
11914
13484
|
if (p === "~")
|
|
11915
13485
|
return home;
|
|
11916
13486
|
if (p.startsWith("~/"))
|
|
11917
|
-
return
|
|
13487
|
+
return join12(home, p.slice(2));
|
|
11918
13488
|
return p;
|
|
11919
13489
|
}
|
|
11920
13490
|
function createApp(store = new Store, hooks = {}) {
|
|
@@ -11979,7 +13549,7 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
11979
13549
|
app.delete("/v1/projects/:id", (c) => store.removeProject(c.req.param("id")) ? c.body(null, 204) : c.json({ error: "not found" }, 404));
|
|
11980
13550
|
app.get("/v1/fs/ls", (c) => {
|
|
11981
13551
|
const q = expandHome(c.req.query("path") ?? "");
|
|
11982
|
-
if (q && !
|
|
13552
|
+
if (q && !existsSync9(q))
|
|
11983
13553
|
return c.json({ error: "no such folder", path: q }, 404);
|
|
11984
13554
|
let dir;
|
|
11985
13555
|
try {
|
|
@@ -11988,7 +13558,7 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
11988
13558
|
dir = homedir4();
|
|
11989
13559
|
}
|
|
11990
13560
|
try {
|
|
11991
|
-
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo:
|
|
13561
|
+
const entries = readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, repo: existsSync9(join12(dir, e.name, ".git")) })).sort((a, b) => a.name.localeCompare(b.name));
|
|
11992
13562
|
const parent = dirname4(dir);
|
|
11993
13563
|
return c.json({ path: dir, parent: parent === dir ? null : parent, entries });
|
|
11994
13564
|
} catch (e) {
|
|
@@ -11996,6 +13566,8 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
11996
13566
|
}
|
|
11997
13567
|
});
|
|
11998
13568
|
app.get("/v1/state", (c) => {
|
|
13569
|
+
if (c.req.query("watching") === "1")
|
|
13570
|
+
store.touchDashboard();
|
|
11999
13571
|
const body = JSON.stringify(store.snapshot());
|
|
12000
13572
|
const etag = `W/"${Bun.hash(body).toString(36)}"`;
|
|
12001
13573
|
const head = { etag, "cache-control": "no-cache" };
|
|
@@ -12176,7 +13748,21 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12176
13748
|
return c.json({ error: e.message }, 500);
|
|
12177
13749
|
}
|
|
12178
13750
|
});
|
|
12179
|
-
app.get("/v1/team", (c) => c.json(team.status()));
|
|
13751
|
+
app.get("/v1/team", (c) => c.json({ ...team.status(), ...hostingStatus(store) }));
|
|
13752
|
+
app.post("/v1/team/host", async (c) => {
|
|
13753
|
+
const b = await c.req.json().catch(() => ({}));
|
|
13754
|
+
const r = await hostTeam(store, b);
|
|
13755
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
13756
|
+
});
|
|
13757
|
+
app.post("/v1/team/join", async (c) => {
|
|
13758
|
+
const b = await c.req.json().catch(() => ({}));
|
|
13759
|
+
const r = await joinTeam(store, b);
|
|
13760
|
+
return c.json(r, r.ok ? 200 : 409);
|
|
13761
|
+
});
|
|
13762
|
+
app.post("/v1/team/leave", async (c) => {
|
|
13763
|
+
const b = await c.req.json().catch(() => ({}));
|
|
13764
|
+
return c.json(await leaveTeam(store, b));
|
|
13765
|
+
});
|
|
12180
13766
|
app.post("/v1/team/credentials", async (c) => {
|
|
12181
13767
|
const b = await c.req.json().catch(() => ({}));
|
|
12182
13768
|
if (typeof b.token !== "string" || !b.token)
|
|
@@ -12223,7 +13809,7 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12223
13809
|
return c.json({ ok: false, error: "project required" }, 400);
|
|
12224
13810
|
const overrides = {};
|
|
12225
13811
|
for (const [k, v] of Object.entries(c.req.query()))
|
|
12226
|
-
if (RULE_IDS.includes(k) && ["ask", "deny", "off"].includes(v))
|
|
13812
|
+
if (RULE_IDS.includes(k) && ["ask", "deny", "off", "rewrite"].includes(v))
|
|
12227
13813
|
overrides[k] = v;
|
|
12228
13814
|
const limit = Math.min(20000, Math.max(100, Number(c.req.query("limit")) || 5000));
|
|
12229
13815
|
return c.json(store.dryRun(projectId, overrides, limit));
|
|
@@ -12232,6 +13818,15 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12232
13818
|
const body = await c.req.json().catch(() => ({}));
|
|
12233
13819
|
return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined, body.by) });
|
|
12234
13820
|
});
|
|
13821
|
+
app.post("/v1/incidents/:seq/apply", async (c) => {
|
|
13822
|
+
const b = await c.req.json().catch(() => ({}));
|
|
13823
|
+
const seq = Number(c.req.param("seq"));
|
|
13824
|
+
if (!b.projectId || !Number.isInteger(seq))
|
|
13825
|
+
return c.json({ ok: false, error: "projectId and a numeric seq required" }, 400);
|
|
13826
|
+
const target = b.target === "claude-md" || b.target === "swarm-toml" || b.target === "both" ? b.target : store.policyFor(null).config.codify.target;
|
|
13827
|
+
const r = await applyCodify(store, forge, b.projectId, seq, target);
|
|
13828
|
+
return c.json(r, r.ok ? 201 : 409);
|
|
13829
|
+
});
|
|
12235
13830
|
app.post("/v1/incidents/:seq/ack", (c) => {
|
|
12236
13831
|
const seq = Number(c.req.param("seq"));
|
|
12237
13832
|
if (!Number.isInteger(seq) || !store.ackIncident(seq, c.req.query("by")))
|
|
@@ -12313,6 +13908,7 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12313
13908
|
return c.json({ error: "project required" }, 400);
|
|
12314
13909
|
return c.json(store.budgetFor(project) ?? { status: null, config: store.config(project).budget });
|
|
12315
13910
|
});
|
|
13911
|
+
app.get("/v1/quota", (c) => c.json(store.quota()));
|
|
12316
13912
|
app.get("/v1/context", (c) => {
|
|
12317
13913
|
const cwd = c.req.query("cwd");
|
|
12318
13914
|
if (!cwd)
|
|
@@ -12444,6 +14040,27 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12444
14040
|
const r = runner.answerPermission(c.req.param("id"), c.req.param("reqId"), b.allow === true, b.message);
|
|
12445
14041
|
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
12446
14042
|
});
|
|
14043
|
+
app.get("/v1/repair", (c) => {
|
|
14044
|
+
const cwd = c.req.query("cwd") ?? "";
|
|
14045
|
+
return c.json({ block: store.repairArmed(cwd) });
|
|
14046
|
+
});
|
|
14047
|
+
app.post("/v1/wake", async (c) => {
|
|
14048
|
+
const b = await c.req.json().catch(() => ({}));
|
|
14049
|
+
const sid = typeof b.session_id === "string" ? b.session_id : "";
|
|
14050
|
+
if (!sid)
|
|
14051
|
+
return c.json({ wake: false, reason: "no session" }, 400);
|
|
14052
|
+
return c.json(await store.waitForWake(sid, 10 * 60000));
|
|
14053
|
+
});
|
|
14054
|
+
app.get("/v1/permissions", (c) => c.json({ permissions: store.pendingPermissions() }));
|
|
14055
|
+
app.post("/v1/permissions/:id", async (c) => {
|
|
14056
|
+
const b = await c.req.json().catch(() => ({}));
|
|
14057
|
+
const r = store.answerInteractive(c.req.param("id"), {
|
|
14058
|
+
behavior: b.terminal ? null : b.allow === true ? "allow" : "deny",
|
|
14059
|
+
...b.message ? { message: b.message } : {},
|
|
14060
|
+
by: b.terminal ? "terminal" : b.by ?? "dashboard"
|
|
14061
|
+
});
|
|
14062
|
+
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
14063
|
+
});
|
|
12447
14064
|
app.delete("/v1/runs/:id", async (c) => {
|
|
12448
14065
|
const r = await runner.stop(c.req.param("id"));
|
|
12449
14066
|
return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
|
|
@@ -12690,9 +14307,61 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12690
14307
|
});
|
|
12691
14308
|
}
|
|
12692
14309
|
const sid = typeof raw.session_id === "string" ? raw.session_id : null;
|
|
14310
|
+
if (event === "PermissionRequest" && sid) {
|
|
14311
|
+
const waitS = store.policyFor(null).config.broker.interactive_wait;
|
|
14312
|
+
if (waitS > 0 && store.dashboardWatching()) {
|
|
14313
|
+
const a = await store.askInteractive(raw, waitS * 1000);
|
|
14314
|
+
return c.json(permissionHookOutput(a ?? { behavior: null, by: "terminal" }, raw.tool_input ?? {}));
|
|
14315
|
+
}
|
|
14316
|
+
store.append({
|
|
14317
|
+
ts: new Date().toISOString(),
|
|
14318
|
+
type: "permission.resolved",
|
|
14319
|
+
projectId: typeof raw.cwd === "string" && existsSync9(raw.cwd) ? store.resolveProject(raw.cwd).id : "p_unknown",
|
|
14320
|
+
sessionId: sid,
|
|
14321
|
+
payload: {
|
|
14322
|
+
requestId: raw.tool_use_id ?? null,
|
|
14323
|
+
decision: "terminal",
|
|
14324
|
+
by: "terminal",
|
|
14325
|
+
source: "interactive",
|
|
14326
|
+
summary: "handed to the terminal (no dashboard watching)"
|
|
14327
|
+
}
|
|
14328
|
+
});
|
|
14329
|
+
return c.json({});
|
|
14330
|
+
}
|
|
14331
|
+
if (event === "Stop" && sid && typeof raw.cwd === "string") {
|
|
14332
|
+
const d = await store.stopDecision(sid, raw.cwd);
|
|
14333
|
+
if (d)
|
|
14334
|
+
return c.json({
|
|
14335
|
+
decision: "block",
|
|
14336
|
+
reason: d.reason,
|
|
14337
|
+
hookSpecificOutput: { hookEventName: "Stop", decision: "block", reason: d.reason }
|
|
14338
|
+
});
|
|
14339
|
+
}
|
|
12693
14340
|
const answers = event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse" ? store.answerContext(sid) : null;
|
|
12694
14341
|
if (event === "PreToolUse" && !store.guardDisabled(hookRepoRoot(store, raw))) {
|
|
12695
14342
|
const guard = store.guardHook(raw);
|
|
14343
|
+
if (guard?.action === "rewrite") {
|
|
14344
|
+
const input = raw.tool_input ?? {};
|
|
14345
|
+
const second = store.evaluateTool(typeof raw.tool_name === "string" ? raw.tool_name : "Bash", { command: guard.command }, sid ?? "", typeof raw.cwd === "string" ? raw.cwd : "", false).decision;
|
|
14346
|
+
if (second.action === "deny" || second.action === "ask")
|
|
14347
|
+
return c.json({
|
|
14348
|
+
hookSpecificOutput: {
|
|
14349
|
+
hookEventName: "PreToolUse",
|
|
14350
|
+
permissionDecision: second.action,
|
|
14351
|
+
permissionDecisionReason: `[swarm] ${second.reason}`
|
|
14352
|
+
}
|
|
14353
|
+
});
|
|
14354
|
+
const note = `[swarm] rewrote the command (${guard.rule}): ${guard.reason}
|
|
14355
|
+
ran: ${guard.command}`;
|
|
14356
|
+
return c.json({
|
|
14357
|
+
hookSpecificOutput: {
|
|
14358
|
+
hookEventName: "PreToolUse",
|
|
14359
|
+
updatedInput: { ...input, command: guard.command },
|
|
14360
|
+
additionalContext: answers ? `${note}
|
|
14361
|
+
${answers}` : note
|
|
14362
|
+
}
|
|
14363
|
+
});
|
|
14364
|
+
}
|
|
12696
14365
|
if (guard) {
|
|
12697
14366
|
return c.json({
|
|
12698
14367
|
hookSpecificOutput: {
|
|
@@ -12704,13 +14373,33 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12704
14373
|
});
|
|
12705
14374
|
}
|
|
12706
14375
|
}
|
|
12707
|
-
|
|
14376
|
+
let collision = null;
|
|
14377
|
+
if (event === "PostToolUse" && sid && typeof raw.cwd === "string") {
|
|
14378
|
+
const fp = raw.tool_input?.file_path;
|
|
14379
|
+
if (WRITE_TOOLS.has(String(raw.tool_name)) && typeof fp === "string")
|
|
14380
|
+
collision = store.collisionContext(sid, raw.cwd, absolutePath(fp, raw.cwd));
|
|
14381
|
+
}
|
|
14382
|
+
const context = [collision, answers].filter(Boolean).join(`
|
|
14383
|
+
`);
|
|
14384
|
+
if (context)
|
|
12708
14385
|
return c.json({
|
|
12709
|
-
additionalContext:
|
|
12710
|
-
hookSpecificOutput: { hookEventName: event, additionalContext:
|
|
14386
|
+
additionalContext: context,
|
|
14387
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: context }
|
|
12711
14388
|
});
|
|
12712
14389
|
return c.json({});
|
|
12713
14390
|
});
|
|
14391
|
+
app.post("/v1/statusline", async (c) => {
|
|
14392
|
+
const raw = await c.req.json().catch(() => null);
|
|
14393
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
14394
|
+
return c.json({ ok: false, error: "expected the statusLine JSON object" }, 400);
|
|
14395
|
+
return c.json(store.statuslineFor(raw));
|
|
14396
|
+
});
|
|
14397
|
+
app.post("/v1/shutdown", (c) => {
|
|
14398
|
+
if (!hooks.shutdown)
|
|
14399
|
+
return c.json({ ok: false, error: "shutdown not available" }, 501);
|
|
14400
|
+
setTimeout(() => hooks.shutdown?.(), 50);
|
|
14401
|
+
return c.json({ ok: true, pid: process.pid });
|
|
14402
|
+
});
|
|
12714
14403
|
app.post("/v1/events", async (c) => {
|
|
12715
14404
|
const e = await c.req.json();
|
|
12716
14405
|
return c.json(store.append(e), 201);
|
|
@@ -12737,7 +14426,7 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12737
14426
|
});
|
|
12738
14427
|
});
|
|
12739
14428
|
});
|
|
12740
|
-
app.get("/", (c) => c.html(
|
|
14429
|
+
app.get("/", (c) => c.html(readFileSync6(join12(WEB_DIR, "index.html"), "utf8")));
|
|
12741
14430
|
app.get("/next", (c) => c.redirect("/", 308));
|
|
12742
14431
|
const MIME = {
|
|
12743
14432
|
js: "text/javascript",
|
|
@@ -12749,15 +14438,15 @@ function createApp(store = new Store, hooks = {}) {
|
|
|
12749
14438
|
const BINARY = new Set(["ico", "png"]);
|
|
12750
14439
|
app.get("/:file{[a-z0-9-]+\\.(js|css|svg|ico|png)}", (c) => {
|
|
12751
14440
|
const f = c.req.param("file");
|
|
12752
|
-
const p =
|
|
12753
|
-
if (!
|
|
14441
|
+
const p = join12(WEB_DIR, f);
|
|
14442
|
+
if (!existsSync9(p))
|
|
12754
14443
|
return c.text(`${f} not built \u2014 run: bun run build:web`, 404);
|
|
12755
14444
|
const st = statSync2(p);
|
|
12756
14445
|
const etag = `W/"${st.size.toString(16)}-${st.mtimeMs.toString(16)}"`;
|
|
12757
14446
|
if (c.req.header("if-none-match") === etag)
|
|
12758
14447
|
return c.body(null, 304, { etag });
|
|
12759
14448
|
const ext = f.split(".").pop() ?? "";
|
|
12760
|
-
return c.body(BINARY.has(ext) ?
|
|
14449
|
+
return c.body(BINARY.has(ext) ? readFileSync6(p) : readFileSync6(p, "utf8"), 200, {
|
|
12761
14450
|
"content-type": MIME[ext] ?? "text/plain",
|
|
12762
14451
|
"cache-control": "no-cache",
|
|
12763
14452
|
etag
|
|
@@ -12892,6 +14581,7 @@ var restart = () => {
|
|
|
12892
14581
|
setTimeout(() => process.exit(0), 100);
|
|
12893
14582
|
};
|
|
12894
14583
|
appHooks.restart = restart;
|
|
14584
|
+
appHooks.shutdown = () => void shutdown();
|
|
12895
14585
|
server = serve();
|
|
12896
14586
|
var port = server.port ?? DEFAULT_PORT2;
|
|
12897
14587
|
ensureToken();
|
|
@@ -12924,6 +14614,8 @@ var tailer = setInterval(() => {
|
|
|
12924
14614
|
store.sweepOrphans();
|
|
12925
14615
|
if (tick % 6 === 0)
|
|
12926
14616
|
store.checkBudgets();
|
|
14617
|
+
if (tick % 6 === 0)
|
|
14618
|
+
store.checkQuota();
|
|
12927
14619
|
if (tick % 12 === 0)
|
|
12928
14620
|
store.checkModels();
|
|
12929
14621
|
if (tick % 2 === 0)
|
|
@@ -12952,4 +14644,15 @@ async function shutdown() {
|
|
|
12952
14644
|
}
|
|
12953
14645
|
process.on("SIGINT", shutdown);
|
|
12954
14646
|
process.on("SIGTERM", shutdown);
|
|
14647
|
+
var parentPid = Number(process.env.SWARM_PARENT_PID);
|
|
14648
|
+
if (Number.isInteger(parentPid) && parentPid > 1) {
|
|
14649
|
+
setInterval(() => {
|
|
14650
|
+
try {
|
|
14651
|
+
process.kill(parentPid, 0);
|
|
14652
|
+
} catch {
|
|
14653
|
+
console.error(`swarmd: parent ${parentPid} is gone \u2014 shutting down`);
|
|
14654
|
+
shutdown();
|
|
14655
|
+
}
|
|
14656
|
+
}, 2000).unref();
|
|
14657
|
+
}
|
|
12955
14658
|
process.on("exit", () => clearDaemonInfo());
|