@taskless-app/swarm 0.1.1 → 0.2.2

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,62 +1,240 @@
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
- import { setTimeout as sleep } from "timers/promises";
6
+ import { setTimeout as sleep2 } from "timers/promises";
7
7
 
8
8
  // src/db.ts
9
- import Database from "better-sqlite3";
9
+ import { createRequire } from "module";
10
10
  import { existsSync } from "fs";
11
11
  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
+ // TASWM-166: раньше эти два kind'а не двигали фазу — хвост дорожки
96
+ // дорисовывался ТЕКУЩЕЙ (до остановки) фазой, напр. "running" сплошным
97
+ // зелёным до самого конца — визуально неотличимо от реально идущего прогона.
98
+ case "stopped":
99
+ push(e.at);
100
+ phase = "stopped";
101
+ break;
102
+ case "superseded":
103
+ push(e.at);
104
+ phase = "superseded";
105
+ break;
106
+ }
26
107
  }
108
+ push(laneEnd);
109
+ return segs;
27
110
  }
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;
111
+ function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
112
+ switch (status) {
113
+ case "queued": {
114
+ const unmet = deps.filter((d) => !d.met);
115
+ if (unmet.length) {
116
+ const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
117
+ return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
118
+ }
119
+ return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
120
+ }
121
+ case "running":
122
+ return { icon: "\u25B6", text: lastStep ? `\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442: ${lastStep}` : "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", tone: "run" };
123
+ case "blocked":
124
+ 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" };
125
+ case "paused":
126
+ return { icon: "\u{1F4A4}", text: stopReason || "\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" };
127
+ case "storm_dead":
128
+ 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" };
129
+ case "review": {
130
+ if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
131
+ if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
132
+ 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" };
133
+ return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
134
+ }
135
+ case "stalled":
136
+ return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
137
+ case "failed":
138
+ return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
139
+ case "stopped":
140
+ return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "stopped" };
141
+ case "superseded":
142
+ return { icon: "\u21BA", text: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u043D\u0430 \u0434\u0440\u0443\u0433\u043E\u0439 \u0432\u0430\u0440\u0438\u0430\u043D\u0442", tone: "superseded" };
143
+ case "done":
144
+ return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
145
+ default:
146
+ return { icon: "\u2022", text: status, tone: "queued" };
147
+ }
37
148
  }
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 "";
149
+ function pickHeadline(lanes) {
150
+ const live = lanes.filter((l2) => l2.live && !l2.obsolete);
151
+ const by = (pred) => live.find(pred);
152
+ const fmt = (l2, extra) => `${l2.name} \xB7 ${l2.slug} \u2014 ${extra}`;
153
+ let l = by((x) => x.status === "review" && x.ci?.tone === "fail");
154
+ if (l) return { icon: "\u274C", text: fmt(l, "CI \u0443\u043F\u0430\u043B \u2014 \u0447\u0438\u043D\u0438\u0442\u044C"), tone: "fail" };
155
+ l = by((x) => x.status === "blocked");
156
+ 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" };
157
+ l = by((x) => x.status === "stalled");
158
+ 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" };
159
+ l = by((x) => x.status === "review" && x.blocker.tone === "merge");
160
+ 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" };
161
+ l = by((x) => x.status === "queued" && x.dependsOn.some((d) => !d.met));
162
+ if (l) {
163
+ const dep = l.dependsOn.find((d) => !d.met);
164
+ return { icon: "\u26D4", text: fmt(l, `\u0436\u0434\u0451\u0442 ${dep.slug}${dep.name ? " \xB7 " + dep.name : ""}`), tone: "dep" };
49
165
  }
166
+ l = by((x) => x.status === "review");
167
+ if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
168
+ l = by((x) => x.status === "running");
169
+ 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" };
170
+ return null;
50
171
  }
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`;
172
+ var DEFAULT_RUN_MS = 25 * 6e4;
58
173
 
59
- // src/names.ts
174
+ // ../swarm-core/src/status.ts
175
+ var STATUS_META = {
176
+ queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
177
+ running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
178
+ blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
179
+ paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
180
+ // overmind был недоступен — ждёт пробуждения (TASWM-41)
181
+ review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
182
+ done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
183
+ failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
184
+ stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
185
+ // молчит/погиб, но ещё разруливается — не терминал
186
+ storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
187
+ // умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
188
+ stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
189
+ superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
190
+ };
191
+ var FALLBACK = STATUS_META.queued;
192
+ var TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
193
+ (s) => STATUS_META[s].terminal
194
+ );
195
+ function statusMeta(status) {
196
+ return STATUS_META[status] ?? FALLBACK;
197
+ }
198
+ function isTerminalStatus(status) {
199
+ return statusMeta(status).terminal;
200
+ }
201
+ function statusTuples() {
202
+ const out = {};
203
+ for (const [status, m] of Object.entries(STATUS_META)) {
204
+ out[status] = [m.pill, m.label, m.color];
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // ../swarm-core/src/time.ts
210
+ function stamp(d = /* @__PURE__ */ new Date()) {
211
+ return d.toISOString().slice(0, 19).replace("T", " ");
212
+ }
213
+ function stampFromEpochSec(sec) {
214
+ return stamp(new Date(sec * 1e3));
215
+ }
216
+ function sqliteMs(ts) {
217
+ return Date.parse(ts.replace(" ", "T") + "Z");
218
+ }
219
+ function toStamp(v) {
220
+ if (typeof v !== "string" || !v) return null;
221
+ const d = new Date(v);
222
+ if (Number.isNaN(d.getTime())) return v.includes("T") ? v.slice(0, 19).replace("T", " ") : v;
223
+ return stamp(d);
224
+ }
225
+ var DAY_MS = 864e5;
226
+
227
+ // ../swarm-core/src/windows.ts
228
+ var ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
229
+ function resolveRoyWindows(env = {}) {
230
+ return {
231
+ windowMs: Number(env.ROY_WINDOW_H || ROY_WINDOW_DEFAULTS.windowH) * 36e5,
232
+ maxTerminalLanes: Number(env.ROY_MAX_DONE || ROY_WINDOW_DEFAULTS.maxDone),
233
+ maxAxisMs: Number(env.ROY_AXIS_H || ROY_WINDOW_DEFAULTS.axisH) * 36e5
234
+ };
235
+ }
236
+
237
+ // ../swarm-core/src/names.ts
60
238
  var DRONE_POOL = [
61
239
  // Латинский бестиарий (15)
62
240
  "SCARAB",
@@ -85,7 +263,7 @@ var DRONE_POOL = [
85
263
  "LEVIATHAN",
86
264
  "SURTUR"
87
265
  ];
88
- var NAME_LIVE = /* @__PURE__ */ new Set(["queued", "running", "blocked", "review"]);
266
+ var ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
89
267
  function toRoman(n) {
90
268
  if (n < 1 || n > 3999) return String(n);
91
269
  const table = [
@@ -113,6 +291,86 @@ function genName(base2, g) {
113
291
  function baseName(name) {
114
292
  return name.split(" ")[0];
115
293
  }
294
+
295
+ // src/config.ts
296
+ var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
297
+ mkdirSync(SWARM_HOME, { recursive: true });
298
+ function normalizeRepo(repo) {
299
+ const r = (repo ?? "").trim();
300
+ if (!r) return "";
301
+ try {
302
+ return realpathSync(r);
303
+ } catch {
304
+ return resolve(r);
305
+ }
306
+ }
307
+ var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
308
+ var WORKTREES_DIR = process.env.SWARM_WORKTREES || join(SWARM_HOME, "worktrees");
309
+ var RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
310
+ function droneWorktreePath(worktreesDir, repo, slug) {
311
+ return join(worktreesDir, basename(normalizeRepo(repo)), slug);
312
+ }
313
+ var DIST_BUILD = true;
314
+ var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
315
+ var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
316
+ function envNum(name, def) {
317
+ const raw = process.env[name];
318
+ if (raw === void 0 || raw === "") return def;
319
+ const n = Number(raw);
320
+ return Number.isFinite(n) && n >= 0 ? n : def;
321
+ }
322
+ var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
323
+ var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
324
+ var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
325
+ var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
326
+ var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
327
+ var SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
328
+ var SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
329
+ var SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
330
+ var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
331
+ var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
332
+ var SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
333
+ var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
334
+ var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
335
+ var SWARM_REPO = process.env.SWARM_REPO || "";
336
+ var SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
337
+ var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
338
+ var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
339
+ var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
340
+ function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
341
+ const base2 = api.replace(/\/$/, "");
342
+ const p = path.startsWith("/") ? path : `/${path}`;
343
+ return `${base2}${p}`;
344
+ }
345
+ function tokenFromFile() {
346
+ try {
347
+ return readFileSync(join(SWARM_HOME, "token"), "utf8").trim();
348
+ } catch {
349
+ return "";
350
+ }
351
+ }
352
+ var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
353
+ var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
354
+ var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
355
+ var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
356
+ var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
357
+ var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
358
+ var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
359
+ var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
360
+ var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
361
+ var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
362
+ var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
363
+ var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
364
+ var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
365
+ var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
366
+ var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
367
+ var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
368
+ var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
369
+ var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
370
+ var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
371
+
372
+ // src/names.ts
373
+ var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
116
374
  function pickName(rows) {
117
375
  const live = /* @__PURE__ */ new Set();
118
376
  const lastUse = /* @__PURE__ */ new Map();
@@ -163,6 +421,8 @@ function decodeProbe(text) {
163
421
  }
164
422
 
165
423
  // src/db.ts
424
+ var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
425
+ var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
166
426
  var SCHEMA = `
