@taskless-app/swarm 0.1.0 → 0.2.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/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { basename as basename2 } from "path";
4
+ import { basename as basename3 } from "path";
5
5
  import { createInterface } from "readline/promises";
6
6
  import { setTimeout as sleep } from "timers/promises";
7
7
 
@@ -12,51 +12,216 @@ import { resolve as resolve2 } from "path";
12
12
 
13
13
  // src/config.ts
14
14
  import { homedir } from "os";
15
- import { join, resolve } from "path";
16
- import { mkdirSync, readFileSync, realpathSync } from "fs";
17
- var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
18
- mkdirSync(SWARM_HOME, { recursive: true });
19
- function normalizeRepo(repo) {
20
- const r = (repo ?? "").trim();
21
- if (!r) return "";
22
- try {
23
- return realpathSync(r);
24
- } catch {
25
- return resolve(r);
15
+ import { basename, join, resolve } from "path";
16
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
17
+
18
+ // ../swarm-core/src/types.ts
19
+ var MILESTONE_KINDS = [
20
+ "spawned",
21
+ "pr_opened",
22
+ "merged",
23
+ "done",
24
+ "failed",
25
+ "stalled",
26
+ "conflict",
27
+ "ci_wake",
28
+ // CI-побудка (TASWM-102) — тоже реконсайл-решение, веха как conflict
29
+ "stopped",
30
+ "superseded",
31
+ "paused",
32
+ "resumed",
33
+ "storm_start",
34
+ "storm_end",
35
+ "storm_dead",
36
+ "anomaly"
37
+ // надзор overmind (TASWM-194) — та же нотификация, что улетела в инбокс, вехой в Истории
38
+ ];
39
+ var MILESTONE_KIND_SET = new Set(MILESTONE_KINDS);
40
+ function isMilestoneKind(kind) {
41
+ return MILESTONE_KIND_SET.has(kind);
42
+ }
43
+
44
+ // ../swarm-core/src/timeline.ts
45
+ function buildSegments(created, events, laneEnd) {
46
+ const segs = [];
47
+ let cursor = created;
48
+ let phase = "queued";
49
+ const push = (to) => {
50
+ if (to > cursor) segs.push({ phase, from: cursor, to });
51
+ cursor = to;
52
+ };
53
+ for (const e of events) {
54
+ switch (e.kind) {
55
+ case "step":
56
+ if (phase === "queued") {
57
+ push(e.at);
58
+ phase = "running";
59
+ }
60
+ break;
61
+ case "blocked":
62
+ push(e.at);
63
+ phase = "blocked";
64
+ break;
65
+ case "answered":
66
+ push(e.at);
67
+ phase = "running";
68
+ break;
69
+ case "pr_opened":
70
+ push(e.at);
71
+ phase = "review";
72
+ break;
73
+ case "merged":
74
+ case "done":
75
+ push(e.at);
76
+ phase = "done";
77
+ break;
78
+ case "failed":
79
+ push(e.at);
80
+ phase = "failed";
81
+ break;
82
+ case "stalled":
83
+ push(e.at);
84
+ phase = "stalled";
85
+ break;
86
+ case "paused":
87
+ case "storm_dead":
88
+ push(e.at);
89
+ phase = "paused";
90
+ break;
91
+ case "resumed":
92
+ push(e.at);
93
+ phase = "running";
94
+ break;
95
+ }
26
96
  }
97
+ push(laneEnd);
98
+ return segs;
27
99
  }
28
- var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
29
- var WORKTREES_DIR = process.env.SWARM_WORKTREES || join(SWARM_HOME, "worktrees");
30
- var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : "sqlite";
31
- var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
32
- function envNum(name, def) {
33
- const raw = process.env[name];
34
- if (raw === void 0 || raw === "") return def;
35
- const n = Number(raw);
36
- return Number.isFinite(n) && n >= 0 ? n : def;
100
+ function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
101
+ switch (status) {
102
+ case "queued": {
103
+ const unmet = deps.filter((d) => !d.met);
104
+ if (unmet.length) {
105
+ const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
106
+ return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
107
+ }
108
+ return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
109
+ }
110
+ case "running":
111
+ return { icon: "\u25B6", text: lastStep ? `\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442: ${lastStep}` : "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", tone: "run" };
112
+ case "blocked":
113
+ return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
114
+ case "paused":
115
+ return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "paused" };
116
+ case "storm_dead":
117
+ return { icon: "\u{1F4A4}", text: "\u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u0448\u0442\u043E\u0440\u043C Anthropic, \u0436\u0434\u0451\u0442 \u043E\u0442\u0431\u043E\u044F", tone: "paused" };
118
+ case "review": {
119
+ if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
120
+ if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
121
+ if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
122
+ return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
123
+ }
124
+ case "stalled":
125
+ return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
126
+ case "failed":
127
+ return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
128
+ case "stopped":
129
+ return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
130
+ case "done":
131
+ return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
132
+ default:
133
+ return { icon: "\u2022", text: status, tone: "queued" };
134
+ }
37
135
  }
38
- var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
39
- var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
40
- var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
41
- var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
42
- var SWARM_REPO = process.env.SWARM_REPO || "";
43
- var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
44
- function tokenFromFile() {
45
- try {
46
- return readFileSync(join(SWARM_HOME, "token"), "utf8").trim();
47
- } catch {
48
- return "";
136
+ function pickHeadline(lanes) {
137
+ const live = lanes.filter((l2) => l2.live && !l2.obsolete);
138
+ const by = (pred) => live.find(pred);
139
+ const fmt = (l2, extra) => `${l2.name} \xB7 ${l2.slug} \u2014 ${extra}`;
140
+ let l = by((x) => x.status === "review" && x.ci?.tone === "fail");
141
+ if (l) return { icon: "\u274C", text: fmt(l, "CI \u0443\u043F\u0430\u043B \u2014 \u0447\u0438\u043D\u0438\u0442\u044C"), tone: "fail" };
142
+ l = by((x) => x.status === "blocked");
143
+ if (l) return { icon: "\u2753", text: fmt(l, "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430"), tone: "blocked" };
144
+ l = by((x) => x.status === "stalled");
145
+ if (l) return { icon: "\u26A0\uFE0F", text: fmt(l, "stalled \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430"), tone: "stalled" };
146
+ l = by((x) => x.status === "review" && x.blocker.tone === "merge");
147
+ if (l) return { icon: "\u{1F535}", text: fmt(l, "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430"), tone: "merge" };
148
+ l = by((x) => x.status === "queued" && x.dependsOn.some((d) => !d.met));
149
+ if (l) {
150
+ const dep = l.dependsOn.find((d) => !d.met);
151
+ return { icon: "\u26D4", text: fmt(l, `\u0436\u0434\u0451\u0442 ${dep.slug}${dep.name ? " \xB7 " + dep.name : ""}`), tone: "dep" };
49
152
  }
153
+ l = by((x) => x.status === "review");
154
+ if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
155
+ l = by((x) => x.status === "running");
156
+ if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
157
+ return null;
50
158
  }
51
- var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
52
- var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
53
- var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
54
- var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
55
- var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
56
- var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
57
- var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
159
+ var DEFAULT_RUN_MS = 25 * 6e4;
58
160
 
59
- // src/names.ts
161
+ // ../swarm-core/src/status.ts
162
+ var STATUS_META = {
163
+ queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
164
+ running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
165
+ blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
166
+ paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
167
+ // overmind был недоступен — ждёт пробуждения (TASWM-41)
168
+ review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
169
+ done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
170
+ failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
171
+ stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
172
+ // молчит/погиб, но ещё разруливается — не терминал
173
+ storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
174
+ // умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
175
+ stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
176
+ superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
177
+ };
178
+ var FALLBACK = STATUS_META.queued;
179
+ var TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
180
+ (s) => STATUS_META[s].terminal
181
+ );
182
+ function statusMeta(status) {
183
+ return STATUS_META[status] ?? FALLBACK;
184
+ }
185
+ function isTerminalStatus(status) {
186
+ return statusMeta(status).terminal;
187
+ }
188
+ function statusTuples() {
189
+ const out = {};
190
+ for (const [status, m] of Object.entries(STATUS_META)) {
191
+ out[status] = [m.pill, m.label, m.color];
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // ../swarm-core/src/time.ts
197
+ function stamp(d = /* @__PURE__ */ new Date()) {
198
+ return d.toISOString().slice(0, 19).replace("T", " ");
199
+ }
200
+ function stampFromEpochSec(sec) {
201
+ return stamp(new Date(sec * 1e3));
202
+ }
203
+ function sqliteMs(ts) {
204
+ return Date.parse(ts.replace(" ", "T") + "Z");
205
+ }
206
+ function toStamp(v) {
207
+ if (typeof v !== "string" || !v) return null;
208
+ const d = new Date(v);
209
+ if (Number.isNaN(d.getTime())) return v.includes("T") ? v.slice(0, 19).replace("T", " ") : v;
210
+ return stamp(d);
211
+ }
212
+ var DAY_MS = 864e5;
213
+
214
+ // ../swarm-core/src/windows.ts
215
+ var ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
216
+ function resolveRoyWindows(env = {}) {
217
+ return {
218
+ windowMs: Number(env.ROY_WINDOW_H || ROY_WINDOW_DEFAULTS.windowH) * 36e5,
219
+ maxTerminalLanes: Number(env.ROY_MAX_DONE || ROY_WINDOW_DEFAULTS.maxDone),
220
+ maxAxisMs: Number(env.ROY_AXIS_H || ROY_WINDOW_DEFAULTS.axisH) * 36e5
221
+ };
222
+ }
223
+
224
+ // ../swarm-core/src/names.ts
60
225
  var DRONE_POOL = [
61
226
  // Латинский бестиарий (15)
62
227
  "SCARAB",
@@ -85,7 +250,7 @@ var DRONE_POOL = [
85
250
  "LEVIATHAN",
86
251
  "SURTUR"
87
252
  ];
88
- var NAME_LIVE = /* @__PURE__ */ new Set(["queued", "running", "blocked", "review"]);
253
+ var ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
89
254
  function toRoman(n) {
90
255
  if (n < 1 || n > 3999) return String(n);
91
256
  const table = [
@@ -113,6 +278,81 @@ function genName(base2, g) {
113
278
  function baseName(name) {
114
279
  return name.split(" ")[0];
115
280
  }
281
+
282
+ // src/config.ts
283
+ var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
284
+ mkdirSync(SWARM_HOME, { recursive: true });
285
+ function normalizeRepo(repo) {
286
+ const r = (repo ?? "").trim();
287
+ if (!r) return "";
288
+ try {
289
+ return realpathSync(r);
290
+ } catch {
291
+ return resolve(r);
292
+ }
293
+ }
294
+ var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
295
+ var WORKTREES_DIR = process.env.SWARM_WORKTREES || join(SWARM_HOME, "worktrees");
296
+ var RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
297
+ function droneWorktreePath(worktreesDir, repo, slug) {
298
+ return join(worktreesDir, basename(normalizeRepo(repo)), slug);
299
+ }
300
+ var DIST_BUILD = true;
301
+ var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
302
+ var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
303
+ function envNum(name, def) {
304
+ const raw = process.env[name];
305
+ if (raw === void 0 || raw === "") return def;
306
+ const n = Number(raw);
307
+ return Number.isFinite(n) && n >= 0 ? n : def;
308
+ }
309
+ var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
310
+ var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
311
+ var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
312
+ var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
313
+ var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
314
+ var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
315
+ var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
316
+ var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
317
+ var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
318
+ var SWARM_REPO = process.env.SWARM_REPO || "";
319
+ var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
320
+ var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
321
+ var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
322
+ function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
323
+ const base2 = api.replace(/\/$/, "");
324
+ const p = path.startsWith("/") ? path : `/${path}`;
325
+ return `${base2}${p}`;
326
+ }
327
+ function tokenFromFile() {
328
+ try {
329
+ return readFileSync(join(SWARM_HOME, "token"), "utf8").trim();
330
+ } catch {
331
+ return "";
332
+ }
333
+ }
334
+ var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
335
+ var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
336
+ var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
337
+ var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
338
+ var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
339
+ var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
340
+ var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
341
+ var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
342
+ var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
343
+ var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
344
+ var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
345
+ var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
346
+ var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
347
+ var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
348
+ var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
349
+ var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
350
+ var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
351
+ var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
352
+ var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
353
+
354
+ // src/names.ts
355
+ var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
116
356
  function pickName(rows) {
117
357
  const live = /* @__PURE__ */ new Set();
118
358
  const lastUse = /* @__PURE__ */ new Map();
@@ -163,6 +403,8 @@ function decodeProbe(text) {
163
403
  }
164
404
 
165
405
  // src/db.ts
406
+ var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
407
+ var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
166
408
  var SCHEMA = `
167
409
  CREATE TABLE IF NOT EXISTS runs (
168
410
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -185,6 +427,8 @@ CREATE TABLE IF NOT EXISTS runs (
185
427
  stop_reason TEXT,
186
428
  respawns INTEGER NOT NULL DEFAULT 0,
187
429
  resolves_run_id INTEGER,
430
+ reviews_run_id INTEGER,
431
+ review_verdict TEXT,
188
432
  task_title TEXT,
189
433
  task_desc TEXT,
190
434
  task_url TEXT,
@@ -262,6 +506,8 @@ function rowToRun(row) {
262
506
  respawns: row.respawns ?? 0,
263
507
  // старые прогоны без колонки → 0
264
508
  resolves_run_id: row.resolves_run_id ?? null,
509
+ reviews_run_id: row.reviews_run_id ?? null,
510
+ review_verdict: row.review_verdict ?? null,
265
511
  task_title: row.task_title ?? null,
266
512
  task_desc: row.task_desc ?? null,
267
513
  task_url: row.task_url ?? null,
@@ -295,7 +541,11 @@ var SwarmDb = class {
295
541
  // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
296
542
  "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
297
543
  "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
298
- "ALTER TABLE events ADD COLUMN actor TEXT"
544
+ "ALTER TABLE events ADD COLUMN actor TEXT",
545
+ // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
546
+ // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
547
+ "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
548
+ "ALTER TABLE runs ADD COLUMN review_verdict TEXT"
299
549
  ]) {
300
550
  try {
301
551
  this.db.exec(alter);
@@ -315,7 +565,7 @@ var SwarmDb = class {
315
565
  createRun(input) {
316
566
  const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
317
567
  const res = this.db.prepare(
318
- "INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
568
+ "INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
319
569
  ).run(
320
570
  input.task_slug,
321
571
  name,
@@ -329,10 +579,11 @@ var SwarmDb = class {
329
579
  input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
330
580
  input.model ?? null,
331
581
  input.decision ? JSON.stringify(input.decision) : null,
332
- input.resolves_run_id ?? null
582
+ input.resolves_run_id ?? null,
583
+ input.reviews_run_id ?? null
333
584
  );
334
585
  const id = Number(res.lastInsertRowid);
335
- const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
586
+ const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
336
587
  this.addEvent(id, "spawned", spawnNote);
337
588
  return this.getRun(id);
338
589
  }
@@ -385,7 +636,7 @@ var SwarmDb = class {
385
636
  */
386
637
  activeResolverFor(reviewRunId) {
387
638
  const row = this.db.prepare(
388
- "SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN ('done','failed','superseded','stopped','stalled') ORDER BY id DESC LIMIT 1"
639
+ `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
389
640
  ).get(reviewRunId);
390
641
  return row ? rowToRun(row) : null;
391
642
  }
@@ -394,10 +645,21 @@ var SwarmDb = class {
394
645
  const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
395
646
  return row.n;
396
647
  }
648
+ /**
649
+ * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR (авто-ревью, TASWM-148) —
650
+ * one-shot идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → повторно не
651
+ * спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО вердикта) в счёт НЕ идут —
652
+ * иначе один транзиентный сбой спавна (диск/git/gh) навсегда блокировал бы ревью этого PR без
653
+ * единого шанса на повтор — тихо нарушая политику владельца «каждый PR ревьюится».
654
+ */
655
+ reviewerCountFor(reviewRunId) {
656
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled')").get(reviewRunId);
657
+ return row.n;
658
+ }
397
659
  /** Последний живой (не терминальный) прогон по slug — для swarm cancel. */
398
660
  activeRunForSlug(slug) {
399
661
  const row = this.db.prepare(
400
- "SELECT * FROM runs WHERE task_slug = ? AND status NOT IN ('done','failed','superseded','stopped') ORDER BY id DESC LIMIT 1"
662
+ `SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
401
663
  ).get(slug);
402
664
  return row ? rowToRun(row) : null;
403
665
  }
@@ -629,11 +891,10 @@ var SwarmDb = class {
629
891
  taskUrl: row.taskUrl ?? null
630
892
  });
631
893
  }
632
- const MILESTONES = /* @__PURE__ */ new Set(["spawned", "pr_opened", "merged", "done", "failed", "stalled", "conflict", "stopped", "superseded", "paused", "resumed", "storm_start", "storm_end", "storm_dead"]);
633
894
  for (const row of this.db.prepare(
634
895
  "SELECT e.*, r.task_slug slug, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM events e LEFT JOIN runs r ON r.id = e.run_id"
635
896
  ).all()) {
636
- if (!MILESTONES.has(row.kind)) continue;
897
+ if (!isMilestoneKind(row.kind)) continue;
637
898
  const global = row.run_id == null;
638
899
  items.push({
639
900
  kind: "milestone",
@@ -694,159 +955,985 @@ var SwarmDb = class {
694
955
  return row.n;
695
956
  }
696
957
  /**
697
- * Спавн-фейлов (событий failed на НЕ-резолверных прогонах) с момента cutoff.
698
- * Резолверные failed (merge-main-in) не считаем штормовым сигналом.
958
+ * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
959
+ * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
960
+ * считаем штормовым сигналом.
699
961
  */
700
962
  spawnFailCountSince(cutoffSeconds) {
701
963
  const row = this.db.prepare(
702
- "SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
964
+ "SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND r.reviews_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
703
965
  ).get(cutoffSeconds);
704
966
  return row.n;
705
967
  }
706
- /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
707
- recordStormProbe(probe, ext) {
708
- this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
968
+ /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
969
+ recordStormProbe(probe, ext) {
970
+ this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
971
+ }
972
+ /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
973
+ latestStormProbe() {
974
+ const row = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 1").get();
975
+ return row ? { rec: decodeProbe(row.text), at: row.at } : null;
976
+ }
977
+ /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
978
+ stormProbeRecordsSince(sinceIso) {
979
+ const rows = this.db.prepare("SELECT text FROM events WHERE run_id IS NULL AND kind='storm_probe' AND at >= ? ORDER BY id").all(sinceIso);
980
+ return rows.map((r) => decodeProbe(r.text));
981
+ }
982
+ };
983
+
984
+ // src/swarmClient.ts
985
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
986
+ import { dirname } from "path";
987
+ import { randomUUID } from "crypto";
988
+
989
+ // src/resources.ts
990
+ import os from "os";
991
+ import { execFileSync } from "child_process";
992
+ import { readFileSync as readFileSync2 } from "fs";
993
+ function isTransientResourceError(e) {
994
+ const code = e?.code;
995
+ return code === "ENFILE" || code === "EMFILE" || code === "EAGAIN";
996
+ }
997
+ function logTransientSpawnError(label, e) {
998
+ if (isTransientResourceError(e)) {
999
+ const code = e.code;
1000
+ console.warn(`[overmind] ${label}: \u041E\u0421 \u043D\u0435 \u0434\u0430\u0451\u0442 \u0441\u043F\u0430\u0432\u043D\u0438\u0442\u044C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u044B (${code}) \u2014 \u043F\u0440\u043E\u043F\u0443\u0441\u043A\u0430\u044E \u0441\u0438\u0433\u043D\u0430\u043B \u044D\u0442\u043E\u0433\u043E \u0442\u0438\u043A\u0430, \u043D\u0435 \u043F\u0430\u0434\u0430\u044E`);
1001
+ } else {
1002
+ console.warn(`[overmind] ${label} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F (\u043D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445 \u0437\u0430 \u044D\u0442\u043E\u0442 \u0442\u0438\u043A): ${String(e)}`);
1003
+ }
1004
+ }
1005
+
1006
+ // src/swarmClient.ts
1007
+ var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
1008
+ var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
1009
+ function runRef(runKey) {
1010
+ return { __runRef: runKey };
1011
+ }
1012
+ function isRunRef(v) {
1013
+ return typeof v === "object" && v !== null && "__runRef" in v;
1014
+ }
1015
+ function jsonFromTextContent(res) {
1016
+ const text = res?.content?.find((c) => c.type === "text")?.text;
1017
+ if (text) {
1018
+ try {
1019
+ return JSON.parse(text);
1020
+ } catch {
1021
+ }
1022
+ }
1023
+ return res?.structuredContent ?? null;
1024
+ }
1025
+ var SwarmClient = class _SwarmClient {
1026
+ call;
1027
+ defaultRepo;
1028
+ autoFlush;
1029
+ listLimit;
1030
+ // Зеркало.
1031
+ runs = /* @__PURE__ */ new Map();
1032
+ runtime = /* @__PURE__ */ new Map();
1033
+ eventsLog = [];
1034
+ questionsLog = [];
1035
+ nextRunId = 1;
1036
+ nextEventId = 1;
1037
+ nextQuestionId = 1;
1038
+ // Корреляция с продом.
1039
+ keyByLocal = /* @__PURE__ */ new Map();
1040
+ // local run id → runKey
1041
+ prodIdByKey = /* @__PURE__ */ new Map();
1042
+ // runKey → prod cuid
1043
+ seenProdEventIds = /* @__PURE__ */ new Set();
1044
+ // дедуп событий при повторной гидрации
1045
+ // Outbox / flush.
1046
+ outboxPath;
1047
+ mapPath;
1048
+ stormPath;
1049
+ // durable-сайдкар глобальных (штормовых) событий
1050
+ pending = [];
1051
+ nextSeq = 1;
1052
+ flushing = false;
1053
+ flushTimer = null;
1054
+ backoffMs = 500;
1055
+ static BACKOFF_BASE = 500;
1056
+ static BACKOFF_MAX = 3e4;
1057
+ constructor(opts) {
1058
+ this.call = opts.call;
1059
+ this.defaultRepo = normalizeRepo(opts.defaultRepo ?? "");
1060
+ this.autoFlush = opts.autoFlush ?? true;
1061
+ this.listLimit = opts.listLimit ?? 200;
1062
+ this.outboxPath = opts.outboxPath;
1063
+ this.mapPath = `${opts.outboxPath}.prodmap.json`;
1064
+ this.stormPath = `${opts.outboxPath}.storm.jsonl`;
1065
+ mkdirSync2(dirname(this.outboxPath), { recursive: true });
1066
+ this.recover();
1067
+ }
1068
+ // ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
1069
+ recover() {
1070
+ if (existsSync2(this.mapPath)) {
1071
+ try {
1072
+ const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
1073
+ for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
1074
+ } catch {
1075
+ }
1076
+ }
1077
+ if (existsSync2(this.outboxPath)) {
1078
+ const txt = readFileSync3(this.outboxPath, "utf8");
1079
+ for (const line of txt.split("\n")) {
1080
+ const s = line.trim();
1081
+ if (!s) continue;
1082
+ try {
1083
+ this.pending.push(JSON.parse(s));
1084
+ } catch {
1085
+ }
1086
+ }
1087
+ this.nextSeq = this.pending.reduce((m, e) => Math.max(m, e.seq), 0) + 1;
1088
+ if (this.autoFlush && this.pending.length) this.scheduleFlush();
1089
+ }
1090
+ this.replayGlobalEvents();
1091
+ }
1092
+ // Реплей глобальных (штормовых) событий из сайдкара в зеркало на старте. Идут в
1093
+ // eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
1094
+ // переприсваиваем локально (id событий — только порядок, не корреляция с продом).
1095
+ replayGlobalEvents() {
1096
+ if (!existsSync2(this.stormPath)) return;
1097
+ let txt;
1098
+ try {
1099
+ txt = readFileSync3(this.stormPath, "utf8");
1100
+ } catch {
1101
+ return;
1102
+ }
1103
+ for (const line of txt.split("\n")) {
1104
+ const s = line.trim();
1105
+ if (!s) continue;
1106
+ try {
1107
+ const g = JSON.parse(s);
1108
+ this.eventsLog.push({
1109
+ id: this.nextEventId++,
1110
+ run_id: null,
1111
+ kind: g.kind,
1112
+ text: g.text ?? null,
1113
+ actor: g.actor ?? null,
1114
+ at: g.at
1115
+ });
1116
+ } catch {
1117
+ }
1118
+ }
1119
+ }
1120
+ // Дописать глобальное событие в durable-сайдкар (append-only, синхронно — как outbox).
1121
+ persistGlobalEvent(ev) {
1122
+ try {
1123
+ appendFileSync(this.stormPath, JSON.stringify(ev) + "\n");
1124
+ } catch {
1125
+ }
1126
+ }
1127
+ // ── зеркало: наложить рантайм на durable-прогон ──────────────────────────────
1128
+ mat(d) {
1129
+ const rt = this.runtime.get(d.id);
1130
+ const { runKey: _k, ...rest } = d;
1131
+ return { ...rest, pid: rt?.pid ?? null, worktree_path: rt?.worktree_path ?? null };
1132
+ }
1133
+ runKeyFor(localId) {
1134
+ return this.keyByLocal.get(localId);
1135
+ }
1136
+ // cuid прода → локальный id (по runKey гидрированного прогона `p:<cuid>`).
1137
+ // Для маппинга прод heroId → локальный hero_run_id при гидрации. Резолвится, когда
1138
+ // прогон-герой присутствует в зеркале; иначе null (уточнится следующим рефрешем).
1139
+ localIdForProdId(prodId) {
1140
+ return [...this.runs.values()].find((r) => r.runKey === `p:${prodId}`)?.id;
1141
+ }
1142
+ // ── outbox: поставить операцию в очередь (зеркало уже обновлено вызывающим) ───
1143
+ enqueue(e) {
1144
+ const full = { ...e, seq: this.nextSeq++ };
1145
+ this.pending.push(full);
1146
+ try {
1147
+ appendFileSync(this.outboxPath, JSON.stringify(full) + "\n");
1148
+ } catch {
1149
+ }
1150
+ this.scheduleFlush();
1151
+ }
1152
+ // Прод-патч прогона: локальные поля Run → поля swarm_run_upsert (см. TASWM-74).
1153
+ // pid/worktree_path (рантайм), respawns (нет в схеме тула) и снимок задачи (task_*,
1154
+ // прод берёт join'ом из Task) в прод НЕ уходят. kind → role (плоский enum). Self-ref
1155
+ // прогонов hero_run_id/resolves_run_id/superseded_by → heroId/resolvesRunId/
1156
+ // supersededById как ОТЛОЖЕННЫЕ ссылки (RunRef) — cuid резолвится на флаше.
1157
+ runFields(patch) {
1158
+ const out = {};
1159
+ if ("status" in patch && patch.status !== void 0) out.status = patch.status;
1160
+ if ("area" in patch) out.area = patch.area ?? null;
1161
+ if ("model" in patch) out.model = patch.model ?? null;
1162
+ if ("name" in patch) out.droneName = patch.name ?? null;
1163
+ if ("cost_usd" in patch && patch.cost_usd !== void 0) out.costUsd = patch.cost_usd;
1164
+ if ("pr_url" in patch) out.prUrl = patch.pr_url ?? null;
1165
+ if ("branch" in patch) out.branch = patch.branch ?? null;
1166
+ if ("repo" in patch) out.repo = patch.repo ?? null;
1167
+ if ("stop_reason" in patch) out.stopReason = patch.stop_reason ?? null;
1168
+ if ("depends_on" in patch && patch.depends_on) out.dependsOn = patch.depends_on;
1169
+ if ("decision" in patch) out.decision = patch.decision ? JSON.stringify(patch.decision) : null;
1170
+ if ("kind" in patch && patch.kind !== void 0) out.role = patch.kind;
1171
+ if ("review_verdict" in patch) out.reviewVerdict = patch.review_verdict ?? null;
1172
+ this.setRunRefField(out, "heroId", patch, "hero_run_id");
1173
+ this.setRunRefField(out, "resolvesRunId", patch, "resolves_run_id");
1174
+ this.setRunRefField(out, "supersededById", patch, "superseded_by");
1175
+ this.setRunRefField(out, "reviewsRunId", patch, "reviews_run_id");
1176
+ return out;
1177
+ }
1178
+ // Положить self-ref прогона в прод-патч: локальный number → отложенная ссылка
1179
+ // {__runRef: runKey} (резолв в cuid на флаше, resolveArgs/materializeRefs). Явный
1180
+ // null → очистка поля. Локальный id без runKey (прогон не в зеркале) — пропускаем.
1181
+ setRunRefField(out, prodKey, patch, localKey) {
1182
+ if (!(localKey in patch)) return;
1183
+ const localId = patch[localKey];
1184
+ if (localId == null) {
1185
+ out[prodKey] = null;
1186
+ return;
1187
+ }
1188
+ const key = this.keyByLocal.get(localId);
1189
+ if (key) out[prodKey] = runRef(key);
1190
+ }
1191
+ enqueueRun(run, fields) {
1192
+ if (Object.keys(fields).length === 0) return;
1193
+ this.enqueue({ t: "run", runKey: run.runKey, taskRef: run.task_slug, fields });
1194
+ }
1195
+ // ── runs ─────────────────────────────────────────────────────────────────────
1196
+ createRun(input) {
1197
+ const rows = [...this.runs.values()].map((r) => ({ id: r.id, name: r.name, status: r.status }));
1198
+ const name = input.name ?? pickName(rows);
1199
+ const id = this.nextRunId++;
1200
+ const now = stamp();
1201
+ const d = {
1202
+ id,
1203
+ runKey: randomUUID(),
1204
+ task_slug: input.task_slug,
1205
+ name,
1206
+ kind: input.kind ?? "drone",
1207
+ // роль прогона: дефолт — рабочий дрон (TASWM-78)
1208
+ hero_run_id: input.hero_run_id ?? null,
1209
+ // отряд героя (задел иерархии)
1210
+ repo: normalizeRepo(input.repo || this.defaultRepo),
1211
+ branch: null,
1212
+ status: "queued",
1213
+ area: input.area ?? null,
1214
+ depends_on: input.depends_on && input.depends_on.length ? input.depends_on : [],
1215
+ model: input.model ?? null,
1216
+ cost_usd: 0,
1217
+ pr_url: null,
1218
+ superseded_by: null,
1219
+ decision: input.decision ?? null,
1220
+ stop_reason: null,
1221
+ respawns: 0,
1222
+ resolves_run_id: input.resolves_run_id ?? null,
1223
+ reviews_run_id: input.reviews_run_id ?? null,
1224
+ review_verdict: null,
1225
+ task_title: null,
1226
+ task_desc: null,
1227
+ task_url: null,
1228
+ created_at: now,
1229
+ updated_at: now
1230
+ };
1231
+ this.runs.set(id, d);
1232
+ this.keyByLocal.set(id, d.runKey);
1233
+ this.enqueueRun(d, this.runFields({
1234
+ status: "queued",
1235
+ area: d.area,
1236
+ model: d.model,
1237
+ name: d.name,
1238
+ repo: d.repo,
1239
+ depends_on: d.depends_on,
1240
+ decision: d.decision,
1241
+ kind: d.kind,
1242
+ hero_run_id: d.hero_run_id,
1243
+ resolves_run_id: d.resolves_run_id,
1244
+ // резолвер конфликта → связь на review-прогон
1245
+ reviews_run_id: d.reviews_run_id
1246
+ // ревьюер (авто-ревью, TASWM-148) → связь на review-прогон
1247
+ }));
1248
+ const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
1249
+ this.addEvent(id, "spawned", spawnNote);
1250
+ return this.mat(d);
1251
+ }
1252
+ getRun(id) {
1253
+ const d = this.runs.get(id);
1254
+ return d ? this.mat(d) : null;
1255
+ }
1256
+ listRuns(status) {
1257
+ const all = [...this.runs.values()].filter((r) => status ? r.status === status : true);
1258
+ all.sort((a, b) => b.id - a.id);
1259
+ return all.map((d) => this.mat(d));
1260
+ }
1261
+ /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
1262
+ squadRuns(heroId) {
1263
+ return [...this.runs.values()].filter((r) => r.hero_run_id === heroId).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
1264
+ }
1265
+ updateRun(id, patch) {
1266
+ const d = this.runs.get(id);
1267
+ if (!d) return null;
1268
+ if (Object.keys(patch).length === 0) return this.mat(d);
1269
+ if ("pid" in patch || "worktree_path" in patch) {
1270
+ const rt = this.runtime.get(id) ?? { pid: null, worktree_path: null };
1271
+ if ("pid" in patch) rt.pid = patch.pid ?? null;
1272
+ if ("worktree_path" in patch) rt.worktree_path = patch.worktree_path ?? null;
1273
+ this.runtime.set(id, rt);
1274
+ }
1275
+ for (const [k, v] of Object.entries(patch)) {
1276
+ if (k === "pid" || k === "worktree_path") continue;
1277
+ d[k] = v;
1278
+ }
1279
+ d.updated_at = stamp();
1280
+ this.enqueueRun(d, this.runFields(patch));
1281
+ return this.mat(d);
1282
+ }
1283
+ incRespawn(id) {
1284
+ const d = this.runs.get(id);
1285
+ if (!d) return 0;
1286
+ d.respawns += 1;
1287
+ d.updated_at = stamp();
1288
+ return d.respawns;
1289
+ }
1290
+ cancel(id, reason) {
1291
+ const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
1292
+ if (run) this.addEvent(id, "stopped", reason);
1293
+ return run;
1294
+ }
1295
+ activeResolverFor(reviewRunId) {
1296
+ const cands = [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId && !RESOLVER_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
1297
+ return cands.length ? this.mat(cands[0]) : null;
1298
+ }
1299
+ resolverCountFor(reviewRunId) {
1300
+ return [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId).length;
1301
+ }
1302
+ // failed/stalled ревьюеры (сорвался спавн ДО вердикта) в счёт НЕ идут — см. db.ts:reviewerCountFor.
1303
+ reviewerCountFor(reviewRunId) {
1304
+ return [...this.runs.values()].filter(
1305
+ (r) => r.reviews_run_id === reviewRunId && r.status !== "failed" && r.status !== "stalled"
1306
+ ).length;
1307
+ }
1308
+ activeRunForSlug(slug) {
1309
+ const cands = [...this.runs.values()].filter((r) => r.task_slug === slug && !SLUG_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
1310
+ return cands.length ? this.mat(cands[0]) : null;
1311
+ }
1312
+ runningAreas(repo) {
1313
+ const seen = /* @__PURE__ */ new Set();
1314
+ for (const r of this.runs.values()) {
1315
+ if (!(r.status === "running" || r.status === "blocked")) continue;
1316
+ if (r.area == null) continue;
1317
+ if (repo !== void 0 && r.repo !== repo) continue;
1318
+ seen.add(r.area);
1319
+ }
1320
+ return [...seen];
1321
+ }
1322
+ unmetDeps(depends_on) {
1323
+ if (!depends_on || depends_on.length === 0) return [];
1324
+ const done = /* @__PURE__ */ new Set();
1325
+ for (const r of this.runs.values()) {
1326
+ if (r.status === "done" && r.resolves_run_id == null) done.add(r.task_slug);
1327
+ }
1328
+ return depends_on.filter((slug) => !done.has(slug));
1329
+ }
1330
+ distinctRepos() {
1331
+ const seen = /* @__PURE__ */ new Set();
1332
+ for (const r of this.runs.values()) if (r.repo) seen.add(r.repo);
1333
+ return [...seen];
1334
+ }
1335
+ // ── questions ──────────────────────────────────────────────────────────────
1336
+ ask(run_id, text, options) {
1337
+ const q = {
1338
+ id: this.nextQuestionId++,
1339
+ run_id,
1340
+ text,
1341
+ options: options && options.length ? options : null,
1342
+ status: "open",
1343
+ answer: null,
1344
+ answered_by: null,
1345
+ escalated: false,
1346
+ asked_at: stamp(),
1347
+ answered_at: null
1348
+ };
1349
+ this.questionsLog.push(q);
1350
+ this.updateRun(run_id, { status: "blocked" });
1351
+ this.addEvent(run_id, "blocked", text);
1352
+ const key = this.runKeyFor(run_id);
1353
+ if (key) this.enqueue({ t: "ask", runKey: key, text });
1354
+ return q;
1355
+ }
1356
+ escalateQuestion(id) {
1357
+ const q = this.questionsLog.find((x) => x.id === id);
1358
+ if (q) q.escalated = true;
1359
+ }
1360
+ withdrawQuestion(id, reason) {
1361
+ const q = this.questionsLog.find((x) => x.id === id);
1362
+ if (q && q.status === "open") {
1363
+ q.status = "withdrawn";
1364
+ q.answer = reason;
1365
+ q.answered_at = stamp();
1366
+ }
1367
+ }
1368
+ answer(question_id, answer, by = "human") {
1369
+ const q = this.questionsLog.find((x) => x.id === question_id);
1370
+ if (q && q.status === "open") {
1371
+ q.status = "answered";
1372
+ q.answer = answer;
1373
+ q.answered_by = by;
1374
+ q.answered_at = stamp();
1375
+ this.updateRun(q.run_id, { status: "running" });
1376
+ this.addEvent(q.run_id, "answered", answer, by);
1377
+ }
1378
+ return q ? { ...q } : null;
1379
+ }
1380
+ supersede(oldId, newId, by = "human") {
1381
+ this.updateRun(oldId, { superseded_by: newId });
1382
+ this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1383
+ }
1384
+ getQuestion(id) {
1385
+ const q = this.questionsLog.find((x) => x.id === id);
1386
+ return q ? { ...q } : null;
1387
+ }
1388
+ openQuestions() {
1389
+ return this.questionsLog.filter((q) => q.status === "open").sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
1390
+ }
1391
+ runIdsWithQuestions() {
1392
+ return [...new Set(this.questionsLog.map((q) => q.run_id))];
1393
+ }
1394
+ /**
1395
+ * Открытые вопросы дронов отряда Героя #heroId (вопрос → его прогон → hero_run_id).
1396
+ * Гейт «отвечать только своим» для answer_drone + список для squad_status (TASWM-81).
1397
+ */
1398
+ openQuestionsFor(heroId) {
1399
+ return this.questionsLog.filter((q) => q.status === "open" && this.runs.get(q.run_id)?.hero_run_id === heroId).sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
1400
+ }
1401
+ // ── events / хроника ─────────────────────────────────────────────────────────
1402
+ addEvent(run_id, kind, text, actor = null) {
1403
+ const ev = { id: this.nextEventId++, run_id, kind, text: text ?? null, actor: actor ?? null, at: stamp() };
1404
+ this.eventsLog.push(ev);
1405
+ if (run_id == null) {
1406
+ this.persistGlobalEvent({ kind, text: ev.text, actor: ev.actor, at: ev.at });
1407
+ return;
1408
+ }
1409
+ const key = this.runKeyFor(run_id);
1410
+ if (key) this.enqueue({ t: "event", runKey: key, kind, text: text ?? null, actor: actor ?? null });
1411
+ }
1412
+ events(run_id) {
1413
+ return this.eventsLog.filter((e) => e.run_id === run_id).sort((a, b) => a.id - b.id).map((e) => ({ ...e }));
1414
+ }
1415
+ history(limit = 50) {
1416
+ const items = [];
1417
+ const runById = (id) => id == null ? null : this.runs.get(id) ?? null;
1418
+ const nameOf = (r, runId) => r?.name ?? `#${runId}`;
1419
+ for (const q of this.questionsLog) {
1420
+ const r = runById(q.run_id);
1421
+ items.push({
1422
+ kind: "qa",
1423
+ at: q.answered_at ?? q.asked_at,
1424
+ slug: r?.task_slug ?? "?",
1425
+ name: nameOf(r, q.run_id),
1426
+ repo: r?.repo ?? "",
1427
+ area: r?.area ?? null,
1428
+ qid: q.id,
1429
+ question: q.text,
1430
+ options: q.options,
1431
+ answer: q.answer,
1432
+ answered_by: q.answered_by,
1433
+ escalated: q.escalated,
1434
+ open: q.status === "open",
1435
+ title: r?.task_title ?? null,
1436
+ pr: r?.pr_url ?? null,
1437
+ taskUrl: r?.task_url ?? null
1438
+ });
1439
+ }
1440
+ for (const e of this.eventsLog) {
1441
+ if (!isMilestoneKind(e.kind)) continue;
1442
+ const global = e.run_id == null;
1443
+ const r = runById(e.run_id);
1444
+ items.push({
1445
+ kind: "milestone",
1446
+ at: e.at,
1447
+ slug: r?.task_slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1448
+ name: global ? "\u0440\u043E\u0439" : nameOf(r, e.run_id),
1449
+ repo: r?.repo ?? "",
1450
+ event: e.kind,
1451
+ text: e.text,
1452
+ actor: e.actor,
1453
+ title: r?.task_title ?? null,
1454
+ pr: r?.pr_url ?? null,
1455
+ taskUrl: r?.task_url ?? null
1456
+ });
1457
+ }
1458
+ items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1459
+ return items.slice(0, limit);
1460
+ }
1461
+ mergedToday() {
1462
+ const today = stamp().slice(0, 10);
1463
+ return this.eventsLog.filter((e) => e.kind === "merged" && e.at.slice(0, 10) === today).length;
1464
+ }
1465
+ // ── штормовой режим ──────────────────────────────────────────────────────────
1466
+ lastGlobal(kinds) {
1467
+ for (let i = this.eventsLog.length - 1; i >= 0; i--) {
1468
+ const e = this.eventsLog[i];
1469
+ if (e.run_id == null && kinds.includes(e.kind)) return e;
1470
+ }
1471
+ return null;
1472
+ }
1473
+ stormActive() {
1474
+ return this.lastGlobal(["storm_start", "storm_end"])?.kind === "storm_start";
1475
+ }
1476
+ currentStormStartedAt() {
1477
+ const e = this.lastGlobal(["storm_start", "storm_end"]);
1478
+ return e?.kind === "storm_start" ? e.at : null;
1479
+ }
1480
+ startStorm(reason) {
1481
+ this.addEvent(null, "storm_start", reason);
1482
+ }
1483
+ endStorm(reason) {
1484
+ this.addEvent(null, "storm_end", reason);
1485
+ }
1486
+ recordDeath(runId, note) {
1487
+ this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1488
+ }
1489
+ deathCountSince(cutoffSeconds) {
1490
+ const cutoff = stampFromEpochSec(cutoffSeconds);
1491
+ return this.eventsLog.filter((e) => e.kind === "died" && e.at >= cutoff).length;
1492
+ }
1493
+ spawnFailCountSince(cutoffSeconds) {
1494
+ const cutoff = stampFromEpochSec(cutoffSeconds);
1495
+ return this.eventsLog.filter((e) => {
1496
+ if (e.kind !== "failed" || e.run_id == null || e.at < cutoff) return false;
1497
+ const r = this.runs.get(e.run_id);
1498
+ return !!r && r.resolves_run_id == null && r.reviews_run_id == null;
1499
+ }).length;
1500
+ }
1501
+ recordStormProbe(probe, ext) {
1502
+ this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
1503
+ }
1504
+ latestStormProbe() {
1505
+ const e = this.lastGlobal(["storm_probe"]);
1506
+ return e ? { rec: decodeProbe(e.text), at: e.at } : null;
1507
+ }
1508
+ stormProbeRecordsSince(sinceIso) {
1509
+ return this.eventsLog.filter((e) => e.run_id == null && e.kind === "storm_probe" && e.at >= sinceIso).sort((a, b) => a.id - b.id).map((e) => decodeProbe(e.text));
1510
+ }
1511
+ // ── гидрация зеркала из прода (старт + реконсайл-рефреш) ──────────────────────
1512
+ async hydrate() {
1513
+ let res;
1514
+ try {
1515
+ res = await this.call("swarm_list_runs", { limit: this.listLimit });
1516
+ } catch {
1517
+ return;
1518
+ }
1519
+ if (res?.isError) return;
1520
+ const runs = res.structuredContent?.runs ?? [];
1521
+ for (const pr of runs) this.hydrateRun(pr);
1522
+ for (const pr of runs) this.linkRefs(pr);
1523
+ }
1524
+ /**
1525
+ * Реконсайл рантайма после рестарта демона (client-режим). pid/worktree_path живут в
1526
+ * ОТДЕЛЬНОЙ Map (машинное — в прод не уходят) и рестарт демона НЕ переживают: гидрация
1527
+ * из прода приносит running/blocked-прогоны с pid=null. Но демон и дроны — РАЗНЫЕ
1528
+ * detached-процессы (launcher: detached+unref), поэтому рестарт демона дронов НЕ убивает
1529
+ * — часть их ещё жива. Сопоставляем каждый running/blocked-прогон зеркала с живым
1530
+ * процессом (ps, injected listProcesses) по детерминированному worktree
1531
+ * (<worktreesDir>/<repo-basename>/<slug>):
1532
+ * • нашёлся живой процесс → восстанавливаем pid+worktree_path (дрон жив — overmind его
1533
+ * НЕ похоронит и не заспавнит второго поверх);
1534
+ * • не нашёлся → дрон не пережил рестарт: восстанавливаем worktree_path, если каталог
1535
+ * цел (для respawn --continue), pid оставляем null. overmind (tickOnce шаг 3) ловит
1536
+ * running/blocked с pid=null как смерть и оживляет/усыновляет/стопорит по общей ветке.
1537
+ * Рантайм пишем ПРЯМО в runtime-Map (в обход updateRun) — чисто локально, без outbox и
1538
+ * без прод-раунд-трипа. Идемпотентно; вызывается один раз на старте после hydrate().
1539
+ * Возвращает счётчики (для лога демона) + reconciled (TASWM-145): false → listProcesses()
1540
+ * бросил (ENFILE и т.п.), реконсайл этого запуска ПРОПУЩЕН целиком (не «confirmed пусто» —
1541
+ * см. коммент в catch ниже). Вызывающий (runDaemon) ОБЯЗАН пропустить startup-sync
1542
+ * (syncCrashedOnStartup) при reconciled=false: та трактует pid==null как «не пережил
1543
+ * рестарт» и безусловно хоронит (stalled/done) — при пропущенном реконсайле это massively
1544
+ * похоронило бы ВСЕХ running/blocked, включая реально живых detached-дронов, тем же
1545
+ * транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
1546
+ */
1547
+ reconcileRuntime(opts) {
1548
+ const wtExists = opts.worktreeExists ?? existsSync2;
1549
+ const pidByWorktree = /* @__PURE__ */ new Map();
1550
+ try {
1551
+ for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
1552
+ } catch (e) {
1553
+ logTransientSpawnError("ps (reconcileRuntime)", e);
1554
+ return { alive: 0, dead: 0, reconciled: false };
1555
+ }
1556
+ let alive = 0;
1557
+ let dead = 0;
1558
+ for (const d of this.runs.values()) {
1559
+ if (d.status !== "running" && d.status !== "blocked") continue;
1560
+ if (this.runtime.get(d.id)?.pid != null) continue;
1561
+ const workerWt = droneWorktreePath(opts.worktreesDir, d.repo || this.defaultRepo, d.task_slug);
1562
+ let matchWt = null;
1563
+ let matchPid = null;
1564
+ if (d.resolves_run_id == null && d.reviews_run_id == null) {
1565
+ const p = pidByWorktree.get(workerWt);
1566
+ if (p != null) {
1567
+ matchWt = workerWt;
1568
+ matchPid = p;
1569
+ }
1570
+ } else if (d.resolves_run_id != null) {
1571
+ const prefix = `${workerWt}-resolve-`;
1572
+ for (const [wt, p] of pidByWorktree) {
1573
+ if (wt.startsWith(prefix)) {
1574
+ matchWt = wt;
1575
+ matchPid = p;
1576
+ break;
1577
+ }
1578
+ }
1579
+ } else {
1580
+ const reviewed = this.runs.get(d.reviews_run_id);
1581
+ const reviewedWt = reviewed ? droneWorktreePath(opts.worktreesDir, reviewed.repo || this.defaultRepo, reviewed.task_slug) : workerWt;
1582
+ const prefix = `${reviewedWt}-review-`;
1583
+ for (const [wt, p] of pidByWorktree) {
1584
+ if (wt.startsWith(prefix)) {
1585
+ matchWt = wt;
1586
+ matchPid = p;
1587
+ break;
1588
+ }
1589
+ }
1590
+ }
1591
+ if (matchPid != null && matchWt) {
1592
+ this.runtime.set(d.id, { pid: matchPid, worktree_path: matchWt });
1593
+ alive++;
1594
+ } else {
1595
+ const wt = d.resolves_run_id == null && d.reviews_run_id == null && wtExists(workerWt) ? workerWt : null;
1596
+ this.runtime.set(d.id, { pid: null, worktree_path: wt });
1597
+ dead++;
1598
+ }
1599
+ }
1600
+ return { alive, dead, reconciled: true };
1601
+ }
1602
+ // Долинковать self-ref прогона по прод-cuid → локальному id (после того как все
1603
+ // прогоны гидрированы). Идемпотентно: ставит найденное, иначе не трогает.
1604
+ linkRefs(pr) {
1605
+ const localId = this.localIdForProdId(String(pr.id ?? ""));
1606
+ const local = localId === void 0 ? void 0 : this.runs.get(localId);
1607
+ if (!local) return;
1608
+ const hero = pr.heroId ?? null;
1609
+ if (hero) local.hero_run_id = this.localIdForProdId(hero) ?? local.hero_run_id;
1610
+ const res = pr.resolvesRunId ?? null;
1611
+ if (res) local.resolves_run_id = this.localIdForProdId(res) ?? local.resolves_run_id;
1612
+ const sup = pr.supersededById ?? null;
1613
+ if (sup) local.superseded_by = this.localIdForProdId(sup) ?? local.superseded_by;
1614
+ const rev = pr.reviewsRunId ?? null;
1615
+ if (rev) local.reviews_run_id = this.localIdForProdId(rev) ?? local.reviews_run_id;
1616
+ }
1617
+ /**
1618
+ * Авто-интейк (TASWM-100): взять следующую claimable-задачу владельца токена через
1619
+ * прод-тул swarm_claim_work и завести её прогон в зеркале. claim атомарен НА СЕРВЕРЕ
1620
+ * (FIFO по createdAt + pg_advisory_xact_lock + дедуп «нет активного прогона») — гонку
1621
+ * двух тиков overmind и двойной claim решает прод, клиенту делать этого не надо. Непустой
1622
+ * ответ → ГИДРИРУЕМ созданный прогон как `p:<cuid>` (тот же путь, что hydrate/реконсайл),
1623
+ * а НЕ createRun: локальный create завёл бы ВТОРОЙ прогон с create-outbox по taskRef и
1624
+ * задвоил бы зеркало с уже существующим прод-прогоном. Гидрация лишь зеркалит прод — его
1625
+ * cuid известен сразу, никакого create-flush. Возвращает локальный Run (queued; overmind
1626
+ * заспавнит его следующим тиком) либо null: нет claimable-задачи / прод недоступен / тул
1627
+ * отверг — во всех случаях тик продолжается, следующий ретраит.
1628
+ */
1629
+ async claim() {
1630
+ let res;
1631
+ try {
1632
+ res = await this.call("swarm_claim_work", {});
1633
+ } catch {
1634
+ return null;
1635
+ }
1636
+ if (res?.isError) return null;
1637
+ const sc = res.structuredContent ?? {};
1638
+ if (!sc.claimed || typeof sc.run !== "object" || sc.run === null) return null;
1639
+ const local = this.hydrateRun(sc.run);
1640
+ return local ? this.mat(local) : null;
1641
+ }
1642
+ /**
1643
+ * Живой потолок дронов владельца из Taskless (TASWM-126): читает who_am_i.maxDrones
1644
+ * (личная настройка uiPrefs.swarm.maxDrones, дефолт 3). overmind капит им ВСЕ спавны —
1645
+ * очередь (шаг 1), резолвер (шаг 2), авто-claim (шаг 5) — чтобы активных дронов не было
1646
+ * больше числа из UI (инцидент 15.07: 4-6 дронов разом уронили ноут). who_am_i отдаёт
1647
+ * плоский JSON в text-контенте (не {display,data}). Прод недоступен / нет валидного числа
1648
+ * → null: ридер (makeMaxDronesReader) берёт env-фолбэк.
1649
+ */
1650
+ async ownerMaxDrones() {
1651
+ let res;
1652
+ try {
1653
+ res = await this.call("who_am_i", {});
1654
+ } catch {
1655
+ return null;
1656
+ }
1657
+ if (res?.isError) return null;
1658
+ const obj = jsonFromTextContent(res);
1659
+ const n = obj?.maxDrones;
1660
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
1661
+ }
1662
+ /**
1663
+ * Пульс overmind в прод (TASWM-129): swarm_heartbeat обновляет User.swarmHeartbeatAt —
1664
+ * дашборд /swarm сравнивает свежесть со своим порогом устаревания («нет связи с
1665
+ * овермайндом» вместо устаревшего «живого» роя). Best-effort, как ownerMaxDrones/claim:
1666
+ * прод недоступен/тул отверг → просто не обновили в этом тике, следующий ретраит —
1667
+ * не роняем тик демона из-за упавшего пульса.
1668
+ */
1669
+ async heartbeat() {
1670
+ try {
1671
+ await this.call("swarm_heartbeat", {});
1672
+ } catch {
1673
+ }
1674
+ }
1675
+ /**
1676
+ * Встроенный надзор (TASWM-194): нотификация аномалии в инбокс владельца — прод-MCP
1677
+ * `swarm_notify`, ПРЯМОЙ вызов (НЕ outbox): аномалия — «сообщи СЕЙЧАС», не durable-факт,
1678
+ * который обязан пережить рестарт демона в неизменном порядке (как run-апдейты/события) —
1679
+ * задержка на цикл outbox-флаша (обычно доли секунды, но в худшем случае — до
1680
+ * восстановления сети) тут не оправдана.
1681
+ * runId (локальный numeric) резолвим в прод-cuid по той же карте, что flush аутбокса
1682
+ * (runKeyFor→prodIdByKey); прогон ещё не успел долететь до прода (cuid неизвестен) →
1683
+ * шлём без runId (нотификация всё равно уйдёт, просто без SwarmEvent-вехи на прогоне).
1684
+ *
1685
+ * БРОСАЕТ при сбое (транспорт ИЛИ isError:true от тула) — НЕ глотает, в отличие от
1686
+ * heartbeat() выше (code-review TASWM-194): вызывающий (overmind.ts tickOnce) коммитит
1687
+ * AnomalyTracker ТОЛЬКО после успешного return — молча проглоченная ошибка означала бы,
1688
+ * что трекер считает недоставленную нотификацию отправленной и молчит renotifyCooldownMs
1689
+ * (по умолчанию час) без единой реальной попытки. `res?.isError` — тот же паттерн, что
1690
+ * ownerMaxDrones() чуть выше: registerTool ловит throw внутри run() и превращает его в
1691
+ * `{isError:true}`, а не в отклонённый промис — без этой проверки схемная/бизнес-ошибка
1692
+ * тула (напр. TASWM-194 review: body > zod max) молча резолвилась бы успехом.
1693
+ */
1694
+ async notifyAnomaly(signal) {
1695
+ const prodRunId = signal.runId != null ? this.prodIdByKey.get(this.runKeyFor(signal.runId) ?? "") : void 0;
1696
+ const res = await this.call("swarm_notify", {
1697
+ tag: signal.key,
1698
+ title: signal.title,
1699
+ body: signal.body,
1700
+ ...signal.href ? { href: signal.href } : {},
1701
+ ...prodRunId ? { runId: prodRunId } : {}
1702
+ });
1703
+ if (res?.isError) throw new Error(`swarm_notify \u043E\u0442\u0432\u0435\u0440\u0433 \xAB${signal.key}\xBB`);
1704
+ }
1705
+ hydrateRun(pr) {
1706
+ const prodId = String(pr.id ?? "");
1707
+ if (!prodId) return null;
1708
+ const runKey = `p:${prodId}`;
1709
+ this.prodIdByKey.set(runKey, prodId);
1710
+ let local = [...this.runs.values()].find((r) => r.runKey === runKey);
1711
+ if (!local) local = [...this.runs.values()].find((r) => this.prodIdByKey.get(r.runKey) === prodId);
1712
+ if (!local) {
1713
+ const id = this.nextRunId++;
1714
+ local = {
1715
+ id,
1716
+ runKey,
1717
+ task_slug: "",
1718
+ name: `#${id}`,
1719
+ kind: "drone",
1720
+ hero_run_id: null,
1721
+ repo: "",
1722
+ branch: null,
1723
+ status: "queued",
1724
+ area: null,
1725
+ depends_on: [],
1726
+ model: null,
1727
+ cost_usd: 0,
1728
+ pr_url: null,
1729
+ superseded_by: null,
1730
+ decision: null,
1731
+ stop_reason: null,
1732
+ respawns: 0,
1733
+ resolves_run_id: null,
1734
+ reviews_run_id: null,
1735
+ review_verdict: null,
1736
+ task_title: null,
1737
+ task_desc: null,
1738
+ task_url: null,
1739
+ created_at: stamp(),
1740
+ updated_at: stamp()
1741
+ };
1742
+ this.runs.set(id, local);
1743
+ this.keyByLocal.set(id, runKey);
1744
+ }
1745
+ const slug = pr.taskSlug ?? pr.task?.slug ?? pr.taskId ?? local.task_slug;
1746
+ local.task_slug = slug || local.task_slug;
1747
+ local.task_title = pr.taskTitle ?? local.task_title;
1748
+ local.task_desc = pr.taskDesc ?? local.task_desc;
1749
+ local.name = pr.droneName ?? local.name;
1750
+ local.kind = pr.role ?? local.kind;
1751
+ const heroProd = pr.heroId ?? null;
1752
+ if (heroProd) local.hero_run_id = this.localIdForProdId(heroProd) ?? local.hero_run_id;
1753
+ const resProd = pr.resolvesRunId ?? null;
1754
+ if (resProd) local.resolves_run_id = this.localIdForProdId(resProd) ?? local.resolves_run_id;
1755
+ const supProd = pr.supersededById ?? null;
1756
+ if (supProd) local.superseded_by = this.localIdForProdId(supProd) ?? local.superseded_by;
1757
+ const revProd = pr.reviewsRunId ?? null;
1758
+ if (revProd) local.reviews_run_id = this.localIdForProdId(revProd) ?? local.reviews_run_id;
1759
+ local.review_verdict = pr.reviewVerdict ?? local.review_verdict;
1760
+ local.repo = pr.repo ?? "";
1761
+ local.branch = pr.branch ?? null;
1762
+ local.status = pr.status ?? local.status;
1763
+ local.area = pr.area ?? null;
1764
+ local.depends_on = Array.isArray(pr.dependsOn) ? pr.dependsOn : [];
1765
+ local.model = pr.model ?? null;
1766
+ local.cost_usd = typeof pr.costUsd === "number" ? pr.costUsd : 0;
1767
+ local.pr_url = pr.prUrl ?? null;
1768
+ local.stop_reason = pr.stopReason ?? null;
1769
+ local.respawns = typeof pr.respawns === "number" ? pr.respawns : 0;
1770
+ local.decision = parseDecision(pr.decision);
1771
+ local.created_at = toStamp(pr.createdAt) ?? local.created_at;
1772
+ local.updated_at = toStamp(pr.updatedAt) ?? local.updated_at;
1773
+ const evs = pr.events ?? [];
1774
+ for (const pe of evs) {
1775
+ const eid = String(pe.id ?? "");
1776
+ if (eid && this.seenProdEventIds.has(eid)) continue;
1777
+ if (eid) this.seenProdEventIds.add(eid);
1778
+ const kind = String(pe.kind ?? "");
1779
+ const text = pe.text ?? null;
1780
+ const at = toStamp(pe.at) ?? stamp();
1781
+ if (kind === "question") {
1782
+ this.questionsLog.push({
1783
+ id: this.nextQuestionId++,
1784
+ run_id: local.id,
1785
+ text: text ?? "",
1786
+ options: null,
1787
+ status: "open",
1788
+ answer: null,
1789
+ answered_by: null,
1790
+ escalated: false,
1791
+ asked_at: at,
1792
+ answered_at: null
1793
+ });
1794
+ continue;
1795
+ }
1796
+ const localKind = kind === "answer" ? "answered" : kind;
1797
+ this.eventsLog.push({
1798
+ id: this.nextEventId++,
1799
+ run_id: local.id,
1800
+ kind: localKind,
1801
+ text,
1802
+ actor: pe.actor ?? null,
1803
+ at
1804
+ });
1805
+ if (kind === "answer" || kind === "answered") {
1806
+ const open = [...this.questionsLog].reverse().find((q) => q.run_id === local.id && q.status === "open");
1807
+ if (open) {
1808
+ open.status = "answered";
1809
+ open.answer = text;
1810
+ open.answered_by = pe.actor ?? "human";
1811
+ open.answered_at = at;
1812
+ }
1813
+ }
1814
+ }
1815
+ return local;
1816
+ }
1817
+ // ── flush outbox в прод ──────────────────────────────────────────────────────
1818
+ scheduleFlush() {
1819
+ if (!this.autoFlush || this.flushing || this.flushTimer || this.pending.length === 0) return;
1820
+ this.flushTimer = setTimeout(() => {
1821
+ this.flushTimer = null;
1822
+ void this.flush();
1823
+ }, this.backoffMs);
1824
+ if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
709
1825
  }
710
- /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
711
- latestStormProbe() {
712
- const row = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 1").get();
713
- return row ? { rec: decodeProbe(row.text), at: row.at } : null;
1826
+ resolveArgs(e) {
1827
+ if (e.t === "run") {
1828
+ const fields = this.materializeRefs(e.fields);
1829
+ const prodId2 = this.prodIdByKey.get(e.runKey);
1830
+ return prodId2 ? { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } } : { tool: "swarm_run_upsert", args: { taskId: e.taskRef, ...fields } };
1831
+ }
1832
+ const prodId = this.prodIdByKey.get(e.runKey);
1833
+ if (!prodId) throw new Error(`\u043D\u0435\u0442 prod-id \u0434\u043B\u044F ${e.runKey} \u2014 \u043F\u0440\u043E\u0433\u043E\u043D \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D \u0432 \u043F\u0440\u043E\u0434\u0435`);
1834
+ if (e.t === "event") return { tool: "swarm_event_append", args: { runId: prodId, kind: e.kind, text: e.text, actor: e.actor } };
1835
+ return { tool: "swarm_ask", args: { runId: prodId, text: e.text } };
714
1836
  }
715
- /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
716
- stormProbeRecordsSince(sinceIso) {
717
- const rows = this.db.prepare("SELECT text FROM events WHERE run_id IS NULL AND kind='storm_probe' AND at >= ? ORDER BY id").all(sinceIso);
718
- return rows.map((r) => decodeProbe(r.text));
1837
+ // Резолв отложенных self-ref ({__runRef: runKey}) в прод-cuid на флаше. Цель уже
1838
+ // создана в проде (её create-запись с меньшим seq флашится раньше) → cuid известен.
1839
+ // Не резолвится (ещё не создан) — поле выкидываем: лучше не слать, чем слать мусор;
1840
+ // догонит следующий апдейт/реконсайл. Обычные значения (null/строки) — как есть.
1841
+ materializeRefs(fields) {
1842
+ const out = {};
1843
+ for (const [k, v] of Object.entries(fields)) {
1844
+ if (isRunRef(v)) {
1845
+ const cuid = this.prodIdByKey.get(v.__runRef);
1846
+ if (cuid) out[k] = cuid;
1847
+ } else {
1848
+ out[k] = v;
1849
+ }
1850
+ }
1851
+ return out;
719
1852
  }
720
- };
721
-
722
- // src/web.ts
723
- import { randomBytes as randomBytes2 } from "crypto";
724
- import { basename } from "path";
725
- import { serve } from "@hono/node-server";
726
- import { $ as $2 } from "execa";
727
-
728
- // ../swarm-core/src/timeline.ts
729
- function buildSegments(created, events, laneEnd) {
730
- const segs = [];
731
- let cursor = created;
732
- let phase = "queued";
733
- const push = (to) => {
734
- if (to > cursor) segs.push({ phase, from: cursor, to });
735
- cursor = to;
736
- };
737
- for (const e of events) {
738
- switch (e.kind) {
739
- case "step":
740
- if (phase === "queued") {
741
- push(e.at);
742
- phase = "running";
1853
+ /** Слить накопленное в прод. Возвращает число успешно отправленных операций.
1854
+ * Порядок сохраняется; ошибка/недоступность → стоп, буфер остаётся, retry-backoff. */
1855
+ async flush() {
1856
+ if (this.flushing) return 0;
1857
+ this.flushing = true;
1858
+ let sent = 0;
1859
+ try {
1860
+ while (this.pending.length) {
1861
+ const e = this.pending[0];
1862
+ let call;
1863
+ try {
1864
+ call = this.resolveArgs(e);
1865
+ } catch {
1866
+ break;
743
1867
  }
744
- break;
745
- case "blocked":
746
- push(e.at);
747
- phase = "blocked";
748
- break;
749
- case "answered":
750
- push(e.at);
751
- phase = "running";
752
- break;
753
- case "pr_opened":
754
- push(e.at);
755
- phase = "review";
756
- break;
757
- case "merged":
758
- case "done":
759
- push(e.at);
760
- phase = "done";
761
- break;
762
- case "failed":
763
- push(e.at);
764
- phase = "failed";
765
- break;
766
- case "stalled":
767
- push(e.at);
768
- phase = "stalled";
769
- break;
1868
+ let res;
1869
+ try {
1870
+ res = await this.call(call.tool, call.args);
1871
+ } catch {
1872
+ break;
1873
+ }
1874
+ if (res?.isError) break;
1875
+ if (e.t === "run") {
1876
+ const run = res.structuredContent?.run;
1877
+ if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
1878
+ this.prodIdByKey.set(e.runKey, run.id);
1879
+ this.persistMap();
1880
+ }
1881
+ } else {
1882
+ const evId = res.structuredContent?.event?.id;
1883
+ if (evId) this.seenProdEventIds.add(evId);
1884
+ }
1885
+ this.pending.shift();
1886
+ sent++;
1887
+ }
1888
+ this.persistPending();
1889
+ if (this.pending.length === 0) {
1890
+ this.backoffMs = _SwarmClient.BACKOFF_BASE;
1891
+ } else {
1892
+ this.backoffMs = Math.min(this.backoffMs * 2, _SwarmClient.BACKOFF_MAX);
1893
+ this.scheduleFlush();
1894
+ }
1895
+ } finally {
1896
+ this.flushing = false;
770
1897
  }
1898
+ return sent;
771
1899
  }
772
- push(laneEnd);
773
- return segs;
774
- }
775
- function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
776
- switch (status) {
777
- case "queued": {
778
- const unmet = deps.filter((d) => !d.met);
779
- if (unmet.length) {
780
- const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
781
- return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
782
- }
783
- return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
1900
+ persistPending() {
1901
+ try {
1902
+ writeFileSync2(this.outboxPath, this.pending.map((e) => JSON.stringify(e)).join("\n") + (this.pending.length ? "\n" : ""));
1903
+ } catch {
784
1904
  }
785
- case "running":
786
- return { icon: "\u25B6", text: lastStep ? `\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442: ${lastStep}` : "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", tone: "run" };
787
- case "blocked":
788
- return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
789
- case "paused":
790
- return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "blocked" };
791
- case "review": {
792
- if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
793
- if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
794
- if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
795
- return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
1905
+ }
1906
+ persistMap() {
1907
+ try {
1908
+ writeFileSync2(this.mapPath, JSON.stringify(Object.fromEntries(this.prodIdByKey)));
1909
+ } catch {
796
1910
  }
797
- case "stalled":
798
- return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
799
- case "failed":
800
- return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
801
- case "stopped":
802
- return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
803
- case "done":
804
- return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
805
- default:
806
- return { icon: "\u2022", text: status, tone: "queued" };
807
1911
  }
808
- }
809
- function pickHeadline(lanes) {
810
- const live = lanes.filter((l2) => l2.live && !l2.obsolete);
811
- const by = (pred) => live.find(pred);
812
- const fmt = (l2, extra) => `${l2.name} \xB7 ${l2.slug} \u2014 ${extra}`;
813
- let l = by((x) => x.status === "review" && x.ci?.tone === "fail");
814
- if (l) return { icon: "\u274C", text: fmt(l, "CI \u0443\u043F\u0430\u043B \u2014 \u0447\u0438\u043D\u0438\u0442\u044C"), tone: "fail" };
815
- l = by((x) => x.status === "blocked");
816
- if (l) return { icon: "\u2753", text: fmt(l, "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430"), tone: "blocked" };
817
- l = by((x) => x.status === "stalled");
818
- if (l) return { icon: "\u26A0\uFE0F", text: fmt(l, "stalled \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430"), tone: "stalled" };
819
- l = by((x) => x.status === "review" && x.blocker.tone === "merge");
820
- if (l) return { icon: "\u{1F535}", text: fmt(l, "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430"), tone: "merge" };
821
- l = by((x) => x.status === "queued" && x.dependsOn.some((d) => !d.met));
822
- if (l) {
823
- const dep = l.dependsOn.find((d) => !d.met);
824
- return { icon: "\u26D4", text: fmt(l, `\u0436\u0434\u0451\u0442 ${dep.slug}${dep.name ? " \xB7 " + dep.name : ""}`), tone: "dep" };
1912
+ /** Незалитых операций в буфере (для тестов/наблюдаемости). */
1913
+ pendingCount() {
1914
+ return this.pending.length;
1915
+ }
1916
+ close() {
1917
+ if (this.flushTimer) {
1918
+ clearTimeout(this.flushTimer);
1919
+ this.flushTimer = null;
1920
+ }
1921
+ void this.flush();
1922
+ }
1923
+ };
1924
+ function parseDecision(v) {
1925
+ if (!v || typeof v !== "string") return null;
1926
+ try {
1927
+ return JSON.parse(v);
1928
+ } catch {
1929
+ return null;
825
1930
  }
826
- l = by((x) => x.status === "review");
827
- if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
828
- l = by((x) => x.status === "running");
829
- if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
830
- return null;
831
1931
  }
832
1932
 
833
- // ../swarm-core/src/status.ts
834
- var STATUS_META = {
835
- queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground" },
836
- running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary" },
837
- blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning" },
838
- paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning" },
839
- // overmind был недоступен — ждёт пробуждения (TASWM-41)
840
- review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info" },
841
- done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success" },
842
- failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive" },
843
- stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive" },
844
- storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning" },
845
- // умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
846
- stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground" },
847
- superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground" }
848
- };
849
- var FALLBACK = STATUS_META.queued;
1933
+ // src/web.ts
1934
+ import { randomBytes as randomBytes2 } from "crypto";
1935
+ import { basename as basename2 } from "path";
1936
+ import { serve } from "@hono/node-server";
850
1937
 
851
1938
  // src/diff.ts
852
1939
  import { statSync } from "fs";
@@ -1027,9 +2114,49 @@ async function collectRunDiff(worktree, opts = {}) {
1027
2114
  };
1028
2115
  }
1029
2116
 
2117
+ // src/gh.ts
2118
+ import { $ as $2 } from "execa";
2119
+ var realChecksRunner = async (pr) => (await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
2120
+ async function prCheckRows(pr, run) {
2121
+ try {
2122
+ const out = await run(pr);
2123
+ const rows = JSON.parse(out);
2124
+ if (!Array.isArray(rows) || rows.length === 0) return null;
2125
+ return rows.map((r) => ({ name: r.name ?? "?", bucket: (r.bucket ?? "").toLowerCase(), link: r.link ?? "" }));
2126
+ } catch {
2127
+ return null;
2128
+ }
2129
+ }
2130
+ async function ghChecks(pr, run = realChecksRunner) {
2131
+ const rows = await prCheckRows(pr, run);
2132
+ if (!rows) return null;
2133
+ let pass = 0, fail = 0, pending2 = 0;
2134
+ for (const r of rows) {
2135
+ if (r.bucket === "pass") pass++;
2136
+ else if (r.bucket === "fail" || r.bucket === "cancel") fail++;
2137
+ else if (r.bucket === "pending") pending2++;
2138
+ }
2139
+ const total = rows.length;
2140
+ let bucket, text, tone;
2141
+ if (fail > 0) {
2142
+ bucket = "fail";
2143
+ tone = "fail";
2144
+ text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
2145
+ } else if (pending2 > 0) {
2146
+ bucket = "pending";
2147
+ tone = "review";
2148
+ text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
2149
+ } else {
2150
+ bucket = "pass";
2151
+ tone = "merge";
2152
+ text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
2153
+ }
2154
+ return { bucket, text, tone, total, pass, fail, pending: pending2 };
2155
+ }
2156
+
1030
2157
  // src/oauth.ts
1031
2158
  import { createHash, randomBytes } from "crypto";
1032
- import { readFileSync as readFileSync2, writeFileSync, unlinkSync } from "fs";
2159
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync } from "fs";
1033
2160
  import { join as join3 } from "path";
1034
2161
  var REGISTER = "/api/oauth/register";
1035
2162
  var AUTHORIZE = "/api/oauth/authorize";
@@ -1121,13 +2248,13 @@ function toStore(clientId, t, now) {
1121
2248
  }
1122
2249
  function loadStore() {
1123
2250
  try {
1124
- return JSON.parse(readFileSync2(STORE_PATH, "utf8"));
2251
+ return JSON.parse(readFileSync4(STORE_PATH, "utf8"));
1125
2252
  } catch {
1126
2253
  return null;
1127
2254
  }
1128
2255
  }
1129
2256
  function saveStore(s) {
1130
- writeFileSync(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
2257
+ writeFileSync3(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
1131
2258
  }
1132
2259
  function clearStore() {
1133
2260
  try {
@@ -1137,7 +2264,7 @@ function clearStore() {
1137
2264
  }
1138
2265
  function loadClientId() {
1139
2266
  try {
1140
- return JSON.parse(readFileSync2(CLIENT_PATH, "utf8")).clientId;
2267
+ return JSON.parse(readFileSync4(CLIENT_PATH, "utf8")).clientId;
1141
2268
  } catch {
1142
2269
  return null;
1143
2270
  }
@@ -1146,7 +2273,7 @@ async function ensureClientId(api, redirectUri, fetchFn = fetch) {
1146
2273
  const existing = loadClientId();
1147
2274
  if (existing) return existing;
1148
2275
  const clientId = await registerClient({ api, redirectUri }, fetchFn);
1149
- writeFileSync(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
2276
+ writeFileSync3(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
1150
2277
  return clientId;
1151
2278
  }
1152
2279
  async function ensureAccess(store, opts = {}) {
@@ -1168,13 +2295,11 @@ async function ensureAccess(store, opts = {}) {
1168
2295
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1169
2296
  var jsonForScript = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
1170
2297
  var TASKLESS_MARK = `<svg viewBox="0 0 60 54" width="19" height="19" fill="none" aria-hidden="true" style="display:block"><g transform="matrix(1.0512,0,0,1.0512,0,-0.021)"><path fill="currentColor" fill-rule="nonzero" d="M46.92,0 L10.24,0 C4.59,0 0,4.59 0,10.24 L0,10.4 C0,16.05 4.59,20.64 10.24,20.64 L16.84,20.64 C19.56,20.64 22.12,19.59 24.05,17.67 L36.06,5.77 C37.25,4.6 38.82,3.95 40.49,3.95 L46.93,3.95 C50.4,3.95 53.22,6.77 53.22,10.24 L53.22,10.4 C53.22,13.87 50.4,16.69 46.93,16.69 L39.82,16.69 C37.05,16.69 34.36,17.83 32.44,19.83 L21.13,31.58 C19.28,33.5 18.27,36.02 18.27,38.68 L18.27,40.94 C18.27,46.59 22.86,51.18 28.51,51.18 C34.16,51.18 38.75,46.59 38.75,40.94 L38.75,20.74 C39.1,20.68 39.46,20.64 39.82,20.64 L46.93,20.64 C52.58,20.64 57.17,16.05 57.17,10.4 L57.17,10.24 C57.17,4.59 52.58,0 46.93,0 L46.92,0 Z M21.26,14.87 C20.07,16.04 18.5,16.69 16.83,16.69 L10.23,16.69 C6.76,16.69 3.94,13.87 3.94,10.4 L3.94,10.24 C3.94,6.77 6.76,3.95 10.23,3.95 L32.27,3.95 L21.25,14.87 L21.26,14.87 Z M34.79,40.94 C34.79,44.41 31.97,47.23 28.5,47.23 C25.03,47.23 22.21,44.41 22.21,40.94 L22.21,38.68 C22.21,37.05 22.83,35.5 23.97,34.32 L34.79,23.07 L34.79,40.94 L34.79,40.94 Z"/></g></svg>`;
1171
- var DAY_MS = 24 * 60 * 60 * 1e3;
1172
- var sqliteMs = (ts) => Date.parse(ts.replace(" ", "T") + "Z");
1173
2298
  var sqliteIso = (ts) => {
1174
2299
  const ms = sqliteMs(ts);
1175
2300
  return Number.isNaN(ms) ? ts : new Date(ms).toISOString();
1176
2301
  };
1177
- function roySnapshot(db2, now = Date.now()) {
2302
+ function roySnapshot(db2, now = Date.now(), overmindLog = []) {
1178
2303
  const all = db2.listRuns();
1179
2304
  const c = (s) => all.filter((r) => r.status === s).length;
1180
2305
  const withQ = new Set(db2.runIdsWithQuestions());
@@ -1244,13 +2369,15 @@ function roySnapshot(db2, now = Date.now()) {
1244
2369
  active: db2.stormActive(),
1245
2370
  since: db2.currentStormStartedAt(),
1246
2371
  dead: all.filter((r) => r.status === "storm_dead").length
1247
- }
2372
+ },
2373
+ overmindLog
1248
2374
  };
1249
2375
  }
1250
- var TL_RECENT_TERMINAL_MS = Number(process.env.ROY_WINDOW_H || 5) * 36e5;
1251
- var TL_MAX_TERMINAL_LANES = Number(process.env.ROY_MAX_DONE || 6);
1252
- var TL_MAX_AXIS_MS = Number(process.env.ROY_AXIS_H || 6) * 36e5;
1253
- var TL_TERMINAL = /* @__PURE__ */ new Set(["done", "failed", "stopped", "superseded"]);
2376
+ var {
2377
+ windowMs: TL_RECENT_TERMINAL_MS,
2378
+ maxTerminalLanes: TL_MAX_TERMINAL_LANES,
2379
+ maxAxisMs: TL_MAX_AXIS_MS
2380
+ } = resolveRoyWindows(process.env);
1254
2381
  var ciCache = /* @__PURE__ */ new Map();
1255
2382
  var CI_TTL = 2e4;
1256
2383
  var CI_MOCK = (() => {
@@ -1266,50 +2393,18 @@ function mockCi(bucket) {
1266
2393
  if (b === "pending") return { bucket: "pending", tone: "review", text: "\u23F3 CI \u0438\u0434\u0451\u0442 (1/2)", total: 2, pass: 1, fail: 0, pending: 1 };
1267
2394
  return { bucket: "pass", tone: "merge", text: "\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443", total: 2, pass: 2, fail: 0, pending: 0 };
1268
2395
  }
1269
- async function ghChecks(pr) {
1270
- try {
1271
- const { stdout: out } = await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`;
1272
- const rows = JSON.parse(out);
1273
- if (!Array.isArray(rows) || rows.length === 0) return null;
1274
- let pass = 0, fail = 0, pending2 = 0;
1275
- for (const r of rows) {
1276
- const b = (r.bucket || "").toLowerCase();
1277
- if (b === "pass") pass++;
1278
- else if (b === "fail" || b === "cancel") fail++;
1279
- else if (b === "pending") pending2++;
1280
- }
1281
- const total = rows.length;
1282
- let bucket, text, tone;
1283
- if (fail > 0) {
1284
- bucket = "fail";
1285
- tone = "fail";
1286
- text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
1287
- } else if (pending2 > 0) {
1288
- bucket = "pending";
1289
- tone = "review";
1290
- text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
1291
- } else {
1292
- bucket = "pass";
1293
- tone = "merge";
1294
- text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
1295
- }
1296
- return { bucket, text, tone, total, pass, fail, pending: pending2 };
1297
- } catch {
1298
- return null;
1299
- }
1300
- }
1301
2396
  async function refreshCI(prs) {
1302
2397
  await Promise.all(prs.map(async (pr) => {
1303
2398
  const ci = await ghChecks(pr);
1304
2399
  ciCache.set(pr, { ci, at: Date.now() });
1305
2400
  }));
1306
2401
  }
1307
- function timelineState(db2, now = Date.now()) {
2402
+ function timelineState(db2, now = Date.now(), overmindLog = []) {
1308
2403
  const runs = db2.listRuns().slice().sort((a, b) => a.id - b.id);
1309
2404
  const doneNameBySlug = /* @__PURE__ */ new Map();
1310
2405
  const activeBySlug = /* @__PURE__ */ new Map();
1311
2406
  for (const r of runs) {
1312
- if (!TL_TERMINAL.has(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
2407
+ if (!isTerminalStatus(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
1313
2408
  if (r.status === "done") doneNameBySlug.set(r.task_slug, r.name);
1314
2409
  }
1315
2410
  const openQ = /* @__PURE__ */ new Map();
@@ -1319,7 +2414,7 @@ function timelineState(db2, now = Date.now()) {
1319
2414
  const lanes = [];
1320
2415
  const reviewPRs = [];
1321
2416
  for (const r of runs) {
1322
- const live = !TL_TERMINAL.has(r.status);
2417
+ const live = !isTerminalStatus(r.status);
1323
2418
  const recencyGated = !live || r.status === "stalled";
1324
2419
  if (recencyGated && sqliteMs(r.updated_at) < cutoff) continue;
1325
2420
  const events = db2.events(r.id).map((e) => ({ kind: e.kind, text: e.text ?? null, at: sqliteIso(e.at) }));
@@ -1347,7 +2442,7 @@ function timelineState(db2, now = Date.now()) {
1347
2442
  id: r.id,
1348
2443
  name: r.name,
1349
2444
  slug: r.task_slug,
1350
- repo: basename(r.repo || "") || "?",
2445
+ repo: basename2(r.repo || "") || "?",
1351
2446
  status: r.status,
1352
2447
  area: r.area,
1353
2448
  start: sqliteIso(r.created_at),
@@ -1361,7 +2456,10 @@ function timelineState(db2, now = Date.now()) {
1361
2456
  ci,
1362
2457
  pr: r.pr_url,
1363
2458
  dependsOn: deps,
1364
- cost: r.cost_usd || 0
2459
+ cost: r.cost_usd || 0,
2460
+ role: r.kind,
2461
+ heroRunId: r.hero_run_id,
2462
+ reviewVerdict: r.review_verdict
1365
2463
  });
1366
2464
  }
1367
2465
  const liveLanes = lanes.filter((l) => l.live);
@@ -1391,7 +2489,7 @@ function timelineState(db2, now = Date.now()) {
1391
2489
  const headline = pickHeadline(shown);
1392
2490
  const stale = [...new Set(reviewPRs)].filter((pr) => now - (ciCache.get(pr)?.at ?? 0) > CI_TTL);
1393
2491
  if (stale.length) void refreshCI(stale);
1394
- return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown };
2492
+ return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown, overmindLog };
1395
2493
  }
1396
2494
  var CSS = `
1397
2495
  :root {
@@ -1529,7 +2627,7 @@ main { max-width: 1080px; margin: 0 auto; width: 100%; }
1529
2627
  .pill .sd { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
1530
2628
  .pill.run { color: var(--primary); background: var(--primary-soft); } .pill.run .sd { animation: pulse 1.8s infinite; }
1531
2629
  .pill.review { color: var(--info); background: color-mix(in oklch, var(--info) 14%, transparent); }
1532
- .pill.blocked { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
2630
+ .pill.blocked, .pill.paused, .pill.storm { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
1533
2631
  .pill.done { color: var(--success); background: color-mix(in oklch, var(--success) 13%, transparent); }
1534
2632
  .pill.stalled, .pill.failed { color: var(--destructive); background: color-mix(in oklch, var(--destructive) 14%, transparent); }
1535
2633
  .pill.stopped, .pill.queued, .pill.superseded { color: var(--muted-foreground); background: var(--secondary); }
@@ -1822,9 +2920,11 @@ var GLYPH = {
1822
2920
  hex:'<path d="M12 2l8 4.6v9.2L12 21l-8-5.2V6.6L12 2z" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linejoin="round"/><circle cx="12" cy="12" r="2" fill="currentColor"/>'
1823
2921
  };
1824
2922
  var GK = ['claw','spore','eye','hex'];
1825
- var ST = { running:['run','\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442','--primary'], review:['review','\u043D\u0430 \u0440\u0435\u0432\u044C\u044E','--info'], blocked:['blocked','\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430','--warning'], paused:['blocked','\u0437\u0430\u043C\u0435\u0440 \u2014 \u0436\u0434\u0451\u0442 overmind','--warning'], storm_dead:['blocked','\u0432 \u043A\u043E\u043A\u043E\u043D\u0435 (\u0448\u0442\u043E\u0440\u043C)','--warning'], done:['done','\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E','--success'], stalled:['stalled','\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441','--destructive'], failed:['failed','\u0443\u043F\u0430\u043B','--destructive'], stopped:['stopped','\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D','--muted-foreground'], queued:['queued','\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438','--muted-foreground'], superseded:['superseded','\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D','--muted-foreground'] };
2923
+ /* \u041A\u0430\u0440\u0442\u0430 \u0441\u0442\u0430\u0442\u0443\u0441\u2192[pill,label,color] \u0438 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u043E\u0441\u0442\u044C \u2014 \u0438\u0437 swarm-core (statusTuples/
2924
+ TERMINAL_STATUSES), \u0430 \u043D\u0435 \u0445\u0430\u0440\u0434\u043A\u043E\u0434-\u043A\u043E\u043F\u0438\u044F (\u043E\u043D\u0430 \u0443\u0436\u0435 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u043B\u0430\u0441\u044C: paused/storm_dead). */
2925
+ var ST = ${jsonForScript(statusTuples())};
1826
2926
  var ORDER = { running:0, blocked:1, paused:2, storm_dead:3, review:4, stalled:5, queued:6, stopped:7, done:8, failed:9, superseded:10 };
1827
- var TERM = { done:1, stopped:1, failed:1, superseded:1 };
2927
+ var TERM = ${jsonForScript(Object.fromEntries(TERMINAL_STATUSES.map((s) => [s, 1])))};
1828
2928
  var EVL = { spawned:'\u0434\u0440\u043E\u043D \u0437\u0430\u043F\u0443\u0449\u0435\u043D', step:'', blocked:'\u0432\u043E\u043F\u0440\u043E\u0441 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0443', answered:'\u043F\u043E\u043B\u0443\u0447\u0435\u043D \u043E\u0442\u0432\u0435\u0442', pr_opened:'PR \u043E\u0442\u043A\u0440\u044B\u0442', merged:'PR \u0441\u043C\u0435\u0440\u0436\u0451\u043D, \u0437\u0430\u0434\u0430\u0447\u0430 \u0437\u0430\u043A\u0440\u044B\u0442\u0430', done:'\u0433\u043E\u0442\u043E\u0432\u043E', failed:'\u0434\u0440\u043E\u043D \u0443\u043F\u0430\u043B', stalled:'\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431', respawned:'\u0430\u0432\u0442\u043E-\u043E\u0436\u0438\u0432\u043B\u0435\u043D\u0438\u0435', paused:'\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D', resumed:'overmind \u0432\u0435\u0440\u043D\u0443\u043B\u0441\u044F \u2014 \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u043B', rescued:'worktree \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D \u2014 \u0437\u0430\u0431\u0435\u0440\u0438 \u0440\u0443\u043A\u0430\u043C\u0438', conflict:'\u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442 \u0441 main (merge-main-in)', died:'\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440', storm_start:'\u26A1 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 \u2014 Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442', storm_end:'\u0441\u0432\u044F\u0437\u044C \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u2014 \u0440\u043E\u0439 \u043F\u0440\u043E\u0431\u0443\u0436\u0434\u0430\u0435\u0442\u0441\u044F', storm_dead:'\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 (\u0448\u0442\u043E\u0440\u043C)', stopped:'\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D', superseded:'\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D' };
1829
2929
  var MDOT = { merged:'--success', done:'--success', pr_opened:'--info', spawned:'--muted-foreground', stalled:'--destructive', failed:'--destructive', respawned:'--warning', paused:'--warning', resumed:'--success', rescued:'--destructive', conflict:'--warning', died:'--destructive', storm_start:'--destructive', storm_end:'--success', storm_dead:'--warning', stopped:'--muted-foreground', superseded:'--muted-foreground' };
1830
2930
 
@@ -2326,14 +3426,15 @@ async function runDiff(db2, id, now = Date.now()) {
2326
3426
  diffCache.set(id, { at: now, payload });
2327
3427
  return payload;
2328
3428
  }
2329
- function eventsStream(db2) {
3429
+ var readNarratorLog = (log) => log?.() ?? [];
3430
+ function eventsStream(db2, narratorLog) {
2330
3431
  let iv;
2331
3432
  let last = "";
2332
3433
  const stream = new ReadableStream({
2333
3434
  start(controller) {
2334
3435
  const enc = new TextEncoder();
2335
3436
  const send = () => {
2336
- const snap = JSON.stringify(roySnapshot(db2));
3437
+ const snap = JSON.stringify(roySnapshot(db2, Date.now(), readNarratorLog(narratorLog)));
2337
3438
  if (snap === last) return;
2338
3439
  last = snap;
2339
3440
  try {
@@ -2367,11 +3468,11 @@ function makeHandler(deps) {
2367
3468
  const p = url.pathname;
2368
3469
  if (p === "/") {
2369
3470
  const store = auth();
2370
- return html(store ? renderDashboard(store.email, roySnapshot(deps.db), taskUrlBase) : renderLogin());
3471
+ return html(store ? renderDashboard(store.email, roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)), taskUrlBase) : renderLogin());
2371
3472
  }
2372
- if (p === "/events") return eventsStream(deps.db);
2373
- if (p === "/state") return Response.json(roySnapshot(deps.db));
2374
- if (p === "/timeline") return Response.json(timelineState(deps.db));
3473
+ if (p === "/events") return eventsStream(deps.db, deps.narratorLog);
3474
+ if (p === "/state") return Response.json(roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
3475
+ if (p === "/timeline") return Response.json(timelineState(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
2375
3476
  const diffMatch = p.match(/^\/run\/(\d+)\/diff$/);
2376
3477
  if (diffMatch) {
2377
3478
  if (!auth()) return unauthorized();
@@ -2449,39 +3550,103 @@ function makeHandler(deps) {
2449
3550
  function startWeb(deps, port = WEB_PORT) {
2450
3551
  const handle = makeHandler(deps);
2451
3552
  const server = serve({ fetch: handle, port, hostname: "127.0.0.1" });
3553
+ server.on("error", (err) => {
3554
+ const e = err;
3555
+ if (e.code === "EADDRINUSE") {
3556
+ console.error(`\u043F\u043E\u0440\u0442 ${port} \u0437\u0430\u043D\u044F\u0442 \u2014 \u043F\u043E\u0445\u043E\u0436\u0435, overmind \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${port}).`);
3557
+ } else {
3558
+ console.error(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0430\u0441\u044C: ${e.message}`);
3559
+ }
3560
+ process.exit(1);
3561
+ });
2452
3562
  return { port, server };
2453
3563
  }
2454
3564
 
2455
3565
  // src/cli.ts
2456
- import { cancelRun, respawnRun, OVERMIND_ENTRY } from "./overmind.js";
3566
+ import { cancelRun, respawnRun, runDaemon, OVERMIND_ENTRY, listDroneProcesses } from "./overmind.js";
2457
3567
 
2458
3568
  // src/launcher.ts
2459
3569
  import { spawn } from "child_process";
2460
- import { readdirSync, writeFileSync as writeFileSync2 } from "fs";
3570
+ import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync3, openSync, readdirSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
2461
3571
  import { homedir as homedir2 } from "os";
2462
3572
  import { join as join4 } from "path";
2463
- function claudeArgs(cfgPath, prompt, continueSession = false) {
2464
- const base2 = ["--dangerously-skip-permissions", "--mcp-config", cfgPath, "-p", prompt];
2465
- return continueSession ? ["--continue", ...base2] : base2;
3573
+ var ISOLATION_FLAGS = ["--setting-sources", "project", "--strict-mcp-config"];
3574
+ function claudeArgs(cfgPath, prompt, continueSession = false, model, pluginDirs = []) {
3575
+ const pluginFlags = pluginDirs.flatMap((dir) => ["--plugin-dir", dir]);
3576
+ const base2 = [
3577
+ "--dangerously-skip-permissions",
3578
+ ...ISOLATION_FLAGS,
3579
+ "--mcp-config",
3580
+ cfgPath,
3581
+ ...pluginFlags,
3582
+ "--output-format",
3583
+ "json",
3584
+ "-p",
3585
+ prompt
3586
+ ];
3587
+ const withModel = model ? ["--model", model, ...base2] : base2;
3588
+ return continueSession ? ["--continue", ...withModel] : withModel;
3589
+ }
3590
+ function roleDir(worktree, kind) {
3591
+ return join4(worktree, ".claude", "roles", kind);
3592
+ }
3593
+ function nextRunLogPath(runLogsDir, runId) {
3594
+ const dir = join4(runLogsDir, String(runId));
3595
+ mkdirSync3(dir, { recursive: true });
3596
+ let files = [];
3597
+ try {
3598
+ files = readdirSync(dir);
3599
+ } catch {
3600
+ }
3601
+ let next = 0;
3602
+ for (const f of files) {
3603
+ const m = /^(\d+)\.json/.exec(f);
3604
+ if (m) next = Math.max(next, Number(m[1]) + 1);
3605
+ }
3606
+ return join4(dir, `${next}.json`);
3607
+ }
3608
+ function openNextRunLog(runLogsDir, runId) {
3609
+ for (let attempt = 0; attempt < 50; attempt++) {
3610
+ try {
3611
+ return openSync(nextRunLogPath(runLogsDir, runId), "wx");
3612
+ } catch (e) {
3613
+ if (e.code !== "EEXIST") throw e;
3614
+ }
3615
+ }
3616
+ throw new Error(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0439\u0442\u0438 \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u0439 run-log \u0444\u0430\u0439\u043B \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${runId} (\u0433\u043E\u043D\u043A\u0430?)`);
2466
3617
  }
2467
3618
  function makeLauncher(cfg) {
2468
3619
  return ({ worktree, run, prompt, continueSession }) => {
3620
+ const isCompiled = cfg.overmindEntry.endsWith(".js");
3621
+ if (!isCompiled && !cfg.overmindEntry.endsWith(".ts")) {
3622
+ throw new Error(`overmindEntry \u043D\u0438 .ts, \u043D\u0438 .js: ${cfg.overmindEntry}`);
3623
+ }
2469
3624
  const mcpConfig = {
2470
3625
  mcpServers: {
2471
3626
  overmind: {
2472
- command: "bun",
2473
- args: ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
3627
+ command: isCompiled ? process.execPath : "bun",
3628
+ args: isCompiled ? [cfg.overmindEntry, "--drone", "--run", String(run.id)] : ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
2474
3629
  env: { SWARM_DB: cfg.dbPath }
2475
3630
  }
2476
3631
  }
2477
3632
  };
2478
3633
  const cfgPath = join4(worktree, ".swarm-mcp.json");
2479
- writeFileSync2(cfgPath, JSON.stringify(mcpConfig, null, 2));
2480
- const child = spawn(cfg.claudeBin ?? "claude", claudeArgs(cfgPath, prompt, continueSession), {
3634
+ writeFileSync4(cfgPath, JSON.stringify(mcpConfig, null, 2));
3635
+ const model = run.model ?? process.env.SWARM_DRONE_MODEL ?? void 0;
3636
+ const dir = roleDir(worktree, run.kind);
3637
+ const pluginDirs = existsSync3(dir) ? [dir] : [];
3638
+ let stdio = "ignore";
3639
+ let outFd;
3640
+ if (cfg.runLogsDir) {
3641
+ outFd = openNextRunLog(cfg.runLogsDir, run.id);
3642
+ stdio = ["ignore", outFd, "ignore"];
3643
+ }
3644
+ const child = spawn(cfg.claudeBin ?? "claude", claudeArgs(cfgPath, prompt, continueSession, model, pluginDirs), {
2481
3645
  cwd: worktree,
2482
3646
  detached: true,
2483
- stdio: "ignore"
3647
+ stdio
2484
3648
  });
3649
+ if (outFd !== void 0) closeSync(outFd);
2485
3650
  child.unref();
2486
3651
  if (child.pid == null) throw new Error("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u043D\u0435\u0442 pid)");
2487
3652
  return child.pid;
@@ -2501,19 +3666,19 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2501
3666
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2502
3667
 
2503
3668
  // src/workspace.ts
2504
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs";
3669
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, unlinkSync as unlinkSync2 } from "fs";
2505
3670
  import { join as join5 } from "path";
2506
3671
  var WORKSPACE_PATH = join5(SWARM_HOME, "workspace.json");
2507
3672
  function loadWorkspace() {
2508
3673
  try {
2509
- const w = JSON.parse(readFileSync3(WORKSPACE_PATH, "utf8"));
3674
+ const w = JSON.parse(readFileSync5(WORKSPACE_PATH, "utf8"));
2510
3675
  return w && typeof w.id === "string" && w.id ? w : null;
2511
3676
  } catch {
2512
3677
  return null;
2513
3678
  }
2514
3679
  }
2515
3680
  function saveWorkspace(w) {
2516
- writeFileSync3(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
3681
+ writeFileSync5(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
2517
3682
  }
2518
3683
  function clearWorkspace() {
2519
3684
  try {
@@ -2543,7 +3708,7 @@ async function connectSwarmMcp(cfg) {
2543
3708
  }
2544
3709
  if (!token) token = TASKLESS_TOKEN;
2545
3710
  if (!token) return null;
2546
- const url = new URL(`${api.replace(/\/$/, "")}/api/mcp`);
3711
+ const url = new URL(mcpEndpoint(api));
2547
3712
  const transport = new StreamableHTTPClientTransport(url, {
2548
3713
  requestInit: { headers: { Authorization: `Bearer ${token}` } }
2549
3714
  });
@@ -2633,7 +3798,187 @@ async function openBrowser(url) {
2633
3798
  }
2634
3799
  }
2635
3800
 
3801
+ // src/screenshot.ts
3802
+ var DESKTOP_VIEWPORT = { name: "desktop", width: 1360, height: 940 };
3803
+ var MOBILE_VIEWPORT = { name: "mobile", width: 390, height: 844 };
3804
+ var DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
3805
+ var DEFAULT_SHOT_TIMEOUT_MS = 2e4;
3806
+ async function captureScreenshots(launchBrowser, opts) {
3807
+ const viewports = opts.viewports ?? DEFAULT_VIEWPORTS;
3808
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_SHOT_TIMEOUT_MS;
3809
+ const fullPage = opts.fullPage ?? true;
3810
+ const browser = await launchBrowser();
3811
+ try {
3812
+ const results = [];
3813
+ for (const vp of viewports) {
3814
+ const ctx = await browser.newContext({
3815
+ viewport: { width: vp.width, height: vp.height },
3816
+ ...opts.storageStatePath ? { storageState: opts.storageStatePath } : {}
3817
+ });
3818
+ try {
3819
+ const page = await ctx.newPage();
3820
+ await page.goto(opts.url, { timeout: timeoutMs, waitUntil: "load" });
3821
+ const path = `${opts.outPrefix}-${vp.name}.png`;
3822
+ await page.screenshot({ path, fullPage, timeout: timeoutMs });
3823
+ results.push({ viewport: vp.name, path });
3824
+ } finally {
3825
+ await ctx.close();
3826
+ }
3827
+ }
3828
+ return results;
3829
+ } finally {
3830
+ await browser.close();
3831
+ }
3832
+ }
3833
+ async function launchChromium(opts = {}) {
3834
+ let mod;
3835
+ try {
3836
+ mod = await import("playwright-core");
3837
+ } catch {
3838
+ throw new Error(
3839
+ "playwright-core \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438 \u043E\u0434\u0438\u043D \u0440\u0430\u0437: (\u0432 \u044D\u0442\u043E\u043C \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u043E) pnpm --filter @taskless-app/swarm add -D playwright-core; (\u043E\u0442\u0434\u0435\u043B\u044C\u043D\u043E) npm i -D playwright-core \u2014 \u0438 \u0432 \u043E\u0431\u043E\u0438\u0445 \u0441\u043B\u0443\u0447\u0430\u044F\u0445 npx playwright install chromium."
3840
+ );
3841
+ }
3842
+ return mod.chromium.launch({ headless: true, ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {} });
3843
+ }
3844
+
3845
+ // src/shotCli.ts
3846
+ function isFlagValue(v) {
3847
+ return v !== void 0 && !v.startsWith("--");
3848
+ }
3849
+ function parseShotArgs(argv) {
3850
+ const url = argv[0];
3851
+ const outPrefix = argv[1];
3852
+ if (!url || !outPrefix) return null;
3853
+ let timeoutMs = SWARM_SHOT_TIMEOUT_MS;
3854
+ let storageStatePath = null;
3855
+ for (let i = 2; i < argv.length; i++) {
3856
+ if (argv[i] === "--timeout" && isFlagValue(argv[i + 1])) {
3857
+ const n = Number(argv[i + 1]);
3858
+ if (Number.isFinite(n) && n >= 0) timeoutMs = n;
3859
+ i++;
3860
+ } else if (argv[i] === "--storage-state" && isFlagValue(argv[i + 1])) {
3861
+ storageStatePath = argv[i + 1];
3862
+ i++;
3863
+ }
3864
+ }
3865
+ return { url, outPrefix, timeoutMs, storageStatePath };
3866
+ }
3867
+ var VIEWPORTS_COUNT = DEFAULT_VIEWPORTS.length;
3868
+ var LAUNCH_BUDGET_MS = 15e3;
3869
+ async function runShotCli(argv, deps = {}) {
3870
+ const log = deps.log ?? ((l) => console.log(l));
3871
+ const error = deps.error ?? ((l) => console.error(l));
3872
+ const args = parseShotArgs(argv);
3873
+ if (!args) {
3874
+ error("\u043D\u0443\u0436\u043D\u043E: swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]");
3875
+ return 1;
3876
+ }
3877
+ const launchBudgetMs = deps.launchBudgetMs ?? LAUNCH_BUDGET_MS;
3878
+ const launched = { browser: null };
3879
+ const trackedLaunch = async () => {
3880
+ const launch = deps.launchBrowser ?? (() => launchChromium({ timeoutMs: launchBudgetMs }));
3881
+ const browser = await launch();
3882
+ launched.browser = browser;
3883
+ return browser;
3884
+ };
3885
+ const budgetMs = args.timeoutMs * VIEWPORTS_COUNT + launchBudgetMs;
3886
+ let timer;
3887
+ const watchdog = new Promise((_, reject) => {
3888
+ timer = setTimeout(
3889
+ () => reject(new Error(`watchdog ${budgetMs}\u043C\u0441 \u0438\u0441\u0442\u0451\u043A \u2014 \u0447\u0442\u043E-\u0442\u043E \u0437\u0430\u0432\u0438\u0441\u043B\u043E \u0432\u043D\u0435 per-viewport \u0442\u0430\u0439\u043C\u0430\u0443\u0442\u0430`)),
3890
+ budgetMs
3891
+ );
3892
+ });
3893
+ try {
3894
+ const results = await Promise.race([
3895
+ captureScreenshots(trackedLaunch, {
3896
+ url: args.url,
3897
+ outPrefix: args.outPrefix,
3898
+ timeoutMs: args.timeoutMs,
3899
+ storageStatePath: args.storageStatePath
3900
+ }),
3901
+ watchdog
3902
+ ]);
3903
+ for (const r of results) log(`${r.viewport} \u2192 ${r.path}`);
3904
+ return 0;
3905
+ } catch (e) {
3906
+ error(`swarm shot: ${e instanceof Error ? e.message : String(e)}`);
3907
+ return 1;
3908
+ } finally {
3909
+ clearTimeout(timer);
3910
+ await launched.browser?.close().catch(() => {
3911
+ });
3912
+ }
3913
+ }
3914
+
3915
+ // package.json
3916
+ var package_default = {
3917
+ name: "@taskless-app/swarm",
3918
+ version: "0.2.0",
3919
+ type: "module",
3920
+ description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
3921
+ keywords: [
3922
+ "ai-agents",
3923
+ "coding-agents",
3924
+ "swarm",
3925
+ "orchestration",
3926
+ "autonomous-agents",
3927
+ "mcp",
3928
+ "taskless",
3929
+ "cli"
3930
+ ],
3931
+ license: "SEE LICENSE IN LICENSE",
3932
+ author: "Ilya Smurov <ilya@smurov.com>",
3933
+ homepage: "https://github.com/ilyasmurov/taskless/tree/main/packages/swarm-daemon#readme",
3934
+ repository: {
3935
+ type: "git",
3936
+ url: "git+https://github.com/ilyasmurov/taskless.git",
3937
+ directory: "packages/swarm-daemon"
3938
+ },
3939
+ engines: {
3940
+ node: ">=22"
3941
+ },
3942
+ bin: {
3943
+ swarm: "dist/cli.js"
3944
+ },
3945
+ files: [
3946
+ "dist",
3947
+ "docs/HERO.md"
3948
+ ],
3949
+ scripts: {
3950
+ overmind: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/overmind.ts',
3951
+ swarm: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/cli.ts',
3952
+ build: "tsup",
3953
+ start: "node dist/overmind.js",
3954
+ prepublishOnly: "pnpm run build",
3955
+ test: "vitest run && SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
3956
+ "test:client": "SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
3957
+ "test:watch": "vitest",
3958
+ typecheck: "tsc -p tsconfig.json --noEmit",
3959
+ lint: "echo 'no lint in swarm-daemon yet'"
3960
+ },
3961
+ dependencies: {
3962
+ "@hono/node-server": "^1.13.0",
3963
+ "@modelcontextprotocol/sdk": "^1.29.0",
3964
+ "better-sqlite3": "^11.8.0",
3965
+ execa: "^9.5.0",
3966
+ zod: "3.25.76"
3967
+ },
3968
+ devDependencies: {
3969
+ "@taskless/swarm-core": "workspace:*",
3970
+ "@types/better-sqlite3": "^7.6.11",
3971
+ "@types/node": "22.10.5",
3972
+ "playwright-core": "1.49.1",
3973
+ tsup: "^8.5.1",
3974
+ tsx: "4.19.2",
3975
+ typescript: "5.7.3",
3976
+ vitest: "2.1.8"
3977
+ }
3978
+ };
3979
+
2636
3980
  // src/cli.ts
3981
+ var PACKAGE_VERSION = package_default.version;
2637
3982
  function takeRepo(argv) {
2638
3983
  const i = argv.indexOf("--repo");
2639
3984
  if (i === -1) return { repo: null, rest: argv };
@@ -2716,7 +4061,7 @@ async function runLogin(db2, opts) {
2716
4061
  if (!chosen) return 1;
2717
4062
  saveWorkspace(toWorkspaceSel(chosen));
2718
4063
  console.log(`\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${chosen.name} (${chosen.slug}) \u2192 ${SWARM_HOME}/workspace.json`);
2719
- console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \u0414\u0435\u043C\u043E\u043D \u0432 \u0442\u043E\u043D\u043A\u043E\u043C \u043A\u043B\u0438\u0435\u043D\u0442\u0435: SWARM_BACKEND=client pnpm --filter @taskless/swarm-daemon start");
4064
+ console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \u041F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: swarm start --repo <\u043F\u0443\u0442\u044C \u043A \u0440\u0435\u043F\u043E>");
2720
4065
  return 0;
2721
4066
  } finally {
2722
4067
  web.server.close();
@@ -2737,14 +4082,16 @@ var STATUS_MARK = {
2737
4082
  function fmtRun(r) {
2738
4083
  const mark = STATUS_MARK[r.status] ?? "?";
2739
4084
  const area = r.area ? ` [${r.area}]` : "";
2740
- const repo = r.repo ? ` (${basename2(r.repo)})` : "";
4085
+ const repo = r.repo ? ` (${basename3(r.repo)})` : "";
2741
4086
  const q = r.status === "blocked" ? " \u2190 \u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430" : "";
2742
4087
  const why = r.status === "stopped" && r.stop_reason ? ` \u2014 ${r.stop_reason}` : "";
2743
4088
  return `${mark} #${r.id} ${r.name} ${r.task_slug}${repo}${area} ${r.status}${q}${why}`;
2744
4089
  }
2745
4090
  function usage() {
2746
- console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432
4091
+ console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432 (v${PACKAGE_VERSION})
2747
4092
 
4093
+ swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
4094
+ swarm start [--repo <path>] \u043F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0435\u0442 \u043E\u0447\u0435\u0440\u0435\u0434\u044C, \u0441\u043F\u0430\u0432\u043D\u0438\u0442/\u0431\u0443\u0434\u0438\u0442 \u0434\u0440\u043E\u043D\u043E\u0432
2748
4095
  swarm ls [status] \u0441\u043F\u0438\u0441\u043E\u043A \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 (\u043E\u043F\u0446. \u0444\u0438\u043B\u044C\u0442\u0440 \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0443)
2749
4096
  swarm spawn <slug> [area] [--repo <path>] [--after <s1,s2>] \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u044C (--after \u2192 \u0436\u0434\u0430\u0442\u044C done \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0435\u0439)
2750
4097
  swarm spawn <slug> --hero [--name HYDRA] [--repo <path>] [--after <s1,s2>] \u0413\u0435\u0440\u043E\u0439-\u043B\u0435\u0439\u0442\u0435\u043D\u0430\u043D\u0442 \u044D\u043F\u0438\u043A\u0430
@@ -2754,14 +4101,95 @@ function usage() {
2754
4101
  swarm cancel <slug> <\u043F\u0440\u0438\u0447\u0438\u043D\u0430\u2026> \u043E\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u0441\u0442\u0430\u0442\u0443\u0441 \xAB\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\xBB + \u043F\u0440\u0438\u0447\u0438\u043D\u0430; \u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u043E\u0441\u0442\u0430\u0451\u0442\u0441\u044F)
2755
4102
  swarm import-db <path> --repo <path> \u0438\u043C\u043F\u043E\u0440\u0442 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u043E\u0439 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E)
2756
4103
  swarm log <runId> \u043B\u043E\u0433 \u043F\u0440\u043E\u0433\u043E\u043D\u0430
2757
- swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
2758
4104
  swarm logout \u0437\u0430\u0431\u044B\u0442\u044C \u0442\u043E\u043A\u0435\u043D\u044B \u0438 \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
2759
4105
  swarm whoami \u043A\u0442\u043E \u0432\u043E\u0448\u0451\u043B \u0438 \u0432 \u043A\u0430\u043A\u043E\u043C \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0435
2760
4106
  swarm web \u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 (\u0432\u0445\u043E\u0434 + \u0441\u0442\u0430\u0442\u0443\u0441) \u043D\u0430 127.0.0.1
4107
+ swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]
4108
+ headless-\u0441\u043A\u0440\u0438\u043D desktop+mobile \u041E\u0414\u041D\u041E\u0419 \u043A\u043E\u043C\u0430\u043D\u0434\u043E\u0439 (TASWM-40):
4109
+ <out-prefix>-desktop.png, <out-prefix>-mobile.png
4110
+ swarm --version \u0432\u0435\u0440\u0441\u0438\u044F \u043F\u0430\u043A\u0435\u0442\u0430
2761
4111
  `);
2762
4112
  }
2763
- var db = new SwarmDb(DB_PATH, SWARM_REPO);
4113
+ async function makeCliStore(repo) {
4114
+ if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
4115
+ const failing = async () => ({ isError: true });
4116
+ const call = await connectSwarmMcp() ?? failing;
4117
+ const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo });
4118
+ await client.hydrate();
4119
+ client.reconcileRuntime({ worktreesDir: WORKTREES_DIR, listProcesses: listDroneProcesses });
4120
+ return client;
4121
+ }
4122
+ async function finishClient(db2) {
4123
+ if (!(db2 instanceof SwarmClient)) return;
4124
+ await db2.flush();
4125
+ db2.close();
4126
+ process.exit(0);
4127
+ }
2764
4128
  var [cmd, ...rawArgs] = process.argv.slice(2);
4129
+ if (cmd === "--version" || cmd === "-v") {
4130
+ console.log(PACKAGE_VERSION);
4131
+ process.exit(0);
4132
+ }
4133
+ if (cmd === "logout") {
4134
+ clearStore();
4135
+ clearWorkspace();
4136
+ console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
4137
+ process.exit(0);
4138
+ }
4139
+ if (cmd === "whoami") {
4140
+ const store = loadStore();
4141
+ if (!store) {
4142
+ console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
4143
+ process.exit(0);
4144
+ }
4145
+ const ws = loadWorkspace();
4146
+ console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
4147
+ console.log(
4148
+ ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
4149
+ );
4150
+ process.exit(0);
4151
+ }
4152
+ if (cmd === "import-db") {
4153
+ const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
4154
+ const path = args[0];
4155
+ const repo = repoFlag ?? SWARM_REPO;
4156
+ if (!path || !repo) {
4157
+ console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
4158
+ process.exit(1);
4159
+ }
4160
+ const sdb = new SwarmDb(DB_PATH, repo);
4161
+ const res = sdb.importDb(path, repo);
4162
+ if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
4163
+ else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
4164
+ sdb.close();
4165
+ process.exit(0);
4166
+ }
4167
+ if (cmd === "start") {
4168
+ if (SWARM_BACKEND !== "client") {
4169
+ console.warn("SWARM_BACKEND=client \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u0434\u0435\u043C\u043E\u043D \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C sqlite-\u0440\u0435\u0436\u0438\u043C\u0435, \u0411\u0415\u0417 \u0441\u0432\u044F\u0437\u0438 \u0441 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u043E\u043C Taskless (\u043D\u0435 \u0443\u0432\u0438\u0434\u0438\u0442 \u0437\u0430\u0434\u0430\u0447\u0438, \u043D\u0435 \u043F\u043E\u0448\u043B\u0451\u0442 \u043F\u0443\u043B\u044C\u0441).");
4170
+ }
4171
+ const { repo: repoFlag } = takeRepo(rawArgs);
4172
+ const repo = repoFlag ?? SWARM_REPO;
4173
+ if (!repo) {
4174
+ console.warn("SWARM_REPO \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u043F\u0440\u043E\u0433\u043E\u043D\u044B \u0431\u0435\u0437 \u044F\u0432\u043D\u043E\u0433\u043E repo \u043D\u0435 \u0437\u0430\u043F\u0443\u0441\u0442\u044F\u0442\u0441\u044F (\u043D\u0443\u0436\u0435\u043D spawn --repo).");
4175
+ }
4176
+ try {
4177
+ await runDaemon({ repo });
4178
+ } catch (e) {
4179
+ console.error(`\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0441\u044F: ${e instanceof Error ? e.message : e}`);
4180
+ process.exit(1);
4181
+ }
4182
+ process.exit(0);
4183
+ }
4184
+ if (cmd === "shot") {
4185
+ const code = await runShotCli(rawArgs);
4186
+ process.exit(code);
4187
+ }
4188
+ if (!cmd || cmd === "--help" || cmd === "-h") {
4189
+ usage();
4190
+ process.exit(0);
4191
+ }
4192
+ var db = await makeCliStore(SWARM_REPO);
2765
4193
  switch (cmd) {
2766
4194
  case "ls": {
2767
4195
  const runs = db.listRuns(rawArgs[0]);
@@ -2812,7 +4240,7 @@ switch (cmd) {
2812
4240
  console.error(`\u043F\u0440\u043E\u0433\u043E\u043D #${runId} \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (running) \u2014 \u0431\u0443\u0434\u0438\u0442\u044C \u043D\u0435\u0447\u0435\u0433\u043E`);
2813
4241
  process.exit(1);
2814
4242
  }
2815
- const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH });
4243
+ const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH, runLogsDir: RUN_LOGS_DIR });
2816
4244
  await respawnRun(
2817
4245
  { db, launch, repoFallback: SWARM_REPO, worktreesDir: WORKTREES_DIR, getTask: () => Promise.resolve(null) },
2818
4246
  run,
@@ -2821,19 +4249,6 @@ switch (cmd) {
2821
4249
  console.log(`\u0440\u0430\u0437\u0431\u0443\u0436\u0435\u043D (--continue): ${fmtRun(db.getRun(runId))}`);
2822
4250
  break;
2823
4251
  }
2824
- case "import-db": {
2825
- const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
2826
- const path = args[0];
2827
- const repo = repoFlag ?? SWARM_REPO;
2828
- if (!path || !repo) {
2829
- console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
2830
- process.exit(1);
2831
- }
2832
- const res = db.importDb(path, repo);
2833
- if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
2834
- else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
2835
- break;
2836
- }
2837
4252
  case "questions": {
2838
4253
  const qs = db.openQuestions();
2839
4254
  if (qs.length === 0) console.log("\u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0432\u043E\u043F\u0440\u043E\u0441\u043E\u0432 \u043D\u0435\u0442");
@@ -2887,7 +4302,7 @@ switch (cmd) {
2887
4302
  process.exit(1);
2888
4303
  }
2889
4304
  const run = db.getRun(runId);
2890
- if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename2(run.repo)})` : ""}`);
4305
+ if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename3(run.repo)})` : ""}`);
2891
4306
  const events = db.events(runId);
2892
4307
  if (events.length === 0) console.log("\u0441\u043E\u0431\u044B\u0442\u0438\u0439 \u043D\u0435\u0442");
2893
4308
  else events.forEach((e) => console.log(`${e.at} ${e.kind}${e.text ? " " + e.text : ""}`));
@@ -2900,25 +4315,6 @@ switch (cmd) {
2900
4315
  db.close();
2901
4316
  process.exit(code);
2902
4317
  }
2903
- case "logout": {
2904
- clearStore();
2905
- clearWorkspace();
2906
- console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
2907
- break;
2908
- }
2909
- case "whoami": {
2910
- const store = loadStore();
2911
- if (!store) {
2912
- console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
2913
- break;
2914
- }
2915
- const ws = loadWorkspace();
2916
- console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
2917
- console.log(
2918
- ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
2919
- );
2920
- break;
2921
- }
2922
4318
  case "web": {
2923
4319
  const web = startWeb({ db });
2924
4320
  console.log(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${web.port} \u2190 \u043E\u0442\u043A\u0440\u043E\u0439 \u0438 \u0432\u043E\u0439\u0434\u0438 \u0447\u0435\u0440\u0435\u0437 Taskless`);
@@ -2929,4 +4325,5 @@ switch (cmd) {
2929
4325
  default:
2930
4326
  usage();
2931
4327
  }
4328
+ await finishClient(db);
2932
4329
  db.close();