167
427
  CREATE TABLE IF NOT EXISTS runs (
168
428
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -185,6 +445,8 @@ CREATE TABLE IF NOT EXISTS runs (
185
445
  stop_reason TEXT,
186
446
  respawns INTEGER NOT NULL DEFAULT 0,
187
447
  resolves_run_id INTEGER,
448
+ reviews_run_id INTEGER,
449
+ review_verdict TEXT,
188
450
  task_title TEXT,
189
451
  task_desc TEXT,
190
452
  task_url TEXT,
@@ -262,6 +524,8 @@ function rowToRun(row) {
262
524
  respawns: row.respawns ?? 0,
263
525
  // старые прогоны без колонки → 0
264
526
  resolves_run_id: row.resolves_run_id ?? null,
527
+ reviews_run_id: row.reviews_run_id ?? null,
528
+ review_verdict: row.review_verdict ?? null,
265
529
  task_title: row.task_title ?? null,
266
530
  task_desc: row.task_desc ?? null,
267
531
  task_url: row.task_url ?? null,
@@ -269,12 +533,25 @@ function rowToRun(row) {
269
533
  updated_at: row.updated_at
270
534
  };
271
535
  }
536
+ function loadBetterSqlite3() {
537
+ const require2 = createRequire(import.meta.url);
538
+ try {
539
+ return require2("better-sqlite3");
540
+ } catch (err) {
541
+ throw new Error(
542
+ `better-sqlite3 \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. Sqlite-\u0440\u0435\u0436\u0438\u043C (SwarmDb) \u2014 \u0434\u0435\u0444\u043E\u043B\u0442 \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u044B/\u0442\u0435\u0441\u0442\u043E\u0432; \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u043F\u0430\u043A\u0435\u0442 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u0438 sqlite \u043D\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u0435 optional dependency (npm i, \u0431\u0435\u0437 --omit=optional) \u0438\u043B\u0438 \u0437\u0430\u0434\u0430\u0439\u0442\u0435 SWARM_BACKEND=client. \u0418\u0441\u0445\u043E\u0434\u043D\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430: ${err.message}`
543
+ );
544
+ }
545
+ }
272
546
  var SwarmDb = class {
273
547
  db;
274
548
  defaultRepo;
549
+ databaseCtor;
550
+ // для importDb (второй Database, source)
275
551
  constructor(path = "swarm.db", defaultRepo = "") {
276
552
  this.defaultRepo = normalizeRepo(defaultRepo);
277
- this.db = new Database(path);
553
+ this.databaseCtor = loadBetterSqlite3();
554
+ this.db = new this.databaseCtor(path);
278
555
  this.db.exec("PRAGMA journal_mode = WAL;");
279
556
  this.db.exec(SCHEMA);
280
557
  for (const alter of [
@@ -295,7 +572,11 @@ var SwarmDb = class {
295
572
  // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
296
573
  "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
297
574
  "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
298
- "ALTER TABLE events ADD COLUMN actor TEXT"
575
+ "ALTER TABLE events ADD COLUMN actor TEXT",
576
+ // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
577
+ // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
578
+ "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
579
+ "ALTER TABLE runs ADD COLUMN review_verdict TEXT"
299
580
  ]) {
300
581
  try {
301
582
  this.db.exec(alter);
@@ -315,7 +596,7 @@ var SwarmDb = class {
315
596
  createRun(input) {
316
597
  const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
317
598
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
599
+ "INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
319
600
  ).run(
320
601
  input.task_slug,
321
602
  name,
@@ -329,10 +610,11 @@ var SwarmDb = class {
329
610
  input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
330
611
  input.model ?? null,
331
612
  input.decision ? JSON.stringify(input.decision) : null,
332
- input.resolves_run_id ?? null
613
+ input.resolves_run_id ?? null,
614
+ input.reviews_run_id ?? null
333
615
  );
334
616
  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}`;
617
+ 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
618
  this.addEvent(id, "spawned", spawnNote);
337
619
  return this.getRun(id);
338
620
  }
@@ -385,7 +667,7 @@ var SwarmDb = class {
385
667
  */
386
668
  activeResolverFor(reviewRunId) {
387
669
  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"
670
+ `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
389
671
  ).get(reviewRunId);
390
672
  return row ? rowToRun(row) : null;
391
673
  }
@@ -394,10 +676,48 @@ var SwarmDb = class {
394
676
  const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
395
677
  return row.n;
396
678
  }
679
+ /**
680
+ * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR (авто-ревью, TASWM-148) —
681
+ * one-shot идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → повторно не
682
+ * спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО вердикта) в счёт НЕ идут —
683
+ * иначе один транзиентный сбой спавна (диск/git/gh) навсегда блокировал бы ревью этого PR без
684
+ * единого шанса на повтор — тихо нарушая политику владельца «каждый PR ревьюится».
685
+ */
686
+ reviewerCountFor(reviewRunId) {
687
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled')").get(reviewRunId);
688
+ return row.n;
689
+ }
690
+ /**
691
+ * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
692
+ * ВСЕГО (любой статус, включая failed/stalled — специально в отличие от reviewerCountFor
693
+ * выше) когда-либо поднималось на review-прогон #reviewRunId. Зеркалит resolverCountFor —
694
+ * плоский потолок попыток. Именно это ловит карусель: reviewerCountFor исключает
695
+ * failed/stalled (чтобы ОДИН транзиентный сбой не блокировал ревью навсегда), но тем
696
+ * самым допускает бесконечный ретрай, если ревьюер стабильно умирает без done().
697
+ */
698
+ reviewerAttemptCountFor(reviewRunId) {
699
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ?").get(reviewRunId);
700
+ return row.n;
701
+ }
702
+ /** Последняя (любой статус) попытка ревьюера на review-прогон #reviewRunId (TASWM-196) —
703
+ * см. store.ts:lastReviewerAttemptFor. */
704
+ lastReviewerAttemptFor(reviewRunId) {
705
+ const row = this.db.prepare("SELECT * FROM runs WHERE reviews_run_id = ? ORDER BY id DESC LIMIT 1").get(reviewRunId);
706
+ return row ? rowToRun(row) : null;
707
+ }
708
+ /**
709
+ * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
710
+ * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
711
+ * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
712
+ */
713
+ activeReviewersFor(reviewRunId) {
714
+ const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
715
+ return rows.map(rowToRun);
716
+ }
397
717
  /** Последний живой (не терминальный) прогон по slug — для swarm cancel. */
398
718
  activeRunForSlug(slug) {
399
719
  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"
720
+ `SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
401
721
  ).get(slug);
402
722
  return row ? rowToRun(row) : null;
403
723
  }
@@ -454,7 +774,7 @@ var SwarmDb = class {
454
774
  const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
455
775
  if (already) return { imported: false, runs: already.run_count };
456
776
  if (!existsSync(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
457
- const src = new Database(abs);
777
+ const src = new this.databaseCtor(abs);
458
778
  try {
459
779
  const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
460
780
  const events = src.prepare("SELECT * FROM events ORDER BY id").all();
@@ -629,11 +949,10 @@ var SwarmDb = class {
629
949
  taskUrl: row.taskUrl ?? null
630
950
  });
631
951
  }
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
952
  for (const row of this.db.prepare(
634
953
  "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
954
  ).all()) {
636
- if (!MILESTONES.has(row.kind)) continue;
955
+ if (!isMilestoneKind(row.kind)) continue;
637
956
  const global = row.run_id == null;
638
957
  items.push({
639
958
  kind: "milestone",
@@ -694,12 +1013,13 @@ var SwarmDb = class {
694
1013
  return row.n;
695
1014
  }
696
1015
  /**
697
- * Спавн-фейлов (событий failed на НЕ-резолверных прогонах) с момента cutoff.
698
- * Резолверные failed (merge-main-in) не считаем штормовым сигналом.
1016
+ * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
1017
+ * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
1018
+ * считаем штормовым сигналом.
699
1019
  */
700
1020
  spawnFailCountSince(cutoffSeconds) {
701
1021
  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')"
1022
+ "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
1023
  ).get(cutoffSeconds);
704
1024
  return row.n;
705
1025
  }
@@ -719,139 +1039,1111 @@ var SwarmDb = class {
719
1039
  }
720
1040
  };
721
1041
 
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";
1042
+ // src/swarmClient.ts
1043
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1044
+ import { dirname } from "path";
1045
+ import { randomUUID } from "crypto";
727
1046
 
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";
1047
+ // src/resources.ts
1048
+ import os from "os";
1049
+ import { execFileSync } from "child_process";
1050
+ import { readFileSync as readFileSync2 } from "fs";
1051
+ function isTransientResourceError(e) {
1052
+ const code = e?.code;
1053
+ return code === "ENFILE" || code === "EMFILE" || code === "EAGAIN";
1054
+ }
1055
+ function logTransientSpawnError(label, e) {
1056
+ if (isTransientResourceError(e)) {
1057
+ const code = e.code;
1058
+ 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`);
1059
+ } else {
1060
+ 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)}`);
1061
+ }
1062
+ }
1063
+
1064
+ // src/swarmClient.ts
1065
+ var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
1066
+ var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
1067
+ function runRef(runKey) {
1068
+ return { __runRef: runKey };
1069
+ }
1070
+ function isRunRef(v) {
1071
+ return typeof v === "object" && v !== null && "__runRef" in v;
1072
+ }
1073
+ function jsonFromTextContent(res) {
1074
+ const text = res?.content?.find((c) => c.type === "text")?.text;
1075
+ if (text) {
1076
+ try {
1077
+ return JSON.parse(text);
1078
+ } catch {
1079
+ }
1080
+ }
1081
+ return res?.structuredContent ?? null;
1082
+ }
1083
+ var SwarmClient = class _SwarmClient {
1084
+ call;
1085
+ defaultRepo;
1086
+ autoFlush;
1087
+ listLimit;
1088
+ // TASWM-169: репо-реестр демона — repoFullName[] на claim/heartbeat + обратная
1089
+ // карта repoFullName→локальный путь для трансляции run.repo из прода (см. resolveRepo).
1090
+ repoFullNames;
1091
+ repoPathByFullName;
1092
+ // Зеркало.
1093
+ runs = /* @__PURE__ */ new Map();
1094
+ runtime = /* @__PURE__ */ new Map();
1095
+ eventsLog = [];
1096
+ questionsLog = [];
1097
+ nextRunId = 1;
1098
+ nextEventId = 1;
1099
+ nextQuestionId = 1;
1100
+ // Корреляция с продом.
1101
+ keyByLocal = /* @__PURE__ */ new Map();
1102
+ // local run id → runKey
1103
+ prodIdByKey = /* @__PURE__ */ new Map();
1104
+ // runKey → prod cuid
1105
+ seenProdEventIds = /* @__PURE__ */ new Set();
1106
+ // дедуп событий при повторной гидрации
1107
+ // Outbox / flush.
1108
+ outboxPath;
1109
+ mapPath;
1110
+ stormPath;
1111
+ // durable-сайдкар глобальных (штормовых) событий
1112
+ pending = [];
1113
+ nextSeq = 1;
1114
+ flushing = false;
1115
+ flushTimer = null;
1116
+ backoffMs = 500;
1117
+ static BACKOFF_BASE = 500;
1118
+ static BACKOFF_MAX = 3e4;
1119
+ constructor(opts) {
1120
+ this.call = opts.call;
1121
+ this.defaultRepo = normalizeRepo(opts.defaultRepo ?? "");
1122
+ this.autoFlush = opts.autoFlush ?? true;
1123
+ this.listLimit = opts.listLimit ?? 200;
1124
+ const registry = opts.repoRegistry ?? [];
1125
+ this.repoFullNames = registry.map((e) => e.repoFullName);
1126
+ this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
1127
+ this.outboxPath = opts.outboxPath;
1128
+ this.mapPath = `${opts.outboxPath}.prodmap.json`;
1129
+ this.stormPath = `${opts.outboxPath}.storm.jsonl`;
1130
+ mkdirSync2(dirname(this.outboxPath), { recursive: true });
1131
+ this.recover();
1132
+ }
1133
+ // ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
1134
+ recover() {
1135
+ if (existsSync2(this.mapPath)) {
1136
+ try {
1137
+ const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
1138
+ for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
1139
+ } catch {
1140
+ }
1141
+ }
1142
+ if (existsSync2(this.outboxPath)) {
1143
+ const txt = readFileSync3(this.outboxPath, "utf8");
1144
+ for (const line of txt.split("\n")) {
1145
+ const s = line.trim();
1146
+ if (!s) continue;
1147
+ try {
1148
+ this.pending.push(JSON.parse(s));
1149
+ } catch {
743
1150
  }
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;
1151
+ }
1152
+ this.nextSeq = this.pending.reduce((m, e) => Math.max(m, e.seq), 0) + 1;
1153
+ if (this.autoFlush && this.pending.length) this.scheduleFlush();
1154
+ }
1155
+ this.replayGlobalEvents();
1156
+ }
1157
+ // Реплей глобальных (штормовых) событий из сайдкара в зеркало на старте. Идут в
1158
+ // eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
1159
+ // переприсваиваем локально (id событий — только порядок, не корреляция с продом).
1160
+ replayGlobalEvents() {
1161
+ if (!existsSync2(this.stormPath)) return;
1162
+ let txt;
1163
+ try {
1164
+ txt = readFileSync3(this.stormPath, "utf8");
1165
+ } catch {
1166
+ return;
1167
+ }
1168
+ for (const line of txt.split("\n")) {
1169
+ const s = line.trim();
1170
+ if (!s) continue;
1171
+ try {
1172
+ const g = JSON.parse(s);
1173
+ this.eventsLog.push({
1174
+ id: this.nextEventId++,
1175
+ run_id: null,
1176
+ kind: g.kind,
1177
+ text: g.text ?? null,
1178
+ actor: g.actor ?? null,
1179
+ at: g.at
1180
+ });
1181
+ } catch {
1182
+ }
1183
+ }
1184
+ }
1185
+ // Дописать глобальное событие в durable-сайдкар (append-only, синхронно — как outbox).
1186
+ persistGlobalEvent(ev) {
1187
+ try {
1188
+ appendFileSync(this.stormPath, JSON.stringify(ev) + "\n");
1189
+ } catch {
1190
+ }
1191
+ }
1192
+ // ── зеркало: наложить рантайм на durable-прогон ──────────────────────────────
1193
+ mat(d) {
1194
+ const rt = this.runtime.get(d.id);
1195
+ const { runKey: _k, ...rest } = d;
1196
+ return { ...rest, pid: rt?.pid ?? null, worktree_path: rt?.worktree_path ?? null };
1197
+ }
1198
+ runKeyFor(localId) {
1199
+ return this.keyByLocal.get(localId);
1200
+ }
1201
+ // cuid прода → локальный id (по runKey гидрированного прогона `p:<cuid>`).
1202
+ // Для маппинга прод heroId → локальный hero_run_id при гидрации. Резолвится, когда
1203
+ // прогон-герой присутствует в зеркале; иначе null (уточнится следующим рефрешем).
1204
+ localIdForProdId(prodId) {
1205
+ return [...this.runs.values()].find((r) => r.runKey === `p:${prodId}`)?.id;
1206
+ }
1207
+ // ── outbox: поставить операцию в очередь (зеркало уже обновлено вызывающим) ───
1208
+ enqueue(e) {
1209
+ const full = { ...e, seq: this.nextSeq++ };
1210
+ this.pending.push(full);
1211
+ try {
1212
+ appendFileSync(this.outboxPath, JSON.stringify(full) + "\n");
1213
+ } catch {
1214
+ }
1215
+ this.scheduleFlush();
1216
+ }
1217
+ // Прод-патч прогона: локальные поля Run → поля swarm_run_upsert (см. TASWM-74).
1218
+ // pid/worktree_path (рантайм), respawns (нет в схеме тула) и снимок задачи (task_*,
1219
+ // прод берёт join'ом из Task) в прод НЕ уходят. kind → role (плоский enum). Self-ref
1220
+ // прогонов hero_run_id/resolves_run_id/superseded_by → heroId/resolvesRunId/
1221
+ // supersededById как ОТЛОЖЕННЫЕ ссылки (RunRef) — cuid резолвится на флаше.
1222
+ runFields(patch) {
1223
+ const out = {};
1224
+ if ("status" in patch && patch.status !== void 0) out.status = patch.status;
1225
+ if ("area" in patch) out.area = patch.area ?? null;
1226
+ if ("model" in patch) out.model = patch.model ?? null;
1227
+ if ("name" in patch) out.droneName = patch.name ?? null;
1228
+ if ("cost_usd" in patch && patch.cost_usd !== void 0) out.costUsd = patch.cost_usd;
1229
+ if ("pr_url" in patch) out.prUrl = patch.pr_url ?? null;
1230
+ if ("branch" in patch) out.branch = patch.branch ?? null;
1231
+ if ("repo" in patch) out.repo = patch.repo ?? null;
1232
+ if ("stop_reason" in patch) out.stopReason = patch.stop_reason ?? null;
1233
+ if ("depends_on" in patch && patch.depends_on) out.dependsOn = patch.depends_on;
1234
+ if ("decision" in patch) out.decision = patch.decision ? JSON.stringify(patch.decision) : null;
1235
+ if ("kind" in patch && patch.kind !== void 0) out.role = patch.kind;
1236
+ if ("review_verdict" in patch) out.reviewVerdict = patch.review_verdict ?? null;
1237
+ this.setRunRefField(out, "heroId", patch, "hero_run_id");
1238
+ this.setRunRefField(out, "resolvesRunId", patch, "resolves_run_id");
1239
+ this.setRunRefField(out, "supersededById", patch, "superseded_by");
1240
+ this.setRunRefField(out, "reviewsRunId", patch, "reviews_run_id");
1241
+ return out;
1242
+ }
1243
+ // Положить self-ref прогона в прод-патч: локальный number → отложенная ссылка
1244
+ // {__runRef: runKey} (резолв в cuid на флаше, resolveArgs/materializeRefs). Явный
1245
+ // null → очистка поля. Локальный id без runKey (прогон не в зеркале) — пропускаем.
1246
+ setRunRefField(out, prodKey, patch, localKey) {
1247
+ if (!(localKey in patch)) return;
1248
+ const localId = patch[localKey];
1249
+ if (localId == null) {
1250
+ out[prodKey] = null;
1251
+ return;
1252
+ }
1253
+ const key = this.keyByLocal.get(localId);
1254
+ if (key) out[prodKey] = runRef(key);
1255
+ }
1256
+ enqueueRun(run, fields) {
1257
+ if (Object.keys(fields).length === 0) return;
1258
+ this.enqueue({ t: "run", runKey: run.runKey, taskRef: run.task_slug, fields });
1259
+ }
1260
+ // ── runs ─────────────────────────────────────────────────────────────────────
1261
+ createRun(input) {
1262
+ const rows = [...this.runs.values()].map((r) => ({ id: r.id, name: r.name, status: r.status }));
1263
+ const name = input.name ?? pickName(rows);
1264
+ const id = this.nextRunId++;
1265
+ const now = stamp();
1266
+ const d = {
1267
+ id,
1268
+ runKey: randomUUID(),
1269
+ task_slug: input.task_slug,
1270
+ name,
1271
+ kind: input.kind ?? "drone",
1272
+ // роль прогона: дефолт — рабочий дрон (TASWM-78)
1273
+ hero_run_id: input.hero_run_id ?? null,
1274
+ // отряд героя (задел иерархии)
1275
+ repo: normalizeRepo(input.repo || this.defaultRepo),
1276
+ branch: null,
1277
+ status: "queued",
1278
+ area: input.area ?? null,
1279
+ depends_on: input.depends_on && input.depends_on.length ? input.depends_on : [],
1280
+ model: input.model ?? null,
1281
+ cost_usd: 0,
1282
+ pr_url: null,
1283
+ superseded_by: null,
1284
+ decision: input.decision ?? null,
1285
+ stop_reason: null,
1286
+ respawns: 0,
1287
+ resolves_run_id: input.resolves_run_id ?? null,
1288
+ reviews_run_id: input.reviews_run_id ?? null,
1289
+ review_verdict: null,
1290
+ task_title: null,
1291
+ task_desc: null,
1292
+ task_url: null,
1293
+ created_at: now,
1294
+ updated_at: now
1295
+ };
1296
+ this.runs.set(id, d);
1297
+ this.keyByLocal.set(id, d.runKey);
1298
+ this.enqueueRun(d, this.runFields({
1299
+ status: "queued",
1300
+ area: d.area,
1301
+ model: d.model,
1302
+ name: d.name,
1303
+ repo: d.repo,
1304
+ depends_on: d.depends_on,
1305
+ decision: d.decision,
1306
+ kind: d.kind,
1307
+ hero_run_id: d.hero_run_id,
1308
+ resolves_run_id: d.resolves_run_id,
1309
+ // резолвер конфликта → связь на review-прогон
1310
+ reviews_run_id: d.reviews_run_id
1311
+ // ревьюер (авто-ревью, TASWM-148) → связь на review-прогон
1312
+ }));
1313
+ 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}`;
1314
+ this.addEvent(id, "spawned", spawnNote);
1315
+ return this.mat(d);
1316
+ }
1317
+ getRun(id) {
1318
+ const d = this.runs.get(id);
1319
+ return d ? this.mat(d) : null;
1320
+ }
1321
+ listRuns(status) {
1322
+ const all = [...this.runs.values()].filter((r) => status ? r.status === status : true);
1323
+ all.sort((a, b) => b.id - a.id);
1324
+ return all.map((d) => this.mat(d));
1325
+ }
1326
+ /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
1327
+ squadRuns(heroId) {
1328
+ return [...this.runs.values()].filter((r) => r.hero_run_id === heroId).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
1329
+ }
1330
+ updateRun(id, patch) {
1331
+ const d = this.runs.get(id);
1332
+ if (!d) return null;
1333
+ if (Object.keys(patch).length === 0) return this.mat(d);
1334
+ if ("pid" in patch || "worktree_path" in patch) {
1335
+ const rt = this.runtime.get(id) ?? { pid: null, worktree_path: null };
1336
+ if ("pid" in patch) rt.pid = patch.pid ?? null;
1337
+ if ("worktree_path" in patch) rt.worktree_path = patch.worktree_path ?? null;
1338
+ this.runtime.set(id, rt);
1339
+ }
1340
+ for (const [k, v] of Object.entries(patch)) {
1341
+ if (k === "pid" || k === "worktree_path") continue;
1342
+ d[k] = v;
1343
+ }
1344
+ d.updated_at = stamp();
1345
+ this.enqueueRun(d, this.runFields(patch));
1346
+ return this.mat(d);
1347
+ }
1348
+ incRespawn(id) {
1349
+ const d = this.runs.get(id);
1350
+ if (!d) return 0;
1351
+ d.respawns += 1;
1352
+ d.updated_at = stamp();
1353
+ return d.respawns;
1354
+ }
1355
+ cancel(id, reason) {
1356
+ const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
1357
+ if (run) this.addEvent(id, "stopped", reason);
1358
+ return run;
1359
+ }
1360
+ activeResolverFor(reviewRunId) {
1361
+ const cands = [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId && !RESOLVER_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
1362
+ return cands.length ? this.mat(cands[0]) : null;
1363
+ }
1364
+ resolverCountFor(reviewRunId) {
1365
+ return [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId).length;
1366
+ }
1367
+ // failed/stalled ревьюеры (сорвался спавн ДО вердикта) в счёт НЕ идут — см. db.ts:reviewerCountFor.
1368
+ reviewerCountFor(reviewRunId) {
1369
+ return [...this.runs.values()].filter(
1370
+ (r) => r.reviews_run_id === reviewRunId && r.status !== "failed" && r.status !== "stalled"
1371
+ ).length;
1372
+ }
1373
+ // Жёсткий кап ревью-спавнов (TASWM-204) — см. db.ts:reviewerAttemptCountFor.
1374
+ reviewerAttemptCountFor(reviewRunId) {
1375
+ return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).length;
1376
+ }
1377
+ // Последняя попытка ревьюера (TASWM-196) — см. db.ts:lastReviewerAttemptFor.
1378
+ lastReviewerAttemptFor(reviewRunId) {
1379
+ const cands = [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).sort((a, b) => b.id - a.id);
1380
+ return cands.length ? this.mat(cands[0]) : null;
1381
+ }
1382
+ // Живые/ждущие ревьюер-прогоны (TASWM-204) — см. db.ts:activeReviewersFor.
1383
+ activeReviewersFor(reviewRunId) {
1384
+ return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId && !TERMINAL_STATUSES.includes(r.status)).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
1385
+ }
1386
+ activeRunForSlug(slug) {
1387
+ const cands = [...this.runs.values()].filter((r) => r.task_slug === slug && !SLUG_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
1388
+ return cands.length ? this.mat(cands[0]) : null;
1389
+ }
1390
+ runningAreas(repo) {
1391
+ const seen = /* @__PURE__ */ new Set();
1392
+ for (const r of this.runs.values()) {
1393
+ if (!(r.status === "running" || r.status === "blocked")) continue;
1394
+ if (r.area == null) continue;
1395
+ if (repo !== void 0 && r.repo !== repo) continue;
1396
+ seen.add(r.area);
1397
+ }
1398
+ return [...seen];
1399
+ }
1400
+ unmetDeps(depends_on) {
1401
+ if (!depends_on || depends_on.length === 0) return [];
1402
+ const done = /* @__PURE__ */ new Set();
1403
+ for (const r of this.runs.values()) {
1404
+ if (r.status === "done" && r.resolves_run_id == null) done.add(r.task_slug);
1405
+ }
1406
+ return depends_on.filter((slug) => !done.has(slug));
1407
+ }
1408
+ distinctRepos() {
1409
+ const seen = /* @__PURE__ */ new Set();
1410
+ for (const r of this.runs.values()) if (r.repo) seen.add(r.repo);
1411
+ return [...seen];
1412
+ }
1413
+ // ── questions ──────────────────────────────────────────────────────────────
1414
+ ask(run_id, text, options) {
1415
+ const q = {
1416
+ id: this.nextQuestionId++,
1417
+ run_id,
1418
+ text,
1419
+ options: options && options.length ? options : null,
1420
+ status: "open",
1421
+ answer: null,
1422
+ answered_by: null,
1423
+ escalated: false,
1424
+ asked_at: stamp(),
1425
+ answered_at: null
1426
+ };
1427
+ this.questionsLog.push(q);
1428
+ this.updateRun(run_id, { status: "blocked" });
1429
+ this.addEvent(run_id, "blocked", text);
1430
+ const key = this.runKeyFor(run_id);
1431
+ if (key) this.enqueue({ t: "ask", runKey: key, text });
1432
+ return q;
1433
+ }
1434
+ escalateQuestion(id) {
1435
+ const q = this.questionsLog.find((x) => x.id === id);
1436
+ if (q) q.escalated = true;
1437
+ }
1438
+ withdrawQuestion(id, reason) {
1439
+ const q = this.questionsLog.find((x) => x.id === id);
1440
+ if (q && q.status === "open") {
1441
+ q.status = "withdrawn";
1442
+ q.answer = reason;
1443
+ q.answered_at = stamp();
1444
+ }
1445
+ }
1446
+ answer(question_id, answer, by = "human") {
1447
+ const q = this.questionsLog.find((x) => x.id === question_id);
1448
+ if (q && q.status === "open") {
1449
+ q.status = "answered";
1450
+ q.answer = answer;
1451
+ q.answered_by = by;
1452
+ q.answered_at = stamp();
1453
+ this.updateRun(q.run_id, { status: "running" });
1454
+ this.addEvent(q.run_id, "answered", answer, by);
1455
+ }
1456
+ return q ? { ...q } : null;
1457
+ }
1458
+ supersede(oldId, newId, by = "human") {
1459
+ this.updateRun(oldId, { superseded_by: newId });
1460
+ this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1461
+ }
1462
+ getQuestion(id) {
1463
+ const q = this.questionsLog.find((x) => x.id === id);
1464
+ return q ? { ...q } : null;
1465
+ }
1466
+ openQuestions() {
1467
+ return this.questionsLog.filter((q) => q.status === "open").sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
1468
+ }
1469
+ runIdsWithQuestions() {
1470
+ return [...new Set(this.questionsLog.map((q) => q.run_id))];
1471
+ }
1472
+ /**
1473
+ * Открытые вопросы дронов отряда Героя #heroId (вопрос → его прогон → hero_run_id).
1474
+ * Гейт «отвечать только своим» для answer_drone + список для squad_status (TASWM-81).
1475
+ */
1476
+ openQuestionsFor(heroId) {
1477
+ 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 }));
1478
+ }
1479
+ // ── events / хроника ─────────────────────────────────────────────────────────
1480
+ addEvent(run_id, kind, text, actor = null) {
1481
+ const ev = { id: this.nextEventId++, run_id, kind, text: text ?? null, actor: actor ?? null, at: stamp() };
1482
+ this.eventsLog.push(ev);
1483
+ if (run_id == null) {
1484
+ this.persistGlobalEvent({ kind, text: ev.text, actor: ev.actor, at: ev.at });
1485
+ return;
1486
+ }
1487
+ const key = this.runKeyFor(run_id);
1488
+ if (key) this.enqueue({ t: "event", runKey: key, kind, text: text ?? null, actor: actor ?? null });
1489
+ }
1490
+ events(run_id) {
1491
+ return this.eventsLog.filter((e) => e.run_id === run_id).sort((a, b) => a.id - b.id).map((e) => ({ ...e }));
1492
+ }
1493
+ history(limit = 50) {
1494
+ const items = [];
1495
+ const runById = (id) => id == null ? null : this.runs.get(id) ?? null;
1496
+ const nameOf = (r, runId) => r?.name ?? `#${runId}`;
1497
+ for (const q of this.questionsLog) {
1498
+ const r = runById(q.run_id);
1499
+ items.push({
1500
+ kind: "qa",
1501
+ at: q.answered_at ?? q.asked_at,
1502
+ slug: r?.task_slug ?? "?",
1503
+ name: nameOf(r, q.run_id),
1504
+ repo: r?.repo ?? "",
1505
+ area: r?.area ?? null,
1506
+ qid: q.id,
1507
+ question: q.text,
1508
+ options: q.options,
1509
+ answer: q.answer,
1510
+ answered_by: q.answered_by,
1511
+ escalated: q.escalated,
1512
+ open: q.status === "open",
1513
+ title: r?.task_title ?? null,
1514
+ pr: r?.pr_url ?? null,
1515
+ taskUrl: r?.task_url ?? null
1516
+ });
1517
+ }
1518
+ for (const e of this.eventsLog) {
1519
+ if (!isMilestoneKind(e.kind)) continue;
1520
+ const global = e.run_id == null;
1521
+ const r = runById(e.run_id);
1522
+ items.push({
1523
+ kind: "milestone",
1524
+ at: e.at,
1525
+ slug: r?.task_slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1526
+ name: global ? "\u0440\u043E\u0439" : nameOf(r, e.run_id),
1527
+ repo: r?.repo ?? "",
1528
+ event: e.kind,
1529
+ text: e.text,
1530
+ actor: e.actor,
1531
+ title: r?.task_title ?? null,
1532
+ pr: r?.pr_url ?? null,
1533
+ taskUrl: r?.task_url ?? null
1534
+ });
1535
+ }
1536
+ items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1537
+ return items.slice(0, limit);
1538
+ }
1539
+ mergedToday() {
1540
+ const today = stamp().slice(0, 10);
1541
+ return this.eventsLog.filter((e) => e.kind === "merged" && e.at.slice(0, 10) === today).length;
1542
+ }
1543
+ // ── штормовой режим ──────────────────────────────────────────────────────────
1544
+ lastGlobal(kinds) {
1545
+ for (let i = this.eventsLog.length - 1; i >= 0; i--) {
1546
+ const e = this.eventsLog[i];
1547
+ if (e.run_id == null && kinds.includes(e.kind)) return e;
1548
+ }
1549
+ return null;
1550
+ }
1551
+ stormActive() {
1552
+ return this.lastGlobal(["storm_start", "storm_end"])?.kind === "storm_start";
1553
+ }
1554
+ currentStormStartedAt() {
1555
+ const e = this.lastGlobal(["storm_start", "storm_end"]);
1556
+ return e?.kind === "storm_start" ? e.at : null;
1557
+ }
1558
+ startStorm(reason) {
1559
+ this.addEvent(null, "storm_start", reason);
1560
+ }
1561
+ endStorm(reason) {
1562
+ this.addEvent(null, "storm_end", reason);
1563
+ }
1564
+ recordDeath(runId, note) {
1565
+ this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1566
+ }
1567
+ deathCountSince(cutoffSeconds) {
1568
+ const cutoff = stampFromEpochSec(cutoffSeconds);
1569
+ return this.eventsLog.filter((e) => e.kind === "died" && e.at >= cutoff).length;
1570
+ }
1571
+ spawnFailCountSince(cutoffSeconds) {
1572
+ const cutoff = stampFromEpochSec(cutoffSeconds);
1573
+ return this.eventsLog.filter((e) => {
1574
+ if (e.kind !== "failed" || e.run_id == null || e.at < cutoff) return false;
1575
+ const r = this.runs.get(e.run_id);
1576
+ return !!r && r.resolves_run_id == null && r.reviews_run_id == null;
1577
+ }).length;
1578
+ }
1579
+ recordStormProbe(probe, ext) {
1580
+ this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
1581
+ }
1582
+ latestStormProbe() {
1583
+ const e = this.lastGlobal(["storm_probe"]);
1584
+ return e ? { rec: decodeProbe(e.text), at: e.at } : null;
1585
+ }
1586
+ stormProbeRecordsSince(sinceIso) {
1587
+ 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));
1588
+ }
1589
+ // ── гидрация зеркала из прода (старт + реконсайл-рефреш) ──────────────────────
1590
+ async hydrate() {
1591
+ let res;
1592
+ try {
1593
+ res = await this.call("swarm_list_runs", { limit: this.listLimit });
1594
+ } catch {
1595
+ return;
1596
+ }
1597
+ if (res?.isError) return;
1598
+ const runs = res.structuredContent?.runs ?? [];
1599
+ for (const pr of runs) this.hydrateRun(pr);
1600
+ for (const pr of runs) this.linkRefs(pr);
1601
+ }
1602
+ /**
1603
+ * Реконсайл рантайма после рестарта демона (client-режим). pid/worktree_path живут в
1604
+ * ОТДЕЛЬНОЙ Map (машинное — в прод не уходят) и рестарт демона НЕ переживают: гидрация
1605
+ * из прода приносит running/blocked-прогоны с pid=null. Но демон и дроны — РАЗНЫЕ
1606
+ * detached-процессы (launcher: detached+unref), поэтому рестарт демона дронов НЕ убивает
1607
+ * — часть их ещё жива. Сопоставляем каждый running/blocked-прогон зеркала с живым
1608
+ * процессом (ps, injected listProcesses) по детерминированному worktree
1609
+ * (<worktreesDir>/<repo-basename>/<slug>):
1610
+ * • нашёлся живой процесс → восстанавливаем pid+worktree_path (дрон жив — overmind его
1611
+ * НЕ похоронит и не заспавнит второго поверх);
1612
+ * • не нашёлся → дрон не пережил рестарт: восстанавливаем worktree_path, если каталог
1613
+ * цел (для respawn --continue), pid оставляем null. overmind (tickOnce шаг 3) ловит
1614
+ * running/blocked с pid=null как смерть и оживляет/усыновляет/стопорит по общей ветке.
1615
+ * Рантайм пишем ПРЯМО в runtime-Map (в обход updateRun) — чисто локально, без outbox и
1616
+ * без прод-раунд-трипа. Идемпотентно; вызывается один раз на старте после hydrate().
1617
+ * Возвращает счётчики (для лога демона) + reconciled (TASWM-145): false → listProcesses()
1618
+ * бросил (ENFILE и т.п.), реконсайл этого запуска ПРОПУЩЕН целиком (не «confirmed пусто» —
1619
+ * см. коммент в catch ниже). Вызывающий (runDaemon) ОБЯЗАН пропустить startup-sync
1620
+ * (syncCrashedOnStartup) при reconciled=false: та трактует pid==null как «не пережил
1621
+ * рестарт» и безусловно хоронит (stalled/done) — при пропущенном реконсайле это massively
1622
+ * похоронило бы ВСЕХ running/blocked, включая реально живых detached-дронов, тем же
1623
+ * транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
1624
+ */
1625
+ reconcileRuntime(opts) {
1626
+ const wtExists = opts.worktreeExists ?? existsSync2;
1627
+ const pidByWorktree = /* @__PURE__ */ new Map();
1628
+ try {
1629
+ for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
1630
+ } catch (e) {
1631
+ logTransientSpawnError("ps (reconcileRuntime)", e);
1632
+ return { alive: 0, dead: 0, reconciled: false };
1633
+ }
1634
+ let alive = 0;
1635
+ let dead = 0;
1636
+ for (const d of this.runs.values()) {
1637
+ if (d.status !== "running" && d.status !== "blocked") continue;
1638
+ if (this.runtime.get(d.id)?.pid != null) continue;
1639
+ const workerWt = droneWorktreePath(opts.worktreesDir, d.repo || this.defaultRepo, d.task_slug);
1640
+ let matchWt = null;
1641
+ let matchPid = null;
1642
+ if (d.resolves_run_id == null && d.reviews_run_id == null) {
1643
+ const p = pidByWorktree.get(workerWt);
1644
+ if (p != null) {
1645
+ matchWt = workerWt;
1646
+ matchPid = p;
1647
+ }
1648
+ } else if (d.resolves_run_id != null) {
1649
+ const prefix = `${workerWt}-resolve-`;
1650
+ for (const [wt, p] of pidByWorktree) {
1651
+ if (wt.startsWith(prefix)) {
1652
+ matchWt = wt;
1653
+ matchPid = p;
1654
+ break;
1655
+ }
1656
+ }
1657
+ } else {
1658
+ const reviewed = this.runs.get(d.reviews_run_id);
1659
+ const reviewedWt = reviewed ? droneWorktreePath(opts.worktreesDir, reviewed.repo || this.defaultRepo, reviewed.task_slug) : workerWt;
1660
+ const prefix = `${reviewedWt}-review-`;
1661
+ for (const [wt, p] of pidByWorktree) {
1662
+ if (wt.startsWith(prefix)) {
1663
+ matchWt = wt;
1664
+ matchPid = p;
1665
+ break;
1666
+ }
1667
+ }
1668
+ }
1669
+ if (matchPid != null && matchWt) {
1670
+ this.runtime.set(d.id, { pid: matchPid, worktree_path: matchWt });
1671
+ alive++;
1672
+ } else {
1673
+ const wt = d.resolves_run_id == null && d.reviews_run_id == null && wtExists(workerWt) ? workerWt : null;
1674
+ this.runtime.set(d.id, { pid: null, worktree_path: wt });
1675
+ dead++;
1676
+ }
1677
+ }
1678
+ return { alive, dead, reconciled: true };
1679
+ }
1680
+ // Долинковать self-ref прогона по прод-cuid → локальному id (после того как все
1681
+ // прогоны гидрированы). Идемпотентно: ставит найденное, иначе не трогает.
1682
+ linkRefs(pr) {
1683
+ const localId = this.localIdForProdId(String(pr.id ?? ""));
1684
+ const local = localId === void 0 ? void 0 : this.runs.get(localId);
1685
+ if (!local) return;
1686
+ const hero = pr.heroId ?? null;
1687
+ if (hero) local.hero_run_id = this.localIdForProdId(hero) ?? local.hero_run_id;
1688
+ const res = pr.resolvesRunId ?? null;
1689
+ if (res) local.resolves_run_id = this.localIdForProdId(res) ?? local.resolves_run_id;
1690
+ const sup = pr.supersededById ?? null;
1691
+ if (sup) local.superseded_by = this.localIdForProdId(sup) ?? local.superseded_by;
1692
+ const rev = pr.reviewsRunId ?? null;
1693
+ if (rev) local.reviews_run_id = this.localIdForProdId(rev) ?? local.reviews_run_id;
1694
+ }
1695
+ /**
1696
+ * Авто-интейк (TASWM-100): взять следующую claimable-задачу владельца токена через
1697
+ * прод-тул swarm_claim_work и завести её прогон в зеркале. claim атомарен НА СЕРВЕРЕ
1698
+ * (FIFO по createdAt + pg_advisory_xact_lock + дедуп «нет активного прогона») — гонку
1699
+ * двух тиков overmind и двойной claim решает прод, клиенту делать этого не надо. Непустой
1700
+ * ответ → ГИДРИРУЕМ созданный прогон как `p:<cuid>` (тот же путь, что hydrate/реконсайл),
1701
+ * а НЕ createRun: локальный create завёл бы ВТОРОЙ прогон с create-outbox по taskRef и
1702
+ * задвоил бы зеркало с уже существующим прод-прогоном. Гидрация лишь зеркалит прод — его
1703
+ * cuid известен сразу, никакого create-flush. Возвращает локальный Run (queued; overmind
1704
+ * заспавнит его следующим тиком) либо null: нет claimable-задачи / прод недоступен / тул
1705
+ * отверг — во всех случаях тик продолжается, следующий ретраит.
1706
+ *
1707
+ * TASWM-169: repoFullNames — ЖИВОЙ реестр демона на момент ЭТОГО вызова, а не
1708
+ * последняя heartbeat-декларация (heartbeat — своя кадансность, ~15с; claim — ~60с;
1709
+ * плюс окно до первого успешного heartbeat при рестарте) — свежий аргумент здесь
1710
+ * убирает то окно рассинхрона целиком. Пусто (демон без SWARM_REPOS) → ключ не
1711
+ * шлём — прод фолбэкается на последнюю heartbeat-декларацию (или без фильтра).
1712
+ */
1713
+ async claim() {
1714
+ let res;
1715
+ try {
1716
+ res = await this.call("swarm_claim_work", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
1717
+ } catch {
1718
+ return null;
1719
+ }
1720
+ if (res?.isError) return null;
1721
+ const sc = res.structuredContent ?? {};
1722
+ if (!sc.claimed || typeof sc.run !== "object" || sc.run === null) return null;
1723
+ const local = this.hydrateRun(sc.run);
1724
+ return local ? this.mat(local) : null;
1725
+ }
1726
+ /**
1727
+ * Живой потолок дронов владельца из Taskless (TASWM-126): читает who_am_i.maxDrones
1728
+ * (личная настройка uiPrefs.swarm.maxDrones, дефолт 3). overmind капит им ВСЕ спавны —
1729
+ * очередь (шаг 1), резолвер (шаг 2), авто-claim (шаг 5) — чтобы активных дронов не было
1730
+ * больше числа из UI (инцидент 15.07: 4-6 дронов разом уронили ноут). who_am_i отдаёт
1731
+ * плоский JSON в text-контенте (не {display,data}). Прод недоступен / нет валидного числа
1732
+ * → null: ридер (makeMaxDronesReader) берёт env-фолбэк.
1733
+ */
1734
+ async ownerMaxDrones() {
1735
+ let res;
1736
+ try {
1737
+ res = await this.call("who_am_i", {});
1738
+ } catch {
1739
+ return null;
1740
+ }
1741
+ if (res?.isError) return null;
1742
+ const obj = jsonFromTextContent(res);
1743
+ const n = obj?.maxDrones;
1744
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
1745
+ }
1746
+ /**
1747
+ * Live verifyMode проекта из Taskless (TASWM-171): читает swarm_repo_config по
1748
+ * repoFullName (owner/repo из origin — см. git.remoteRepoFullName) — Project.swarmConfig.
1749
+ * verifyMode ("local"/"ci"). Прод недоступен / repo не смэплен на swarm-enabled проект
1750
+ * владельца токена (config=null в ответе) → null: ридер (makeVerifyModeReader) берёт
1751
+ * env-фолбэк SWARM_VERIFY.
1752
+ */
1753
+ async repoVerifyMode(repoFullName) {
1754
+ let res;
1755
+ try {
1756
+ res = await this.call("swarm_repo_config", { repoFullName });
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+ if (res?.isError) return null;
1761
+ const sc = res.structuredContent ?? {};
1762
+ const v = sc.config?.verifyMode;
1763
+ return v === "local" || v === "ci" ? v : null;
1764
+ }
1765
+ /**
1766
+ * Пульс overmind в прод (TASWM-129): swarm_heartbeat обновляет User.swarmHeartbeatAt —
1767
+ * дашборд /swarm сравнивает свежесть со своим порогом устаревания («нет связи с
1768
+ * овермайндом» вместо устаревшего «живого» роя). Best-effort, как ownerMaxDrones/claim:
1769
+ * прод недоступен/тул отверг → просто не обновили в этом тике, следующий ретраит —
1770
+ * не роняем тик демона из-за упавшего пульса.
1771
+ *
1772
+ * TASWM-169: заодно декларирует repoFullNames (репо-реестр демона) — та же
1773
+ * кадансность, что и пульс, естественная точка «сказать серверу, что обслуживаю».
1774
+ * Пустой реестр (демон не сконфигурирован на multi-repo/SWARM_REPOS) → ключ не
1775
+ * шлём вовсе (undefined) — прод НЕ трогает прошлую декларацию (см. swarm.ts), не
1776
+ * стирает её ложным «репо больше нет» из-за демона без SWARM_REPOS в env.
1777
+ */
1778
+ async heartbeat() {
1779
+ try {
1780
+ await this.call("swarm_heartbeat", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
1781
+ } catch {
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Встроенный надзор (TASWM-194): нотификация аномалии в инбокс владельца — прод-MCP
1786
+ * `swarm_notify`, ПРЯМОЙ вызов (НЕ outbox): аномалия — «сообщи СЕЙЧАС», не durable-факт,
1787
+ * который обязан пережить рестарт демона в неизменном порядке (как run-апдейты/события) —
1788
+ * задержка на цикл outbox-флаша (обычно доли секунды, но в худшем случае — до
1789
+ * восстановления сети) тут не оправдана.
1790
+ * runId (локальный numeric) резолвим в прод-cuid по той же карте, что flush аутбокса
1791
+ * (runKeyFor→prodIdByKey); прогон ещё не успел долететь до прода (cuid неизвестен) →
1792
+ * шлём без runId (нотификация всё равно уйдёт, просто без SwarmEvent-вехи на прогоне).
1793
+ *
1794
+ * БРОСАЕТ при сбое (транспорт ИЛИ isError:true от тула) — НЕ глотает, в отличие от
1795
+ * heartbeat() выше (code-review TASWM-194): вызывающий (overmind.ts tickOnce) коммитит
1796
+ * AnomalyTracker ТОЛЬКО после успешного return — молча проглоченная ошибка означала бы,
1797
+ * что трекер считает недоставленную нотификацию отправленной и молчит renotifyCooldownMs
1798
+ * (по умолчанию час) без единой реальной попытки. `res?.isError` — тот же паттерн, что
1799
+ * ownerMaxDrones() чуть выше: registerTool ловит throw внутри run() и превращает его в
1800
+ * `{isError:true}`, а не в отклонённый промис — без этой проверки схемная/бизнес-ошибка
1801
+ * тула (напр. TASWM-194 review: body > zod max) молча резолвилась бы успехом.
1802
+ */
1803
+ async notifyAnomaly(signal) {
1804
+ const prodRunId = signal.runId != null ? this.prodIdByKey.get(this.runKeyFor(signal.runId) ?? "") : void 0;
1805
+ const res = await this.call("swarm_notify", {
1806
+ tag: signal.key,
1807
+ title: signal.title,
1808
+ body: signal.body,
1809
+ ...signal.href ? { href: signal.href } : {},
1810
+ ...prodRunId ? { runId: prodRunId } : {}
1811
+ });
1812
+ if (res?.isError) throw new Error(`swarm_notify \u043E\u0442\u0432\u0435\u0440\u0433 \xAB${signal.key}\xBB`);
1813
+ }
1814
+ /**
1815
+ * TASWM-169: прод присылает run.repo как GitHub `owner/repo` для задач, взятых
1816
+ * через claim-фильтр (matchedRepo в swarm_claim_work, см. api/mcp/tools/swarm.ts) —
1817
+ * сервер не знает локальных fs-путей. Транслируем в путь по СВОЕМУ репо-реестру
1818
+ * (см. repoRegistry.ts), т.к. git-операции (addWorktree/droneWorktreePath) ждут
1819
+ * именно путь. Не нашли в реестре (репо не наш / ручной `--repo <path>` спавн, где
1820
+ * repo — ВСЕГДА уже путь, старый формат) → возвращаем как есть без изменений
1821
+ * (обратная совместимость: раньше pr.repo ВСЕГДА был локальным путём).
1822
+ */
1823
+ resolveRepo(raw) {
1824
+ if (!raw) return "";
1825
+ return this.repoPathByFullName.get(raw) ?? raw;
1826
+ }
1827
+ hydrateRun(pr) {
1828
+ const prodId = String(pr.id ?? "");
1829
+ if (!prodId) return null;
1830
+ const runKey = `p:${prodId}`;
1831
+ this.prodIdByKey.set(runKey, prodId);
1832
+ let local = [...this.runs.values()].find((r) => r.runKey === runKey);
1833
+ if (!local) local = [...this.runs.values()].find((r) => this.prodIdByKey.get(r.runKey) === prodId);
1834
+ if (!local) {
1835
+ const id = this.nextRunId++;
1836
+ local = {
1837
+ id,
1838
+ runKey,
1839
+ task_slug: "",
1840
+ name: `#${id}`,
1841
+ kind: "drone",
1842
+ hero_run_id: null,
1843
+ repo: "",
1844
+ branch: null,
1845
+ status: "queued",
1846
+ area: null,
1847
+ depends_on: [],
1848
+ model: null,
1849
+ cost_usd: 0,
1850
+ pr_url: null,
1851
+ superseded_by: null,
1852
+ decision: null,
1853
+ stop_reason: null,
1854
+ respawns: 0,
1855
+ resolves_run_id: null,
1856
+ reviews_run_id: null,
1857
+ review_verdict: null,
1858
+ task_title: null,
1859
+ task_desc: null,
1860
+ task_url: null,
1861
+ created_at: stamp(),
1862
+ updated_at: stamp()
1863
+ };
1864
+ this.runs.set(id, local);
1865
+ this.keyByLocal.set(id, runKey);
1866
+ }
1867
+ const slug = pr.taskSlug ?? pr.task?.slug ?? pr.taskId ?? local.task_slug;
1868
+ local.task_slug = slug || local.task_slug;
1869
+ local.task_title = pr.taskTitle ?? local.task_title;
1870
+ local.task_desc = pr.taskDesc ?? local.task_desc;
1871
+ local.name = pr.droneName ?? local.name;
1872
+ local.kind = pr.role ?? local.kind;
1873
+ const heroProd = pr.heroId ?? null;
1874
+ if (heroProd) local.hero_run_id = this.localIdForProdId(heroProd) ?? local.hero_run_id;
1875
+ const resProd = pr.resolvesRunId ?? null;
1876
+ if (resProd) local.resolves_run_id = this.localIdForProdId(resProd) ?? local.resolves_run_id;
1877
+ const supProd = pr.supersededById ?? null;
1878
+ if (supProd) local.superseded_by = this.localIdForProdId(supProd) ?? local.superseded_by;
1879
+ const revProd = pr.reviewsRunId ?? null;
1880
+ if (revProd) local.reviews_run_id = this.localIdForProdId(revProd) ?? local.reviews_run_id;
1881
+ local.review_verdict = pr.reviewVerdict ?? local.review_verdict;
1882
+ local.repo = this.resolveRepo(pr.repo ?? null);
1883
+ local.branch = pr.branch ?? null;
1884
+ local.status = pr.status ?? local.status;
1885
+ local.area = pr.area ?? null;
1886
+ local.depends_on = Array.isArray(pr.dependsOn) ? pr.dependsOn : [];
1887
+ local.model = pr.model ?? null;
1888
+ local.cost_usd = typeof pr.costUsd === "number" ? pr.costUsd : 0;
1889
+ local.pr_url = pr.prUrl ?? null;
1890
+ local.stop_reason = pr.stopReason ?? null;
1891
+ local.respawns = typeof pr.respawns === "number" ? pr.respawns : 0;
1892
+ local.decision = parseDecision(pr.decision);
1893
+ local.created_at = toStamp(pr.createdAt) ?? local.created_at;
1894
+ local.updated_at = toStamp(pr.updatedAt) ?? local.updated_at;
1895
+ const evs = pr.events ?? [];
1896
+ for (const pe of evs) {
1897
+ const eid = String(pe.id ?? "");
1898
+ if (eid && this.seenProdEventIds.has(eid)) continue;
1899
+ if (eid) this.seenProdEventIds.add(eid);
1900
+ const kind = String(pe.kind ?? "");
1901
+ const text = pe.text ?? null;
1902
+ const at = toStamp(pe.at) ?? stamp();
1903
+ if (kind === "question") {
1904
+ this.questionsLog.push({
1905
+ id: this.nextQuestionId++,
1906
+ run_id: local.id,
1907
+ text: text ?? "",
1908
+ options: null,
1909
+ status: "open",
1910
+ answer: null,
1911
+ answered_by: null,
1912
+ escalated: false,
1913
+ asked_at: at,
1914
+ answered_at: null
1915
+ });
1916
+ continue;
1917
+ }
1918
+ const localKind = kind === "answer" ? "answered" : kind;
1919
+ this.eventsLog.push({
1920
+ id: this.nextEventId++,
1921
+ run_id: local.id,
1922
+ kind: localKind,
1923
+ text,
1924
+ actor: pe.actor ?? null,
1925
+ at
1926
+ });
1927
+ if (kind === "answer" || kind === "answered") {
1928
+ const open = [...this.questionsLog].reverse().find((q) => q.run_id === local.id && q.status === "open");
1929
+ if (open) {
1930
+ open.status = "answered";
1931
+ open.answer = text;
1932
+ open.answered_by = pe.actor ?? "human";
1933
+ open.answered_at = at;
1934
+ }
1935
+ }
1936
+ }
1937
+ return local;
1938
+ }
1939
+ // ── flush outbox в прод ──────────────────────────────────────────────────────
1940
+ scheduleFlush() {
1941
+ if (!this.autoFlush || this.flushing || this.flushTimer || this.pending.length === 0) return;
1942
+ this.flushTimer = setTimeout(() => {
1943
+ this.flushTimer = null;
1944
+ void this.flush();
1945
+ }, this.backoffMs);
1946
+ if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
1947
+ }
1948
+ // TASWM-202: create-флаш без прод-id слал taskId=e.taskRef НАПРЯМУЮ — а taskRef это
1949
+ // человеческий слаг (task_slug, напр. «TASWM-171»), не cuid. Резолверы конфликта
1950
+ // рождаются ЛОКАЛЬНО (createRun с uuid-runKey) и попадают в outbox БЕЗ прод-id —
1951
+ // обычные прогоны рождаются в проде (claim) и гидрируются сразу с cuid, эта ветка
1952
+ // их не касается. Прод create-схема ждёт cuid → отбивает слаг → строго
1953
+ // упорядоченный flush стоит на этой голове НАВЕЧНО (ядовитая голова, инцидент
1954
+ // 14.07/18.07, TASWM-201 — общий карантин; здесь — конкретный источник). Резолвим
1955
+ // слаг→cuid через get_task (кэш — taskIdCache) ПЕРЕД тем как слать create.
1956
+ taskIdCache = /* @__PURE__ */ new Map();
1957
+ // task_slug → cuid прода
1958
+ // Create для этого runKey резолвился окончательно неудачно (задача не найдена/
1959
+ // недоступна) — событие/вопросы того же прогона тоже никогда не получат prod-id,
1960
+ // без карантина они бы бросали «нет prod-id» на каждом флаше и СНОВА вставали
1961
+ // ядовитой головой (просто на одну запись ниже по очереди).
1962
+ poisonedRunKeys = /* @__PURE__ */ new Set();
1963
+ // "transient" — прод/сеть недоступны, ответа нет или ошибка НЕ про отсутствие задачи
1964
+ // (offline-стаб CLI, auth-протух, 5xx): НЕ терминально — запись остаётся в outbox и
1965
+ // повторится на следующем флаше (иначе сетевой сбой = потеря прогона). null —
1966
+ // терминально: прод ЯВНО ответил «задачи нет» (task_not_found / not_found в тексте
1967
+ // tool-ошибки) либо успешный ответ без id — только это карантиним.
1968
+ async resolveTaskCuid(taskRef) {
1969
+ const cached = this.taskIdCache.get(taskRef);
1970
+ if (cached) return cached;
1971
+ let res;
1972
+ try {
1973
+ res = await this.call("get_task", { id: taskRef });
1974
+ } catch {
1975
+ return "transient";
770
1976
  }
1977
+ if (!res) return "transient";
1978
+ if (res.isError) {
1979
+ const text = (res.content ?? []).map((c) => c.text ?? "").join(" ");
1980
+ return /not_found/i.test(text) ? null : "transient";
1981
+ }
1982
+ const id = res.structuredContent?.task?.id;
1983
+ if (!id) return null;
1984
+ this.taskIdCache.set(taskRef, id);
1985
+ return id;
771
1986
  }
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" };
1987
+ async resolveArgs(e) {
1988
+ if (e.t === "run") {
1989
+ const fields = this.materializeRefs(e.fields);
1990
+ const prodId2 = this.prodIdByKey.get(e.runKey);
1991
+ if (prodId2) return { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } };
1992
+ const taskId = await this.resolveTaskCuid(e.taskRef);
1993
+ if (taskId === "transient") {
1994
+ throw new Error(`slug\u2192cuid \u0434\u043B\u044F \xAB${e.taskRef}\xBB \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D (\u043F\u0440\u043E\u0434 \u043C\u043E\u0440\u0433\u043D\u0443\u043B) \u2014 retry`);
782
1995
  }
783
- return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
1996
+ if (!taskId) {
1997
+ console.error(
1998
+ `[swarm-client] create-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: \u043F\u0440\u043E\u0434 \u043D\u0435 \u0437\u043D\u0430\u0435\u0442 \u0437\u0430\u0434\u0430\u0447\u0443 \xAB${e.taskRef}\xBB (runKey=${e.runKey}) \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`
1999
+ );
2000
+ this.poisonedRunKeys.add(e.runKey);
2001
+ return "skip";
2002
+ }
2003
+ return { tool: "swarm_run_upsert", args: { taskId, ...fields } };
784
2004
  }
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 };
2005
+ if (this.poisonedRunKeys.has(e.runKey)) {
2006
+ console.error(`[swarm-client] ${e.t}-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: create \u043F\u0440\u043E\u0433\u043E\u043D\u0430 runKey=${e.runKey} \u043D\u0435 \u0440\u0435\u0437\u043E\u043B\u0432\u0438\u043B\u0441\u044F \u0440\u0430\u043D\u0435\u0435 \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`);
2007
+ return "skip";
796
2008
  }
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" };
2009
+ const prodId = this.prodIdByKey.get(e.runKey);
2010
+ 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`);
2011
+ if (e.t === "event") return { tool: "swarm_event_append", args: { runId: prodId, kind: e.kind, text: e.text, actor: e.actor } };
2012
+ return { tool: "swarm_ask", args: { runId: prodId, text: e.text } };
807
2013
  }
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" };
2014
+ // Резолв отложенных self-ref ({__runRef: runKey}) в прод-cuid на флаше. Цель уже
2015
+ // создана в проде (её create-запись с меньшим seq флашится раньше) → cuid известен.
2016
+ // Не резолвится (ещё не создан) — поле выкидываем: лучше не слать, чем слать мусор;
2017
+ // догонит следующий апдейт/реконсайл. Обычные значения (null/строки) — как есть.
2018
+ materializeRefs(fields) {
2019
+ const out = {};
2020
+ for (const [k, v] of Object.entries(fields)) {
2021
+ if (isRunRef(v)) {
2022
+ const cuid = this.prodIdByKey.get(v.__runRef);
2023
+ if (cuid) out[k] = cuid;
2024
+ } else {
2025
+ out[k] = v;
2026
+ }
2027
+ }
2028
+ return out;
2029
+ }
2030
+ /** Слить накопленное в прод. Возвращает число успешно отправленных операций.
2031
+ * Порядок сохраняется; ошибка/недоступность → стоп, буфер остаётся, retry-backoff. */
2032
+ async flush() {
2033
+ if (this.flushing) return 0;
2034
+ this.flushing = true;
2035
+ let sent = 0;
2036
+ try {
2037
+ while (this.pending.length) {
2038
+ const e = this.pending[0];
2039
+ let call;
2040
+ try {
2041
+ call = await this.resolveArgs(e);
2042
+ } catch {
2043
+ break;
2044
+ }
2045
+ if (call === "skip") {
2046
+ this.pending.shift();
2047
+ continue;
2048
+ }
2049
+ let res;
2050
+ try {
2051
+ res = await this.call(call.tool, call.args);
2052
+ } catch {
2053
+ break;
2054
+ }
2055
+ if (res?.isError) break;
2056
+ if (e.t === "run") {
2057
+ const run = res.structuredContent?.run;
2058
+ if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
2059
+ this.prodIdByKey.set(e.runKey, run.id);
2060
+ this.persistMap();
2061
+ }
2062
+ } else {
2063
+ const evId = res.structuredContent?.event?.id;
2064
+ if (evId) this.seenProdEventIds.add(evId);
2065
+ }
2066
+ this.pending.shift();
2067
+ sent++;
2068
+ }
2069
+ this.persistPending();
2070
+ if (this.pending.length === 0) {
2071
+ this.backoffMs = _SwarmClient.BACKOFF_BASE;
2072
+ } else {
2073
+ this.backoffMs = Math.min(this.backoffMs * 2, _SwarmClient.BACKOFF_MAX);
2074
+ this.scheduleFlush();
2075
+ }
2076
+ } finally {
2077
+ this.flushing = false;
2078
+ }
2079
+ return sent;
2080
+ }
2081
+ persistPending() {
2082
+ try {
2083
+ writeFileSync2(this.outboxPath, this.pending.map((e) => JSON.stringify(e)).join("\n") + (this.pending.length ? "\n" : ""));
2084
+ } catch {
2085
+ }
2086
+ }
2087
+ persistMap() {
2088
+ try {
2089
+ writeFileSync2(this.mapPath, JSON.stringify(Object.fromEntries(this.prodIdByKey)));
2090
+ } catch {
2091
+ }
2092
+ }
2093
+ /** Незалитых операций в буфере (для тестов/наблюдаемости). */
2094
+ pendingCount() {
2095
+ return this.pending.length;
2096
+ }
2097
+ close() {
2098
+ if (this.flushTimer) {
2099
+ clearTimeout(this.flushTimer);
2100
+ this.flushTimer = null;
2101
+ }
2102
+ void this.flush();
2103
+ }
2104
+ };
2105
+ function parseDecision(v) {
2106
+ if (!v || typeof v !== "string") return null;
2107
+ try {
2108
+ return JSON.parse(v);
2109
+ } catch {
2110
+ return null;
825
2111
  }
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
2112
  }
832
2113
 
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;
2114
+ // src/repoRegistry.ts
2115
+ import { $ } from "execa";
2116
+ function parseRepoFullName(remoteUrl) {
2117
+ const m = remoteUrl.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(\.git)?\/?$/i);
2118
+ if (!m) return null;
2119
+ return `${m[1]}/${m[2]}`.toLowerCase();
2120
+ }
2121
+ async function resolveRepoFullName(path) {
2122
+ const res = await $({ reject: false })`git -C ${path} remote get-url origin`;
2123
+ if (res.exitCode !== 0) return null;
2124
+ return parseRepoFullName(res.stdout);
2125
+ }
2126
+ async function buildRepoRegistry(paths) {
2127
+ const resolved = await Promise.all(
2128
+ paths.map(async (raw) => {
2129
+ const path = normalizeRepo(raw);
2130
+ if (!path) return null;
2131
+ const repoFullName = await resolveRepoFullName(path);
2132
+ return repoFullName ? { repoFullName, path } : null;
2133
+ })
2134
+ );
2135
+ return resolved.filter((e) => e !== null);
2136
+ }
2137
+
2138
+ // src/web.ts
2139
+ import { randomBytes as randomBytes2 } from "crypto";
2140
+ import { basename as basename2 } from "path";
2141
+ import { serve } from "@hono/node-server";
850
2142
 
851
2143
  // src/diff.ts
852
2144
  import { statSync } from "fs";
853
2145
  import { join as join2 } from "path";
854
- import { $ } from "execa";
2146
+ import { $ as $2 } from "execa";
855
2147
  var MAX_FILES = 200;
856
2148
  var MAX_LINES_PER_FILE = 1500;
857
2149
  var MAX_UNTRACKED = 60;
@@ -971,12 +2263,12 @@ function truncateFile(f) {
971
2263
  }
972
2264
  async function resolveBase(wt) {
973
2265
  const tryRef = async (ref) => {
974
- const sha = (await $({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
2266
+ const sha = (await $2({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
975
2267
  if (!sha) return null;
976
- const mb = (await $({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
2268
+ const mb = (await $2({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
977
2269
  return mb || null;
978
2270
  };
979
- const originHead = (await $({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
2271
+ const originHead = (await $2({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
980
2272
  const candidates = [originHead, "origin/main", "origin/master", "main", "master"].filter(Boolean);
981
2273
  for (const c of candidates) {
982
2274
  const mb = await tryRef(c);
@@ -995,16 +2287,16 @@ async function collectRunDiff(worktree, opts = {}) {
995
2287
  const stub = (reason) => ({ ok: false, reason, pr: opts.pr ?? null, files: [], added: 0, removed: 0 });
996
2288
  const gone = opts.status === "queued" ? "\u0434\u0440\u043E\u043D \u0435\u0449\u0451 \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u043A\u043E\u0434\u0430 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442" : "worktree \u0443\u0431\u0440\u0430\u043D \u2014 \u043A\u043E\u0434 \u0441\u043C\u043E\u0442\u0440\u0438 \u0432 PR";
997
2289
  if (!worktree) return stub(gone);
998
- const isWt = (await $({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
2290
+ const isWt = (await $2({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
999
2291
  if (isWt !== "true") return stub(gone);
1000
2292
  const base2 = await resolveBase(worktree);
1001
2293
  const from = base2 ?? "HEAD";
1002
- const trackedTxt = (await $({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
2294
+ const trackedTxt = (await $2({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
1003
2295
  let files = parseUnifiedDiff(trackedTxt);
1004
- const untrackedRaw = (await $({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
2296
+ const untrackedRaw = (await $2({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
1005
2297
  const untracked = untrackedRaw.split("\0").filter(Boolean).slice(0, MAX_UNTRACKED);
1006
2298
  for (const rel of untracked) {
1007
- const t = (await $({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
2299
+ const t = (await $2({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
1008
2300
  for (const pf of parseUnifiedDiff(t)) {
1009
2301
  pf.status = "added";
1010
2302
  if (!pf.path || pf.path === "/dev/null") pf.path = rel;
@@ -1027,9 +2319,49 @@ async function collectRunDiff(worktree, opts = {}) {
1027
2319
  };
1028
2320
  }
1029
2321
 
2322
+ // src/gh.ts
2323
+ import { $ as $3 } from "execa";
2324
+ var realChecksRunner = async (pr) => (await $3({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
2325
+ async function prCheckRows(pr, run) {
2326
+ try {
2327
+ const out = await run(pr);
2328
+ const rows = JSON.parse(out);
2329
+ if (!Array.isArray(rows) || rows.length === 0) return null;
2330
+ return rows.map((r) => ({ name: r.name ?? "?", bucket: (r.bucket ?? "").toLowerCase(), link: r.link ?? "" }));
2331
+ } catch {
2332
+ return null;
2333
+ }
2334
+ }
2335
+ async function ghChecks(pr, run = realChecksRunner) {
2336
+ const rows = await prCheckRows(pr, run);
2337
+ if (!rows) return null;
2338
+ let pass = 0, fail = 0, pending2 = 0;
2339
+ for (const r of rows) {
2340
+ if (r.bucket === "pass") pass++;
2341
+ else if (r.bucket === "fail" || r.bucket === "cancel") fail++;
2342
+ else if (r.bucket === "pending") pending2++;
2343
+ }
2344
+ const total = rows.length;
2345
+ let bucket, text, tone;
2346
+ if (fail > 0) {
2347
+ bucket = "fail";
2348
+ tone = "fail";
2349
+ text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
2350
+ } else if (pending2 > 0) {
2351
+ bucket = "pending";
2352
+ tone = "review";
2353
+ text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
2354
+ } else {
2355
+ bucket = "pass";
2356
+ tone = "merge";
2357
+ text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
2358
+ }
2359
+ return { bucket, text, tone, total, pass, fail, pending: pending2 };
2360
+ }
2361
+
1030
2362
  // src/oauth.ts
1031
2363
  import { createHash, randomBytes } from "crypto";
1032
- import { readFileSync as readFileSync2, writeFileSync, unlinkSync } from "fs";
2364
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync } from "fs";
1033
2365
  import { join as join3 } from "path";
1034
2366
  var REGISTER = "/api/oauth/register";
1035
2367
  var AUTHORIZE = "/api/oauth/authorize";
@@ -1121,13 +2453,13 @@ function toStore(clientId, t, now) {
1121
2453
  }
1122
2454
  function loadStore() {
1123
2455
  try {
1124
- return JSON.parse(readFileSync2(STORE_PATH, "utf8"));
2456
+ return JSON.parse(readFileSync4(STORE_PATH, "utf8"));
1125
2457
  } catch {
1126
2458
  return null;
1127
2459
  }
1128
2460
  }
1129
2461
  function saveStore(s) {
1130
- writeFileSync(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
2462
+ writeFileSync3(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
1131
2463
  }
1132
2464
  function clearStore() {
1133
2465
  try {
@@ -1137,7 +2469,7 @@ function clearStore() {
1137
2469
  }
1138
2470
  function loadClientId() {
1139
2471
  try {
1140
- return JSON.parse(readFileSync2(CLIENT_PATH, "utf8")).clientId;
2472
+ return JSON.parse(readFileSync4(CLIENT_PATH, "utf8")).clientId;
1141
2473
  } catch {
1142
2474
  return null;
1143
2475
  }
@@ -1146,7 +2478,7 @@ async function ensureClientId(api, redirectUri, fetchFn = fetch) {
1146
2478
  const existing = loadClientId();
1147
2479
  if (existing) return existing;
1148
2480
  const clientId = await registerClient({ api, redirectUri }, fetchFn);
1149
- writeFileSync(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
2481
+ writeFileSync3(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
1150
2482
  return clientId;
1151
2483
  }
1152
2484
  async function ensureAccess(store, opts = {}) {
@@ -1168,13 +2500,11 @@ async function ensureAccess(store, opts = {}) {
1168
2500
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1169
2501
  var jsonForScript = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
1170
2502
  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
2503
  var sqliteIso = (ts) => {
1174
2504
  const ms = sqliteMs(ts);
1175
2505
  return Number.isNaN(ms) ? ts : new Date(ms).toISOString();
1176
2506
  };
1177
- function roySnapshot(db2, now = Date.now()) {
2507
+ function roySnapshot(db2, now = Date.now(), overmindLog = []) {
1178
2508
  const all = db2.listRuns();
1179
2509
  const c = (s) => all.filter((r) => r.status === s).length;
1180
2510
  const withQ = new Set(db2.runIdsWithQuestions());
@@ -1244,13 +2574,15 @@ function roySnapshot(db2, now = Date.now()) {
1244
2574
  active: db2.stormActive(),
1245
2575
  since: db2.currentStormStartedAt(),
1246
2576
  dead: all.filter((r) => r.status === "storm_dead").length
1247
- }
2577
+ },
2578
+ overmindLog
1248
2579
  };
1249
2580
  }
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"]);
2581
+ var {
2582
+ windowMs: TL_RECENT_TERMINAL_MS,
2583
+ maxTerminalLanes: TL_MAX_TERMINAL_LANES,
2584
+ maxAxisMs: TL_MAX_AXIS_MS
2585
+ } = resolveRoyWindows(process.env);
1254
2586
  var ciCache = /* @__PURE__ */ new Map();
1255
2587
  var CI_TTL = 2e4;
1256
2588
  var CI_MOCK = (() => {
@@ -1266,50 +2598,18 @@ function mockCi(bucket) {
1266
2598
  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
2599
  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
2600
  }
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
2601
  async function refreshCI(prs) {
1302
2602
  await Promise.all(prs.map(async (pr) => {
1303
2603
  const ci = await ghChecks(pr);
1304
2604
  ciCache.set(pr, { ci, at: Date.now() });
1305
2605
  }));
1306
2606
  }
1307
- function timelineState(db2, now = Date.now()) {
2607
+ function timelineState(db2, now = Date.now(), overmindLog = []) {
1308
2608
  const runs = db2.listRuns().slice().sort((a, b) => a.id - b.id);
1309
2609
  const doneNameBySlug = /* @__PURE__ */ new Map();
1310
2610
  const activeBySlug = /* @__PURE__ */ new Map();
1311
2611
  for (const r of runs) {
1312
- if (!TL_TERMINAL.has(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
2612
+ if (!isTerminalStatus(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
1313
2613
  if (r.status === "done") doneNameBySlug.set(r.task_slug, r.name);
1314
2614
  }
1315
2615
  const openQ = /* @__PURE__ */ new Map();
@@ -1319,7 +2619,7 @@ function timelineState(db2, now = Date.now()) {
1319
2619
  const lanes = [];
1320
2620
  const reviewPRs = [];
1321
2621
  for (const r of runs) {
1322
- const live = !TL_TERMINAL.has(r.status);
2622
+ const live = !isTerminalStatus(r.status);
1323
2623
  const recencyGated = !live || r.status === "stalled";
1324
2624
  if (recencyGated && sqliteMs(r.updated_at) < cutoff) continue;
1325
2625
  const events = db2.events(r.id).map((e) => ({ kind: e.kind, text: e.text ?? null, at: sqliteIso(e.at) }));
@@ -1347,7 +2647,7 @@ function timelineState(db2, now = Date.now()) {
1347
2647
  id: r.id,
1348
2648
  name: r.name,
1349
2649
  slug: r.task_slug,
1350
- repo: basename(r.repo || "") || "?",
2650
+ repo: basename2(r.repo || "") || "?",
1351
2651
  status: r.status,
1352
2652
  area: r.area,
1353
2653
  start: sqliteIso(r.created_at),
@@ -1361,7 +2661,11 @@ function timelineState(db2, now = Date.now()) {
1361
2661
  ci,
1362
2662
  pr: r.pr_url,
1363
2663
  dependsOn: deps,
1364
- cost: r.cost_usd || 0
2664
+ cost: r.cost_usd || 0,
2665
+ role: r.kind,
2666
+ heroRunId: r.hero_run_id,
2667
+ reviewVerdict: r.review_verdict,
2668
+ model: r.model
1365
2669
  });
1366
2670
  }
1367
2671
  const liveLanes = lanes.filter((l) => l.live);
@@ -1391,7 +2695,7 @@ function timelineState(db2, now = Date.now()) {
1391
2695
  const headline = pickHeadline(shown);
1392
2696
  const stale = [...new Set(reviewPRs)].filter((pr) => now - (ciCache.get(pr)?.at ?? 0) > CI_TTL);
1393
2697
  if (stale.length) void refreshCI(stale);
1394
- return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown };
2698
+ return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown, overmindLog };
1395
2699
  }
1396
2700
  var CSS = `
1397
2701
  :root {
@@ -1529,7 +2833,7 @@ main { max-width: 1080px; margin: 0 auto; width: 100%; }
1529
2833
  .pill .sd { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
1530
2834
  .pill.run { color: var(--primary); background: var(--primary-soft); } .pill.run .sd { animation: pulse 1.8s infinite; }
1531
2835
  .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); }
2836
+ .pill.blocked, .pill.paused, .pill.storm { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
1533
2837
  .pill.done { color: var(--success); background: color-mix(in oklch, var(--success) 13%, transparent); }
1534
2838
  .pill.stalled, .pill.failed { color: var(--destructive); background: color-mix(in oklch, var(--destructive) 14%, transparent); }
1535
2839
  .pill.stopped, .pill.queued, .pill.superseded { color: var(--muted-foreground); background: var(--secondary); }
@@ -1822,9 +3126,11 @@ var GLYPH = {
1822
3126
  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
3127
  };
1824
3128
  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'] };
3129
+ /* \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/
3130
+ 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). */
3131
+ var ST = ${jsonForScript(statusTuples())};
1826
3132
  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 };
3133
+ var TERM = ${jsonForScript(Object.fromEntries(TERMINAL_STATUSES.map((s) => [s, 1])))};
1828
3134
  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
3135
  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
3136
 
@@ -1857,7 +3163,7 @@ function cardStep(d){
1857
3163
  if(d.status==='stalled') return d.reason || '\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431';
1858
3164
  if(d.status==='stopped') return d.stopReason || d.reason || '\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D';
1859
3165
  if(d.status==='storm_dead') return '\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 (Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442), worktree \u0446\u0435\u043B, \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043E\u0442\u0431\u043E\u044F';
1860
- if(d.status==='paused') return '\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';
3166
+ if(d.status==='paused') return d.stopReason || '\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'; // TASWM-151: \u0440\u0435\u0441\u0443\u0440\u0441-\u0440\u0430\u0437\u0433\u0440\u0443\u0437\u043A\u0430 \u0437\u0430\u0434\u0430\u0451\u0442 \u0441\u0432\u043E\u0439 stopReason
1861
3167
  if(d.status==='review') { var n=prNum(d.pr); return 'PR'+(n?(' #'+n):'')+' \u043D\u0430 \u0440\u0435\u0432\u044C\u044E'; }
1862
3168
  if(d.status==='done') return '\u0441\u043C\u0435\u0440\u0436\u0435\u043D';
1863
3169
  if(d.status==='queued') return (d.waiting&&d.waiting.length) ? '\u0436\u0434\u0451\u0442: '+d.waiting.join(', ') : '\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430';
@@ -2326,14 +3632,15 @@ async function runDiff(db2, id, now = Date.now()) {
2326
3632
  diffCache.set(id, { at: now, payload });
2327
3633
  return payload;
2328
3634
  }
2329
- function eventsStream(db2) {
3635
+ var readNarratorLog = (log) => log?.() ?? [];
3636
+ function eventsStream(db2, narratorLog) {
2330
3637
  let iv;
2331
3638
  let last = "";
2332
3639
  const stream = new ReadableStream({
2333
3640
  start(controller) {
2334
3641
  const enc = new TextEncoder();
2335
3642
  const send = () => {
2336
- const snap = JSON.stringify(roySnapshot(db2));
3643
+ const snap = JSON.stringify(roySnapshot(db2, Date.now(), readNarratorLog(narratorLog)));
2337
3644
  if (snap === last) return;
2338
3645
  last = snap;
2339
3646
  try {
@@ -2367,11 +3674,11 @@ function makeHandler(deps) {
2367
3674
  const p = url.pathname;
2368
3675
  if (p === "/") {
2369
3676
  const store = auth();
2370
- return html(store ? renderDashboard(store.email, roySnapshot(deps.db), taskUrlBase) : renderLogin());
3677
+ return html(store ? renderDashboard(store.email, roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)), taskUrlBase) : renderLogin());
2371
3678
  }
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));
3679
+ if (p === "/events") return eventsStream(deps.db, deps.narratorLog);
3680
+ if (p === "/state") return Response.json(roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
3681
+ if (p === "/timeline") return Response.json(timelineState(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
2375
3682
  const diffMatch = p.match(/^\/run\/(\d+)\/diff$/);
2376
3683
  if (diffMatch) {
2377
3684
  if (!auth()) return unauthorized();
@@ -2449,39 +3756,106 @@ function makeHandler(deps) {
2449
3756
  function startWeb(deps, port = WEB_PORT) {
2450
3757
  const handle = makeHandler(deps);
2451
3758
  const server = serve({ fetch: handle, port, hostname: "127.0.0.1" });
3759
+ server.on("error", (err) => {
3760
+ const e = err;
3761
+ if (e.code === "EADDRINUSE") {
3762
+ 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}).`);
3763
+ } else {
3764
+ console.error(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0430\u0441\u044C: ${e.message}`);
3765
+ }
3766
+ process.exit(1);
3767
+ });
2452
3768
  return { port, server };
2453
3769
  }
2454
3770
 
2455
3771
  // src/cli.ts
2456
- import { cancelRun, respawnRun, OVERMIND_ENTRY } from "./overmind.js";
3772
+ import { cancelRun, respawnRun, runDaemon, OVERMIND_ENTRY, listDroneProcesses } from "./overmind.js";
2457
3773
 
2458
3774
  // src/launcher.ts
2459
3775
  import { spawn } from "child_process";
2460
- import { readdirSync, writeFileSync as writeFileSync2 } from "fs";
3776
+ import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync3, openSync, readdirSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
2461
3777
  import { homedir as homedir2 } from "os";
2462
3778
  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;
3779
+ var ISOLATION_FLAGS = ["--setting-sources", "project", "--strict-mcp-config"];
3780
+ function claudeArgs(cfgPath, prompt, continueSession = false, model, pluginDirs = []) {
3781
+ const pluginFlags = pluginDirs.flatMap((dir) => ["--plugin-dir", dir]);
3782
+ const base2 = [
3783
+ "--dangerously-skip-permissions",
3784
+ ...ISOLATION_FLAGS,
3785
+ "--mcp-config",
3786
+ cfgPath,
3787
+ ...pluginFlags,
3788
+ "--output-format",
3789
+ "json",
3790
+ "-p",
3791
+ prompt
3792
+ ];
3793
+ const withModel = model ? ["--model", model, ...base2] : base2;
3794
+ return continueSession ? ["--continue", ...withModel] : withModel;
3795
+ }
3796
+ function roleDir(worktree, kind) {
3797
+ return join4(worktree, ".claude", "roles", kind);
3798
+ }
3799
+ function nextRunLogPath(runLogsDir, runId) {
3800
+ const dir = join4(runLogsDir, String(runId));
3801
+ mkdirSync3(dir, { recursive: true });
3802
+ let files = [];
3803
+ try {
3804
+ files = readdirSync(dir);
3805
+ } catch {
3806
+ }
3807
+ let next = 0;
3808
+ for (const f of files) {
3809
+ const m = /^(\d+)\.json/.exec(f);
3810
+ if (m) next = Math.max(next, Number(m[1]) + 1);
3811
+ }
3812
+ return join4(dir, `${next}.json`);
3813
+ }
3814
+ function openNextRunLog(runLogsDir, runId) {
3815
+ for (let attempt = 0; attempt < 50; attempt++) {
3816
+ try {
3817
+ return openSync(nextRunLogPath(runLogsDir, runId), "wx");
3818
+ } catch (e) {
3819
+ if (e.code !== "EEXIST") throw e;
3820
+ }
3821
+ }
3822
+ 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
3823
  }
2467
3824
  function makeLauncher(cfg) {
2468
3825
  return ({ worktree, run, prompt, continueSession }) => {
3826
+ const isCompiled = cfg.overmindEntry.endsWith(".js");
3827
+ if (!isCompiled && !cfg.overmindEntry.endsWith(".ts")) {
3828
+ throw new Error(`overmindEntry \u043D\u0438 .ts, \u043D\u0438 .js: ${cfg.overmindEntry}`);
3829
+ }
2469
3830
  const mcpConfig = {
2470
3831
  mcpServers: {
2471
3832
  overmind: {
2472
- command: "bun",
2473
- args: ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
2474
- env: { SWARM_DB: cfg.dbPath }
3833
+ command: isCompiled ? process.execPath : "bun",
3834
+ args: isCompiled ? [cfg.overmindEntry, "--drone", "--run", String(run.id)] : ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
3835
+ // SWARM_TASK_SLUG (TASWM-211): task_slug зафиксирован ЗДЕСЬ, на спавне — устойчив к
3836
+ // пересозданию локального стора между спавном и вызовами read_task (droneServer.ts
3837
+ // доверяет ему, а не db.getRun(run.id).task_slug, который может протухнуть/переехать).
3838
+ env: { SWARM_DB: cfg.dbPath, SWARM_TASK_SLUG: run.task_slug }
2475
3839
  }
2476
3840
  }
2477
3841
  };
2478
3842
  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), {
3843
+ writeFileSync4(cfgPath, JSON.stringify(mcpConfig, null, 2));
3844
+ const model = run.model ?? process.env.SWARM_DRONE_MODEL ?? void 0;
3845
+ const dir = roleDir(worktree, run.kind);
3846
+ const pluginDirs = existsSync3(dir) ? [dir] : [];
3847
+ let stdio = "ignore";
3848
+ let outFd;
3849
+ if (cfg.runLogsDir) {
3850
+ outFd = openNextRunLog(cfg.runLogsDir, run.id);
3851
+ stdio = ["ignore", outFd, "ignore"];
3852
+ }
3853
+ const child = spawn(cfg.claudeBin ?? "claude", claudeArgs(cfgPath, prompt, continueSession, model, pluginDirs), {
2481
3854
  cwd: worktree,
2482
3855
  detached: true,
2483
- stdio: "ignore"
3856
+ stdio
2484
3857
  });
3858
+ if (outFd !== void 0) closeSync(outFd);
2485
3859
  child.unref();
2486
3860
  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
3861
  return child.pid;
@@ -2489,11 +3863,132 @@ function makeLauncher(cfg) {
2489
3863
  }
2490
3864
 
2491
3865
  // src/git.ts
2492
- import { $ as $3 } from "execa";
3866
+ import { $ as $4 } from "execa";
3867
+
3868
+ // src/procCleanup.ts
3869
+ import { execFileSync as execFileSync2 } from "child_process";
3870
+ function sleep(ms) {
3871
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
3872
+ }
3873
+ function parseLsofCwd(output) {
3874
+ const rows = [];
3875
+ let pid = null;
3876
+ for (const line of output.split("\n")) {
3877
+ const pMatch = /^p(\d+)$/.exec(line);
3878
+ if (pMatch) {
3879
+ pid = Number(pMatch[1]);
3880
+ } else if (line.startsWith("n") && pid != null) {
3881
+ rows.push({ pid, cwd: line.slice(1) });
3882
+ pid = null;
3883
+ }
3884
+ }
3885
+ return rows;
3886
+ }
3887
+ function cwdUnder(cwd, dir) {
3888
+ return cwd === dir || cwd.startsWith(`${dir}/`);
3889
+ }
3890
+ function pidsWithCwdUnder(dir) {
3891
+ let out;
3892
+ try {
3893
+ out = execFileSync2("lsof", ["-a", "-d", "cwd", "-F", "pn"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
3894
+ } catch (e) {
3895
+ logTransientSpawnError("lsof", e);
3896
+ return [];
3897
+ }
3898
+ return parseLsofCwd(out).filter((r) => cwdUnder(r.cwd, dir));
3899
+ }
3900
+ function pidAlive(pid) {
3901
+ try {
3902
+ process.kill(pid, 0);
3903
+ return true;
3904
+ } catch (e) {
3905
+ return e.code === "EPERM";
3906
+ }
3907
+ }
3908
+ async function killPidsGraceful(pids, opts = {}) {
3909
+ const graceMs = opts.graceMs ?? 3e3;
3910
+ const pollMs = opts.pollMs ?? 150;
3911
+ const kill = opts.kill ?? ((pid, signal) => process.kill(pid, signal));
3912
+ const isAlive = opts.isAlive ?? pidAlive;
3913
+ const sleepFn = opts.sleepFn ?? sleep;
3914
+ const signalled = [];
3915
+ for (const pid of pids) {
3916
+ try {
3917
+ kill(pid, "SIGTERM");
3918
+ signalled.push(pid);
3919
+ } catch {
3920
+ }
3921
+ }
3922
+ if (!signalled.length) return signalled;
3923
+ const deadline = Date.now() + graceMs;
3924
+ while (Date.now() < deadline && signalled.some((pid) => isAlive(pid))) {
3925
+ await sleepFn(pollMs);
3926
+ }
3927
+ for (const pid of signalled) {
3928
+ try {
3929
+ if (isAlive(pid)) kill(pid, "SIGKILL");
3930
+ } catch {
3931
+ }
3932
+ }
3933
+ return signalled;
3934
+ }
3935
+ function composeProjectPrefix(worktreeDirName) {
3936
+ return worktreeDirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3937
+ }
3938
+ function containerBelongsToPrefix(name, prefix) {
3939
+ return name === prefix || name.startsWith(`${prefix}-`);
3940
+ }
3941
+ function dockerContainerNames() {
3942
+ try {
3943
+ return execFileSync2("docker", ["ps", "-a", "--format", "{{.Names}}"], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
3944
+ } catch (e) {
3945
+ logTransientSpawnError("docker ps", e);
3946
+ return [];
3947
+ }
3948
+ }
3949
+ function removeContainers(names) {
3950
+ const removed = [];
3951
+ for (const name of names) {
3952
+ try {
3953
+ execFileSync2("docker", ["rm", "-f", name], { encoding: "utf8" });
3954
+ removed.push(name);
3955
+ } catch (e) {
3956
+ console.warn(`[procCleanup] docker rm -f ${name} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F: ${String(e)}`);
3957
+ }
3958
+ }
3959
+ return removed;
3960
+ }
3961
+ async function cleanupWorktreeProcesses(worktree, opts = {}) {
3962
+ try {
3963
+ const found = pidsWithCwdUnder(worktree);
3964
+ if (found.length) {
3965
+ const killed = await killPidsGraceful(
3966
+ found.map((f) => f.pid),
3967
+ opts
3968
+ );
3969
+ if (killed.length) console.log(`[procCleanup] worktree ${worktree}: \u043F\u043E\u0433\u0430\u0448\u0435\u043D\u043E \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 ${killed.length} (${killed.join(", ")})`);
3970
+ }
3971
+ } catch (e) {
3972
+ console.error(`[procCleanup] \u0433\u0430\u0448\u0435\u043D\u0438\u0435 \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u043E\u0441\u044C:`, e);
3973
+ }
3974
+ try {
3975
+ const prefix = composeProjectPrefix(worktree.split("/").filter(Boolean).pop() ?? "");
3976
+ if (!prefix) return;
3977
+ const names = dockerContainerNames().filter((n) => containerBelongsToPrefix(n, prefix));
3978
+ if (!names.length) return;
3979
+ const removed = removeContainers(names);
3980
+ if (removed.length) console.log(`[procCleanup] worktree ${worktree}: \u0441\u043D\u0435\u0441\u0435\u043D\u043E docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 ${removed.length} (${removed.join(", ")})`);
3981
+ } catch (e) {
3982
+ console.error(`[procCleanup] \u0441\u043D\u043E\u0441 docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F:`, e);
3983
+ }
3984
+ }
3985
+
3986
+ // src/git.ts
2493
3987
  async function removeWorktree(repo, path) {
2494
- const branch = (await $3({ reject: false })`git -C ${path} symbolic-ref --quiet --short HEAD`).stdout.trim();
2495
- await $3({ reject: false })`git -C ${repo} worktree remove --force ${path}`;
2496
- if (branch) await $3({ reject: false })`git -C ${repo} branch -D ${branch}`;
3988
+ const branch = (await $4({ reject: false })`git -C ${path} symbolic-ref --quiet --short HEAD`).stdout.trim();
3989
+ await cleanupWorktreeProcesses(path);
3990
+ await $4({ reject: false })`git -C ${repo} worktree remove --force ${path}`;
3991
+ if (branch) await $4({ reject: false })`git -C ${repo} branch -D ${branch}`;
2497
3992
  }
2498
3993
 
2499
3994
  // src/taskless.ts
@@ -2501,19 +3996,19 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2501
3996
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2502
3997
 
2503
3998
  // src/workspace.ts
2504
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs";
3999
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, unlinkSync as unlinkSync2 } from "fs";
2505
4000
  import { join as join5 } from "path";
2506
4001
  var WORKSPACE_PATH = join5(SWARM_HOME, "workspace.json");
2507
4002
  function loadWorkspace() {
2508
4003
  try {
2509
- const w = JSON.parse(readFileSync3(WORKSPACE_PATH, "utf8"));
4004
+ const w = JSON.parse(readFileSync5(WORKSPACE_PATH, "utf8"));
2510
4005
  return w && typeof w.id === "string" && w.id ? w : null;
2511
4006
  } catch {
2512
4007
  return null;
2513
4008
  }
2514
4009
  }
2515
4010
  function saveWorkspace(w) {
2516
- writeFileSync3(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
4011
+ writeFileSync5(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
2517
4012
  }
2518
4013
  function clearWorkspace() {
2519
4014
  try {
@@ -2543,7 +4038,7 @@ async function connectSwarmMcp(cfg) {
2543
4038
  }
2544
4039
  if (!token) token = TASKLESS_TOKEN;
2545
4040
  if (!token) return null;
2546
- const url = new URL(`${api.replace(/\/$/, "")}/api/mcp`);
4041
+ const url = new URL(mcpEndpoint(api));
2547
4042
  const transport = new StreamableHTTPClientTransport(url, {
2548
4043
  requestInit: { headers: { Authorization: `Bearer ${token}` } }
2549
4044
  });
@@ -2633,7 +4128,189 @@ async function openBrowser(url) {
2633
4128
  }
2634
4129
  }
2635
4130
 
4131
+ // src/screenshot.ts
4132
+ var DESKTOP_VIEWPORT = { name: "desktop", width: 1360, height: 940 };
4133
+ var MOBILE_VIEWPORT = { name: "mobile", width: 390, height: 844 };
4134
+ var DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
4135
+ var DEFAULT_SHOT_TIMEOUT_MS = 2e4;
4136
+ async function captureScreenshots(launchBrowser, opts) {
4137
+ const viewports = opts.viewports ?? DEFAULT_VIEWPORTS;
4138
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_SHOT_TIMEOUT_MS;
4139
+ const fullPage = opts.fullPage ?? true;
4140
+ const browser = await launchBrowser();
4141
+ try {
4142
+ const results = [];
4143
+ for (const vp of viewports) {
4144
+ const ctx = await browser.newContext({
4145
+ viewport: { width: vp.width, height: vp.height },
4146
+ ...opts.storageStatePath ? { storageState: opts.storageStatePath } : {}
4147
+ });
4148
+ try {
4149
+ const page = await ctx.newPage();
4150
+ await page.goto(opts.url, { timeout: timeoutMs, waitUntil: "load" });
4151
+ const path = `${opts.outPrefix}-${vp.name}.png`;
4152
+ await page.screenshot({ path, fullPage, timeout: timeoutMs });
4153
+ results.push({ viewport: vp.name, path });
4154
+ } finally {
4155
+ await ctx.close();
4156
+ }
4157
+ }
4158
+ return results;
4159
+ } finally {
4160
+ await browser.close();
4161
+ }
4162
+ }
4163
+ async function launchChromium(opts = {}) {
4164
+ let mod;
4165
+ try {
4166
+ mod = await import("playwright-core");
4167
+ } catch {
4168
+ throw new Error(
4169
+ "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."
4170
+ );
4171
+ }
4172
+ return mod.chromium.launch({ headless: true, ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {} });
4173
+ }
4174
+
4175
+ // src/shotCli.ts
4176
+ function isFlagValue(v) {
4177
+ return v !== void 0 && !v.startsWith("--");
4178
+ }
4179
+ function parseShotArgs(argv) {
4180
+ const url = argv[0];
4181
+ const outPrefix = argv[1];
4182
+ if (!url || !outPrefix) return null;
4183
+ let timeoutMs = SWARM_SHOT_TIMEOUT_MS;
4184
+ let storageStatePath = null;
4185
+ for (let i = 2; i < argv.length; i++) {
4186
+ if (argv[i] === "--timeout" && isFlagValue(argv[i + 1])) {
4187
+ const n = Number(argv[i + 1]);
4188
+ if (Number.isFinite(n) && n >= 0) timeoutMs = n;
4189
+ i++;
4190
+ } else if (argv[i] === "--storage-state" && isFlagValue(argv[i + 1])) {
4191
+ storageStatePath = argv[i + 1];
4192
+ i++;
4193
+ }
4194
+ }
4195
+ return { url, outPrefix, timeoutMs, storageStatePath };
4196
+ }
4197
+ var VIEWPORTS_COUNT = DEFAULT_VIEWPORTS.length;
4198
+ var LAUNCH_BUDGET_MS = 15e3;
4199
+ async function runShotCli(argv, deps = {}) {
4200
+ const log = deps.log ?? ((l) => console.log(l));
4201
+ const error = deps.error ?? ((l) => console.error(l));
4202
+ const args = parseShotArgs(argv);
4203
+ if (!args) {
4204
+ error("\u043D\u0443\u0436\u043D\u043E: swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]");
4205
+ return 1;
4206
+ }
4207
+ const launchBudgetMs = deps.launchBudgetMs ?? LAUNCH_BUDGET_MS;
4208
+ const launched = { browser: null };
4209
+ const trackedLaunch = async () => {
4210
+ const launch = deps.launchBrowser ?? (() => launchChromium({ timeoutMs: launchBudgetMs }));
4211
+ const browser = await launch();
4212
+ launched.browser = browser;
4213
+ return browser;
4214
+ };
4215
+ const budgetMs = args.timeoutMs * VIEWPORTS_COUNT + launchBudgetMs;
4216
+ let timer;
4217
+ const watchdog = new Promise((_, reject) => {
4218
+ timer = setTimeout(
4219
+ () => 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`)),
4220
+ budgetMs
4221
+ );
4222
+ });
4223
+ try {
4224
+ const results = await Promise.race([
4225
+ captureScreenshots(trackedLaunch, {
4226
+ url: args.url,
4227
+ outPrefix: args.outPrefix,
4228
+ timeoutMs: args.timeoutMs,
4229
+ storageStatePath: args.storageStatePath
4230
+ }),
4231
+ watchdog
4232
+ ]);
4233
+ for (const r of results) log(`${r.viewport} \u2192 ${r.path}`);
4234
+ return 0;
4235
+ } catch (e) {
4236
+ error(`swarm shot: ${e instanceof Error ? e.message : String(e)}`);
4237
+ return 1;
4238
+ } finally {
4239
+ clearTimeout(timer);
4240
+ await launched.browser?.close().catch(() => {
4241
+ });
4242
+ }
4243
+ }
4244
+
4245
+ // package.json
4246
+ var package_default = {
4247
+ name: "@taskless-app/swarm",
4248
+ version: "0.2.2",
4249
+ type: "module",
4250
+ description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
4251
+ keywords: [
4252
+ "ai-agents",
4253
+ "coding-agents",
4254
+ "swarm",
4255
+ "orchestration",
4256
+ "autonomous-agents",
4257
+ "mcp",
4258
+ "taskless",
4259
+ "cli"
4260
+ ],
4261
+ license: "SEE LICENSE IN LICENSE",
4262
+ author: "Ilya Smurov <ilya@smurov.com>",
4263
+ homepage: "https://github.com/ilyasmurov/taskless/tree/main/packages/swarm-daemon#readme",
4264
+ repository: {
4265
+ type: "git",
4266
+ url: "git+https://github.com/ilyasmurov/taskless.git",
4267
+ directory: "packages/swarm-daemon"
4268
+ },
4269
+ engines: {
4270
+ node: ">=22"
4271
+ },
4272
+ bin: {
4273
+ swarm: "dist/cli.js"
4274
+ },
4275
+ files: [
4276
+ "dist",
4277
+ "docs/HERO.md"
4278
+ ],
4279
+ scripts: {
4280
+ overmind: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/overmind.ts',
4281
+ swarm: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/cli.ts',
4282
+ build: "tsup",
4283
+ start: "node dist/overmind.js",
4284
+ prepublishOnly: "pnpm run build",
4285
+ test: "vitest run && SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
4286
+ "test:client": "SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
4287
+ "test:watch": "vitest",
4288
+ typecheck: "tsc -p tsconfig.json --noEmit",
4289
+ lint: "echo 'no lint in swarm-daemon yet'"
4290
+ },
4291
+ dependencies: {
4292
+ "@hono/node-server": "^2.0.10",
4293
+ "@modelcontextprotocol/sdk": "^1.29.0",
4294
+ execa: "^10.0.0",
4295
+ zod: "3.25.76"
4296
+ },
4297
+ optionalDependencies: {
4298
+ "better-sqlite3": "^12.11.1"
4299
+ },
4300
+ devDependencies: {
4301
+ "@taskless/swarm-core": "workspace:*",
4302
+ "@types/better-sqlite3": "^7.6.13",
4303
+ "@types/node": "22.10.5",
4304
+ "playwright-core": "1.49.1",
4305
+ tsup: "^8.5.1",
4306
+ tsx: "4.19.2",
4307
+ typescript: "5.7.3",
4308
+ vitest: "2.1.8"
4309
+ }
4310
+ };
4311
+
2636
4312
  // src/cli.ts
4313
+ var PACKAGE_VERSION = package_default.version;
2637
4314
  function takeRepo(argv) {
2638
4315
  const i = argv.indexOf("--repo");
2639
4316
  if (i === -1) return { repo: null, rest: argv };
@@ -2687,7 +4364,7 @@ async function runLogin(db2, opts) {
2687
4364
  loadStore,
2688
4365
  beforeToken: null,
2689
4366
  now: Date.now,
2690
- sleep,
4367
+ sleep: sleep2,
2691
4368
  timeoutMs: 5 * 6e4,
2692
4369
  pollMs: 1e3
2693
4370
  });
@@ -2716,7 +4393,7 @@ async function runLogin(db2, opts) {
2716
4393
  if (!chosen) return 1;
2717
4394
  saveWorkspace(toWorkspaceSel(chosen));
2718
4395
  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");
4396
+ 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
4397
  return 0;
2721
4398
  } finally {
2722
4399
  web.server.close();
@@ -2737,14 +4414,16 @@ var STATUS_MARK = {
2737
4414
  function fmtRun(r) {
2738
4415
  const mark = STATUS_MARK[r.status] ?? "?";
2739
4416
  const area = r.area ? ` [${r.area}]` : "";
2740
- const repo = r.repo ? ` (${basename2(r.repo)})` : "";
4417
+ const repo = r.repo ? ` (${basename3(r.repo)})` : "";
2741
4418
  const q = r.status === "blocked" ? " \u2190 \u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430" : "";
2742
4419
  const why = r.status === "stopped" && r.stop_reason ? ` \u2014 ${r.stop_reason}` : "";
2743
4420
  return `${mark} #${r.id} ${r.name} ${r.task_slug}${repo}${area} ${r.status}${q}${why}`;
2744
4421
  }
2745
4422
  function usage() {
2746
- console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432
4423
+ console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432 (v${PACKAGE_VERSION})
2747
4424
 
4425
+ 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
4426
+ 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
4427
  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
4428
  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
4429
  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 +4433,98 @@ function usage() {
2754
4433
  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
4434
  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
4435
  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
4436
  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
4437
  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
4438
  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
4439
+ swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]
4440
+ headless-\u0441\u043A\u0440\u0438\u043D desktop+mobile \u041E\u0414\u041D\u041E\u0419 \u043A\u043E\u043C\u0430\u043D\u0434\u043E\u0439 (TASWM-40):
4441
+ <out-prefix>-desktop.png, <out-prefix>-mobile.png
4442
+ swarm --version \u0432\u0435\u0440\u0441\u0438\u044F \u043F\u0430\u043A\u0435\u0442\u0430
2761
4443
  `);
2762
4444
  }
2763
- var db = new SwarmDb(DB_PATH, SWARM_REPO);
4445
+ async function makeCliStore(repo) {
4446
+ if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
4447
+ const failing = async () => ({ isError: true });
4448
+ const [call, repoRegistry] = await Promise.all([
4449
+ connectSwarmMcp().then((c) => c ?? failing),
4450
+ buildRepoRegistry(SWARM_REPO_PATHS)
4451
+ ]);
4452
+ const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo, repoRegistry });
4453
+ await client.hydrate();
4454
+ client.reconcileRuntime({ worktreesDir: WORKTREES_DIR, listProcesses: listDroneProcesses });
4455
+ return client;
4456
+ }
4457
+ async function finishClient(db2) {
4458
+ if (!(db2 instanceof SwarmClient)) return;
4459
+ await db2.flush();
4460
+ db2.close();
4461
+ process.exit(0);
4462
+ }
2764
4463
  var [cmd, ...rawArgs] = process.argv.slice(2);
4464
+ if (cmd === "--version" || cmd === "-v") {
4465
+ console.log(PACKAGE_VERSION);
4466
+ process.exit(0);
4467
+ }
4468
+ if (cmd === "logout") {
4469
+ clearStore();
4470
+ clearWorkspace();
4471
+ 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).");
4472
+ process.exit(0);
4473
+ }
4474
+ if (cmd === "whoami") {
4475
+ const store = loadStore();
4476
+ if (!store) {
4477
+ console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
4478
+ process.exit(0);
4479
+ }
4480
+ const ws = loadWorkspace();
4481
+ console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
4482
+ console.log(
4483
+ 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"
4484
+ );
4485
+ process.exit(0);
4486
+ }
4487
+ if (cmd === "import-db") {
4488
+ const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
4489
+ const path = args[0];
4490
+ const repo = repoFlag ?? SWARM_REPO;
4491
+ if (!path || !repo) {
4492
+ console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
4493
+ process.exit(1);
4494
+ }
4495
+ const sdb = new SwarmDb(DB_PATH, repo);
4496
+ const res = sdb.importDb(path, repo);
4497
+ 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})`);
4498
+ 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`);
4499
+ sdb.close();
4500
+ process.exit(0);
4501
+ }
4502
+ if (cmd === "start") {
4503
+ if (SWARM_BACKEND !== "client") {
4504
+ 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).");
4505
+ }
4506
+ const { repo: repoFlag } = takeRepo(rawArgs);
4507
+ const repo = repoFlag ?? SWARM_REPO;
4508
+ if (!repo) {
4509
+ 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).");
4510
+ }
4511
+ try {
4512
+ await runDaemon({ repo });
4513
+ } catch (e) {
4514
+ console.error(`\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0441\u044F: ${e instanceof Error ? e.message : e}`);
4515
+ process.exit(1);
4516
+ }
4517
+ process.exit(0);
4518
+ }
4519
+ if (cmd === "shot") {
4520
+ const code = await runShotCli(rawArgs);
4521
+ process.exit(code);
4522
+ }
4523
+ if (!cmd || cmd === "--help" || cmd === "-h") {
4524
+ usage();
4525
+ process.exit(0);
4526
+ }
4527
+ var db = await makeCliStore(SWARM_REPO);
2765
4528
  switch (cmd) {
2766
4529
  case "ls": {
2767
4530
  const runs = db.listRuns(rawArgs[0]);
@@ -2812,7 +4575,7 @@ switch (cmd) {
2812
4575
  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
4576
  process.exit(1);
2814
4577
  }
2815
- const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH });
4578
+ const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH, runLogsDir: RUN_LOGS_DIR });
2816
4579
  await respawnRun(
2817
4580
  { db, launch, repoFallback: SWARM_REPO, worktreesDir: WORKTREES_DIR, getTask: () => Promise.resolve(null) },
2818
4581
  run,
@@ -2821,19 +4584,6 @@ switch (cmd) {
2821
4584
  console.log(`\u0440\u0430\u0437\u0431\u0443\u0436\u0435\u043D (--continue): ${fmtRun(db.getRun(runId))}`);
2822
4585
  break;
2823
4586
  }
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
4587
  case "questions": {
2838
4588
  const qs = db.openQuestions();
2839
4589
  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 +4637,7 @@ switch (cmd) {
2887
4637
  process.exit(1);
2888
4638
  }
2889
4639
  const run = db.getRun(runId);
2890
- if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename2(run.repo)})` : ""}`);
4640
+ if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename3(run.repo)})` : ""}`);
2891
4641
  const events = db.events(runId);
2892
4642
  if (events.length === 0) console.log("\u0441\u043E\u0431\u044B\u0442\u0438\u0439 \u043D\u0435\u0442");
2893
4643
  else events.forEach((e) => console.log(`${e.at} ${e.kind}${e.text ? " " + e.text : ""}`));
@@ -2900,25 +4650,6 @@ switch (cmd) {
2900
4650
  db.close();
2901
4651
  process.exit(code);
2902
4652
  }
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
4653
  case "web": {
2923
4654
  const web = startWeb({ db });
2924
4655
  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 +4660,5 @@ switch (cmd) {
2929
4660
  default:
2930
4661
  usage();
2931
4662
  }
4663
+ await finishClient(db);
2932
4664
  db.close();