@frumu/tandem-panel 0.3.28 → 0.4.3

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/bin/setup.js CHANGED
@@ -4,14 +4,15 @@ import { spawn } from "child_process";
4
4
  import { createServer } from "http";
5
5
  import { readFileSync, existsSync, createReadStream, createWriteStream } from "fs";
6
6
  import { mkdir, readdir, stat, rm, readFile, writeFile } from "fs/promises";
7
- import { randomBytes } from "crypto";
8
- import { join, dirname, extname, normalize, resolve, basename } from "path";
7
+ import { createHash, randomBytes } from "crypto";
8
+ import { join, dirname, extname, normalize, resolve, basename, relative } from "path";
9
9
  import { Transform } from "stream";
10
10
  import { pipeline } from "stream/promises";
11
11
  import { fileURLToPath } from "url";
12
12
  import { createRequire } from "module";
13
13
  import { homedir } from "os";
14
- import { ensureEnv } from "./init-env.js";
14
+ import { ensureBootstrapEnv, resolveEnvLoadOrder } from "../lib/setup/env.js";
15
+ import { createSwarmApiHandler } from "../server/routes/swarm.js";
15
16
 
16
17
  function parseDotEnv(content) {
17
18
  const out = {};
@@ -22,7 +23,10 @@ function parseDotEnv(content) {
22
23
  if (idx <= 0) continue;
23
24
  const key = line.slice(0, idx).trim();
24
25
  let value = line.slice(idx + 1).trim();
25
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
26
+ if (
27
+ (value.startsWith('"') && value.endsWith('"')) ||
28
+ (value.startsWith("'") && value.endsWith("'"))
29
+ ) {
26
30
  value = value.slice(1, -1);
27
31
  }
28
32
  out[key] = value;
@@ -79,34 +83,56 @@ const cli = parseCliArgs(process.argv.slice(2));
79
83
  const rawArgs = process.argv.slice(2);
80
84
  const initRequested = cli.has("init");
81
85
  const resetTokenRequested = cli.has("reset-token");
86
+ const explicitEnvFile = String(cli.value("env-file") || "").trim();
82
87
  const installServicesRequested = cli.has("install-services");
88
+ const serviceOpRaw = String(cli.value("service-op") || "")
89
+ .trim()
90
+ .toLowerCase();
91
+ const serviceOp = ["status", "start", "stop", "restart", "enable", "disable", "logs"].includes(
92
+ serviceOpRaw
93
+ )
94
+ ? serviceOpRaw
95
+ : "";
83
96
  const serviceModeRaw = String(cli.value("service-mode") || "both")
84
97
  .trim()
85
98
  .toLowerCase();
86
99
  const serviceMode = ["both", "engine", "panel"].includes(serviceModeRaw) ? serviceModeRaw : "both";
87
100
  const serviceUserArg = String(cli.value("service-user") || "").trim();
88
- const serviceSetupOnly = rawArgs.length > 0 && rawArgs.every((arg) => {
89
- if (arg === "--install-services") return true;
90
- if (arg.startsWith("--service-mode")) return true;
91
- if (arg.startsWith("--service-user")) return true;
92
- return false;
93
- });
101
+ const serviceSetupOnly =
102
+ rawArgs.length > 0 &&
103
+ rawArgs.every((arg) => {
104
+ if (arg === "--install-services") return true;
105
+ if (arg.startsWith("--service-op")) return true;
106
+ if (arg.startsWith("--service-mode")) return true;
107
+ if (arg.startsWith("--service-user")) return true;
108
+ return false;
109
+ });
94
110
  const cwdEnvPath = resolve(process.cwd(), ".env");
111
+ for (const envPath of resolveEnvLoadOrder({ explicitEnvFile, cwd: process.cwd() })) {
112
+ loadDotEnvFile(envPath);
113
+ }
95
114
 
96
115
  if (initRequested) {
97
- const result = ensureEnv({ overwrite: resetTokenRequested });
116
+ const result = await ensureBootstrapEnv({
117
+ cwd: process.cwd(),
118
+ envPath: explicitEnvFile || undefined,
119
+ overwrite: resetTokenRequested,
120
+ });
98
121
  console.log("[Tandem Control Panel] Environment initialized.");
99
122
  console.log(`[Tandem Control Panel] .env: ${result.envPath}`);
100
123
  console.log(`[Tandem Control Panel] Engine URL: ${result.engineUrl}`);
101
- console.log(`[Tandem Control Panel] Panel URL: http://localhost:${result.panelPort}`);
124
+ console.log(
125
+ `[Tandem Control Panel] Panel URL: http://${result.panelHost}:${result.panelPort}`
126
+ );
102
127
  console.log(`[Tandem Control Panel] Token: ${result.token}`);
103
- if (process.argv.slice(2).length === 1 || (process.argv.slice(2).length === 2 && resetTokenRequested)) {
128
+ if (
129
+ process.argv.slice(2).length === 1 ||
130
+ (process.argv.slice(2).length === 2 && resetTokenRequested)
131
+ ) {
104
132
  process.exit(0);
105
133
  }
106
134
  }
107
135
 
108
- loadDotEnvFile(cwdEnvPath);
109
-
110
136
  const __dirname = dirname(fileURLToPath(import.meta.url));
111
137
  const DIST_DIR = join(__dirname, "..", "dist");
112
138
  const REPO_ROOT = resolve(__dirname, "..", "..", "..");
@@ -125,9 +151,20 @@ function resolveDefaultChannelUploadsRoot() {
125
151
  }
126
152
 
127
153
  const PORTAL_PORT = Number.parseInt(process.env.TANDEM_CONTROL_PANEL_PORT || "39732", 10);
154
+ const PANEL_HOST = (process.env.TANDEM_CONTROL_PANEL_HOST || "127.0.0.1").trim() || "127.0.0.1";
155
+ const PANEL_PUBLIC_URL = String(process.env.TANDEM_CONTROL_PANEL_PUBLIC_URL || "").trim();
128
156
  const ENGINE_HOST = (process.env.TANDEM_ENGINE_HOST || "127.0.0.1").trim();
129
157
  const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 10);
130
- const ENGINE_URL = (process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`).replace(/\/+$/, "");
158
+ const ENGINE_URL = (
159
+ process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
160
+ ).replace(/\/+$/, "");
161
+ const SWARM_RUNS_PATH = resolve(homedir(), ".tandem", "control-panel", "swarm-runs.json");
162
+ const SWARM_HIDDEN_RUNS_PATH = resolve(
163
+ homedir(),
164
+ ".tandem",
165
+ "control-panel",
166
+ "swarm-hidden-runs.json"
167
+ );
131
168
  const AUTO_START_ENGINE = (process.env.TANDEM_CONTROL_PANEL_AUTO_START_ENGINE || "1") !== "0";
132
169
  const CONFIGURED_ENGINE_TOKEN = (
133
170
  process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
@@ -136,7 +173,9 @@ const CONFIGURED_ENGINE_TOKEN = (
136
173
  ).trim();
137
174
  const SESSION_TTL_MS =
138
175
  Number.parseInt(process.env.TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES || "1440", 10) * 60 * 1000;
139
- const FILES_ROOT = resolve(process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot());
176
+ const FILES_ROOT = resolve(
177
+ process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot()
178
+ );
140
179
  const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "control-panel")
141
180
  .trim()
142
181
  .replace(/\\/g, "/")
@@ -144,11 +183,30 @@ const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "cont
144
183
  .replace(/\/+$/, "");
145
184
  const MAX_UPLOAD_BYTES = Math.max(
146
185
  1,
147
- Number.parseInt(process.env.TANDEM_CONTROL_PANEL_MAX_UPLOAD_BYTES || `${250 * 1024 * 1024}`, 10) ||
148
- 250 * 1024 * 1024
186
+ Number.parseInt(
187
+ process.env.TANDEM_CONTROL_PANEL_MAX_UPLOAD_BYTES || `${250 * 1024 * 1024}`,
188
+ 10
189
+ ) || 250 * 1024 * 1024
149
190
  );
150
191
  const require = createRequire(import.meta.url);
151
192
  const SETUP_ENTRYPOINT = fileURLToPath(import.meta.url);
193
+ const CONTROL_PANEL_PACKAGE = (() => {
194
+ try {
195
+ return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
196
+ } catch {
197
+ return {};
198
+ }
199
+ })();
200
+ const CONTROL_PANEL_VERSION = String(CONTROL_PANEL_PACKAGE?.version || "0.0.0").trim() || "0.0.0";
201
+ const CONTROL_PANEL_BUILD_FINGERPRINT = (() => {
202
+ try {
203
+ const source = readFileSync(SETUP_ENTRYPOINT);
204
+ const digest = createHash("sha1").update(source).digest("hex").slice(0, 8);
205
+ return `${CONTROL_PANEL_VERSION}-${digest}`;
206
+ } catch {
207
+ return `${CONTROL_PANEL_VERSION}-unknown`;
208
+ }
209
+ })();
152
210
 
153
211
  const log = (msg) => console.log(`[Tandem Control Panel] ${msg}`);
154
212
  const err = (msg) => console.error(`[Tandem Control Panel] ERROR: ${msg}`);
@@ -190,10 +248,137 @@ const swarmState = {
190
248
  objective: "",
191
249
  workspaceRoot: REPO_ROOT,
192
250
  maxTasks: 3,
251
+ maxAgents: 3,
252
+ workflowId: "swarm.blackboard.default",
253
+ modelProvider: "",
254
+ modelId: "",
255
+ mcpServers: [],
256
+ repoRoot: "",
257
+ preflight: {
258
+ gitAvailable: null,
259
+ repoReady: true,
260
+ autoInitialized: false,
261
+ code: "workspace_ready",
262
+ reason: "",
263
+ guidance: "",
264
+ },
193
265
  lastError: "",
266
+ executorState: "idle",
267
+ executorReason: "",
268
+ executorMode: "context_steps",
269
+ resolvedModelProvider: "",
270
+ resolvedModelId: "",
271
+ modelResolutionSource: "none",
272
+ verificationMode: "strict",
273
+ runId: "",
274
+ attachedPid: null,
275
+ buildVersion: CONTROL_PANEL_VERSION,
276
+ buildFingerprint: CONTROL_PANEL_BUILD_FINGERPRINT,
277
+ buildStartedAt: Date.now(),
194
278
  };
279
+ const swarmRunControllers = new Map();
195
280
  const swarmSseClients = new Set();
196
281
 
282
+ function createSwarmRunController(runId = "", overrides = {}) {
283
+ return {
284
+ status: "idle",
285
+ startedAt: null,
286
+ stoppedAt: null,
287
+ objective: "",
288
+ workspaceRoot: REPO_ROOT,
289
+ maxTasks: 3,
290
+ maxAgents: 3,
291
+ workflowId: "swarm.blackboard.default",
292
+ modelProvider: "",
293
+ modelId: "",
294
+ mcpServers: [],
295
+ repoRoot: "",
296
+ lastError: "",
297
+ executorState: "idle",
298
+ executorReason: "",
299
+ executorMode: "context_steps",
300
+ resolvedModelProvider: "",
301
+ resolvedModelId: "",
302
+ modelResolutionSource: "none",
303
+ verificationMode: "strict",
304
+ runId: String(runId || "").trim(),
305
+ attachedPid: null,
306
+ registryCache: null,
307
+ reasons: [],
308
+ logs: [],
309
+ ...overrides,
310
+ };
311
+ }
312
+
313
+ function syncLegacySwarmState(controller) {
314
+ if (!controller || typeof controller !== "object") return;
315
+ swarmState.status = controller.status || swarmState.status;
316
+ swarmState.startedAt = controller.startedAt ?? swarmState.startedAt;
317
+ swarmState.stoppedAt = controller.stoppedAt ?? swarmState.stoppedAt;
318
+ swarmState.objective = controller.objective || swarmState.objective;
319
+ swarmState.workspaceRoot = controller.workspaceRoot || swarmState.workspaceRoot;
320
+ swarmState.maxTasks = controller.maxTasks ?? swarmState.maxTasks;
321
+ swarmState.maxAgents = controller.maxAgents ?? swarmState.maxAgents;
322
+ swarmState.workflowId = controller.workflowId || swarmState.workflowId;
323
+ swarmState.modelProvider = controller.modelProvider || swarmState.modelProvider;
324
+ swarmState.modelId = controller.modelId || swarmState.modelId;
325
+ swarmState.mcpServers = Array.isArray(controller.mcpServers)
326
+ ? controller.mcpServers
327
+ : swarmState.mcpServers;
328
+ swarmState.repoRoot = controller.repoRoot || swarmState.repoRoot;
329
+ swarmState.lastError = controller.lastError || "";
330
+ swarmState.executorState = controller.executorState || swarmState.executorState;
331
+ swarmState.executorReason = controller.executorReason || "";
332
+ swarmState.executorMode = controller.executorMode || swarmState.executorMode;
333
+ swarmState.resolvedModelProvider =
334
+ controller.resolvedModelProvider || swarmState.resolvedModelProvider;
335
+ swarmState.resolvedModelId = controller.resolvedModelId || swarmState.resolvedModelId;
336
+ swarmState.modelResolutionSource =
337
+ controller.modelResolutionSource || swarmState.modelResolutionSource;
338
+ swarmState.verificationMode = controller.verificationMode || swarmState.verificationMode;
339
+ swarmState.runId = controller.runId || swarmState.runId;
340
+ swarmState.attachedPid = controller.attachedPid || null;
341
+ swarmState.registryCache = controller.registryCache || null;
342
+ }
343
+
344
+ function getSwarmRunController(runId = "") {
345
+ const key = String(runId || "").trim();
346
+ if (!key) return null;
347
+ return swarmRunControllers.get(key) || null;
348
+ }
349
+
350
+ function upsertSwarmRunController(runId = "", patch = {}) {
351
+ const key = String(runId || "").trim();
352
+ if (!key) return null;
353
+ const current = getSwarmRunController(key) || createSwarmRunController(key);
354
+ const next = {
355
+ ...current,
356
+ ...patch,
357
+ runId: key,
358
+ };
359
+ swarmRunControllers.set(key, next);
360
+ if (String(swarmState.runId || "").trim() === key) {
361
+ syncLegacySwarmState(next);
362
+ }
363
+ return next;
364
+ }
365
+
366
+ function setActiveSwarmRunId(runId = "") {
367
+ const key = String(runId || "").trim();
368
+ swarmState.runId = key;
369
+ if (!key) {
370
+ swarmState.status = "idle";
371
+ swarmState.executorState = "idle";
372
+ swarmState.executorReason = "";
373
+ swarmState.lastError = "";
374
+ swarmState.attachedPid = null;
375
+ swarmState.registryCache = null;
376
+ return;
377
+ }
378
+ const controller = getSwarmRunController(key);
379
+ if (controller) syncLegacySwarmState(controller);
380
+ }
381
+
197
382
  const sleep = (ms) => new Promise((resolveFn) => setTimeout(resolveFn, ms));
198
383
 
199
384
  function shellEscape(token) {
@@ -207,6 +392,7 @@ function runCmd(bin, args = [], options = {}) {
207
392
  const child = spawn(bin, args, {
208
393
  stdio: options.stdio || "pipe",
209
394
  env: options.env || process.env,
395
+ cwd: options.cwd || undefined,
210
396
  });
211
397
  let stdout = "";
212
398
  let stderr = "";
@@ -231,6 +417,279 @@ function runCmd(bin, args = [], options = {}) {
231
417
  });
232
418
  }
233
419
 
420
+ async function loadSwarmRunsHistory() {
421
+ try {
422
+ const raw = await readFile(SWARM_RUNS_PATH, "utf8");
423
+ const parsed = JSON.parse(raw);
424
+ if (!Array.isArray(parsed?.runs)) return [];
425
+ return parsed.runs
426
+ .filter((row) => row && typeof row === "object")
427
+ .map((row) => ({
428
+ runId: String(row.runId || "").trim(),
429
+ objective: String(row.objective || "").trim(),
430
+ workspaceRoot: String(row.workspaceRoot || "").trim(),
431
+ status: String(row.status || "unknown").trim(),
432
+ startedAt: Number(row.startedAt || 0) || 0,
433
+ stoppedAt: Number(row.stoppedAt || 0) || 0,
434
+ pid: Number(row.pid || 0) || null,
435
+ attached: row.attached === true,
436
+ }))
437
+ .filter((row) => row.runId || row.workspaceRoot || row.pid);
438
+ } catch {
439
+ return [];
440
+ }
441
+ }
442
+
443
+ async function saveSwarmRunsHistory(runs = []) {
444
+ const payload = JSON.stringify(
445
+ { version: 1, updatedAtMs: Date.now(), runs: runs.slice(-100) },
446
+ null,
447
+ 2
448
+ );
449
+ await mkdir(dirname(SWARM_RUNS_PATH), { recursive: true });
450
+ await writeFile(SWARM_RUNS_PATH, payload, "utf8");
451
+ }
452
+
453
+ async function loadHiddenSwarmRunIds() {
454
+ try {
455
+ const raw = await readFile(SWARM_HIDDEN_RUNS_PATH, "utf8");
456
+ const parsed = JSON.parse(raw);
457
+ const ids = Array.isArray(parsed?.runIds) ? parsed.runIds : [];
458
+ return new Set(
459
+ ids
460
+ .map((id) => String(id || "").trim())
461
+ .filter(Boolean)
462
+ .slice(0, 5000)
463
+ );
464
+ } catch {
465
+ return new Set();
466
+ }
467
+ }
468
+
469
+ async function saveHiddenSwarmRunIds(runIdSet) {
470
+ const runIds = Array.from(runIdSet)
471
+ .map((id) => String(id || "").trim())
472
+ .filter(Boolean)
473
+ .sort((a, b) => a.localeCompare(b));
474
+ await mkdir(dirname(SWARM_HIDDEN_RUNS_PATH), { recursive: true });
475
+ await writeFile(
476
+ SWARM_HIDDEN_RUNS_PATH,
477
+ JSON.stringify({ updatedAt: Date.now(), runIds }, null, 2),
478
+ "utf8"
479
+ );
480
+ }
481
+
482
+ async function recordSwarmRun(update = {}) {
483
+ try {
484
+ const runId = String(update.runId || "").trim() || randomBytes(8).toString("hex");
485
+ const runs = await loadSwarmRunsHistory();
486
+ const idx = runs.findIndex((row) => row.runId === runId);
487
+ const next = {
488
+ runId,
489
+ objective: String(update.objective || "").trim(),
490
+ workspaceRoot: String(update.workspaceRoot || "").trim(),
491
+ status: String(update.status || "unknown").trim(),
492
+ startedAt: Number(update.startedAt || 0) || Date.now(),
493
+ stoppedAt: Number(update.stoppedAt || 0) || 0,
494
+ pid: Number(update.pid || 0) || null,
495
+ attached: update.attached === true,
496
+ };
497
+ if (idx >= 0) runs[idx] = { ...runs[idx], ...next };
498
+ else runs.push(next);
499
+ await saveSwarmRunsHistory(runs);
500
+ return runId;
501
+ } catch {
502
+ return String(update.runId || "").trim();
503
+ }
504
+ }
505
+
506
+ function buildGitSafeEnv(baseEnv, safeDirectory) {
507
+ const env = { ...(baseEnv || process.env) };
508
+ const value = String(safeDirectory || "*").trim() || "*";
509
+ env.GIT_CONFIG_COUNT = "1";
510
+ env.GIT_CONFIG_KEY_0 = "safe.directory";
511
+ env.GIT_CONFIG_VALUE_0 = value;
512
+ return env;
513
+ }
514
+
515
+ function runGit(args = [], options = {}) {
516
+ return runCmd("git", args, {
517
+ ...options,
518
+ env: buildGitSafeEnv(options.env || process.env, options.safeDirectory),
519
+ });
520
+ }
521
+
522
+ function guidanceForGitInstall() {
523
+ if (process.platform === "darwin") {
524
+ return "Install Git: `xcode-select --install` or `brew install git`.";
525
+ }
526
+ if (process.platform === "win32") {
527
+ return "Install Git for Windows from https://git-scm.com/download/win and restart the app.";
528
+ }
529
+ return "Install Git using your package manager (for example `sudo apt install git`) and restart the app.";
530
+ }
531
+
532
+ function setSwarmPreflight(patch = {}) {
533
+ swarmState.preflight = {
534
+ gitAvailable: swarmState.preflight?.gitAvailable ?? null,
535
+ repoReady: swarmState.preflight?.repoReady ?? false,
536
+ autoInitialized: swarmState.preflight?.autoInitialized ?? false,
537
+ code: swarmState.preflight?.code || "",
538
+ reason: swarmState.preflight?.reason || "",
539
+ guidance: swarmState.preflight?.guidance || "",
540
+ ...patch,
541
+ };
542
+ }
543
+
544
+ async function detectGitAvailable() {
545
+ try {
546
+ await runGit(["--version"], { stdio: "pipe" });
547
+ return true;
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+
553
+ async function isGitRepo(cwd) {
554
+ try {
555
+ const out = await runGit(["-C", String(cwd || ""), "rev-parse", "--show-toplevel"], {
556
+ stdio: "pipe",
557
+ safeDirectory: "*",
558
+ });
559
+ const root = String(out.stdout || "").trim();
560
+ return root ? { ok: true, root, error: "" } : { ok: false, root: "", error: "" };
561
+ } catch (error) {
562
+ return { ok: false, root: "", error: String(error?.message || error || "") };
563
+ }
564
+ }
565
+
566
+ async function bootstrapEmptyGitRepo(workspaceRoot) {
567
+ await runGit(["init"], { cwd: workspaceRoot, stdio: "pipe", safeDirectory: workspaceRoot });
568
+ const readmePath = join(workspaceRoot, "README.md");
569
+ const ignorePath = join(workspaceRoot, ".gitignore");
570
+ if (!existsSync(readmePath)) {
571
+ await writeFile(
572
+ readmePath,
573
+ "# Swarm Workspace\n\nInitialized automatically for Tandem Swarm.\n",
574
+ "utf8"
575
+ );
576
+ }
577
+ if (!existsSync(ignorePath)) {
578
+ await writeFile(ignorePath, "node_modules/\n.DS_Store\n.swarm/worktrees/\n", "utf8");
579
+ }
580
+ await runGit(["add", "."], { cwd: workspaceRoot, stdio: "pipe", safeDirectory: workspaceRoot });
581
+ try {
582
+ await runGit(["commit", "-m", "Initialize swarm workspace"], {
583
+ cwd: workspaceRoot,
584
+ stdio: "pipe",
585
+ safeDirectory: workspaceRoot,
586
+ });
587
+ } catch (commitError) {
588
+ const message = String(commitError?.message || "");
589
+ if (!message.includes("Author identity unknown")) throw commitError;
590
+ await runGit(["config", "user.name", "Swarm Bootstrap"], {
591
+ cwd: workspaceRoot,
592
+ stdio: "pipe",
593
+ safeDirectory: workspaceRoot,
594
+ });
595
+ await runGit(["config", "user.email", "swarm@local"], {
596
+ cwd: workspaceRoot,
597
+ stdio: "pipe",
598
+ safeDirectory: workspaceRoot,
599
+ });
600
+ await runGit(["commit", "-m", "Initialize swarm workspace"], {
601
+ cwd: workspaceRoot,
602
+ stdio: "pipe",
603
+ safeDirectory: workspaceRoot,
604
+ });
605
+ }
606
+ }
607
+
608
+ async function preflightSwarmWorkspace(workspaceRoot, options = {}) {
609
+ const allowInitNonEmpty = options.allowInitNonEmpty === true;
610
+ const guidance = guidanceForGitInstall();
611
+ const gitAvailable = await detectGitAvailable();
612
+ if (!gitAvailable) {
613
+ return {
614
+ gitAvailable,
615
+ repoReady: false,
616
+ autoInitialized: false,
617
+ code: "git_missing",
618
+ repoRoot: "",
619
+ reason: "Git executable not found",
620
+ guidance,
621
+ };
622
+ }
623
+
624
+ const normalized = resolve(workspaceRoot);
625
+ if (!existsSync(normalized)) {
626
+ throw new Error(`Workspace root does not exist: ${normalized}`);
627
+ }
628
+ const details = await stat(normalized);
629
+ if (!details.isDirectory()) {
630
+ throw new Error(`Workspace root is not a directory: ${normalized}`);
631
+ }
632
+
633
+ const repo = await isGitRepo(normalized);
634
+ if (repo.ok) {
635
+ return {
636
+ gitAvailable: true,
637
+ repoReady: true,
638
+ autoInitialized: false,
639
+ code: "ok",
640
+ repoRoot: repo.root,
641
+ reason: "",
642
+ guidance,
643
+ };
644
+ }
645
+ const repoErrorText = String(repo.error || "");
646
+ const repoErrorLower = repoErrorText.toLowerCase();
647
+ if (
648
+ repoErrorText &&
649
+ !repoErrorLower.includes("not a git repository") &&
650
+ !repoErrorLower.includes("needed a single revision")
651
+ ) {
652
+ return {
653
+ gitAvailable: true,
654
+ repoReady: false,
655
+ autoInitialized: false,
656
+ code: "git_probe_failed",
657
+ repoRoot: "",
658
+ reason: `Git could not inspect the selected directory: ${repoErrorText}`,
659
+ guidance,
660
+ };
661
+ }
662
+
663
+ const entries = await readdir(normalized);
664
+ if (entries.length > 0 && !allowInitNonEmpty) {
665
+ const detail = repoErrorText ? ` Git probe output: ${repoErrorText}` : "";
666
+ return {
667
+ gitAvailable: true,
668
+ repoReady: false,
669
+ autoInitialized: false,
670
+ code: "not_repo_non_empty",
671
+ repoRoot: "",
672
+ reason: `Selected directory is not a Git repository and is not empty: ${normalized}. Choose an existing repo or an empty directory.${detail}`,
673
+ guidance,
674
+ };
675
+ }
676
+
677
+ await bootstrapEmptyGitRepo(normalized);
678
+ const initializedRepo = await isGitRepo(normalized);
679
+ if (!initializedRepo.ok) {
680
+ throw new Error(`Git repository initialization failed for ${normalized}`);
681
+ }
682
+ return {
683
+ gitAvailable: true,
684
+ repoReady: true,
685
+ autoInitialized: true,
686
+ code: allowInitNonEmpty ? "auto_initialized_non_empty" : "auto_initialized_empty",
687
+ repoRoot: initializedRepo.root,
688
+ reason: "",
689
+ guidance,
690
+ };
691
+ }
692
+
234
693
  async function installServices() {
235
694
  if (process.platform !== "linux") {
236
695
  throw new Error("--install-services currently supports Linux/systemd only.");
@@ -239,7 +698,8 @@ async function installServices() {
239
698
  throw new Error("Service installation needs root privileges. Re-run with sudo.");
240
699
  }
241
700
 
242
- const serviceUser = serviceUserArg || String(process.env.SUDO_USER || process.env.USER || "root").trim();
701
+ const serviceUser =
702
+ serviceUserArg || String(process.env.SUDO_USER || process.env.USER || "root").trim();
243
703
  if (!serviceUser) throw new Error("Could not determine service user.");
244
704
  const serviceGroup = serviceUser;
245
705
  const installEngine = serviceMode === "both" || serviceMode === "engine";
@@ -252,7 +712,9 @@ async function installServices() {
252
712
  const engineBin = String(process.env.TANDEM_ENGINE_BIN || "tandem-engine").trim();
253
713
  const token =
254
714
  CONFIGURED_ENGINE_TOKEN ||
255
- (existsSync(engineEnvPath) ? parseDotEnv(readFileSync(engineEnvPath, "utf8")).TANDEM_API_TOKEN || "" : "") ||
715
+ (existsSync(engineEnvPath)
716
+ ? parseDotEnv(readFileSync(engineEnvPath, "utf8")).TANDEM_API_TOKEN || ""
717
+ : "") ||
256
718
  `tk_${randomBytes(16).toString("hex")}`;
257
719
 
258
720
  await mkdir("/etc/tandem", { recursive: true });
@@ -263,17 +725,17 @@ async function installServices() {
263
725
  log(`Warning: could not chown ${stateDir} to ${serviceUser}:${serviceGroup}: ${e.message}`);
264
726
  }
265
727
 
266
- const existingEngineEnv = existsSync(engineEnvPath) ? parseDotEnv(readFileSync(engineEnvPath, "utf8")) : {};
728
+ const existingEngineEnv = existsSync(engineEnvPath)
729
+ ? parseDotEnv(readFileSync(engineEnvPath, "utf8"))
730
+ : {};
267
731
  const engineEnv = {
268
732
  ...existingEngineEnv,
269
733
  TANDEM_API_TOKEN: token,
270
734
  TANDEM_STATE_DIR: stateDir,
271
735
  TANDEM_MEMORY_DB_PATH: existingEngineEnv.TANDEM_MEMORY_DB_PATH || `${stateDir}/memory.sqlite`,
272
736
  TANDEM_ENABLE_GLOBAL_MEMORY: existingEngineEnv.TANDEM_ENABLE_GLOBAL_MEMORY || "1",
273
- TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
274
- existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
275
- TANDEM_TOOL_ROUTER_ENABLED:
276
- existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
737
+ TANDEM_DISABLE_TOOL_GUARD_BUDGETS: existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
738
+ TANDEM_TOOL_ROUTER_ENABLED: existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
277
739
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
278
740
  existingEngineEnv.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
279
741
  TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
@@ -282,8 +744,7 @@ async function installServices() {
282
744
  existingEngineEnv.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
283
745
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
284
746
  existingEngineEnv.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
285
- TANDEM_TOOL_EXEC_TIMEOUT_MS:
286
- existingEngineEnv.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
747
+ TANDEM_TOOL_EXEC_TIMEOUT_MS: existingEngineEnv.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
287
748
  TANDEM_BASH_TIMEOUT_MS: existingEngineEnv.TANDEM_BASH_TIMEOUT_MS || "30000",
288
749
  };
289
750
  const engineEnvBody = Object.entries(engineEnv)
@@ -293,7 +754,9 @@ async function installServices() {
293
754
  await runCmd("chmod", ["640", engineEnvPath]);
294
755
 
295
756
  const panelAutoStart = serviceMode === "panel" ? "1" : "0";
296
- const existingPanelEnv = existsSync(panelEnvPath) ? parseDotEnv(readFileSync(panelEnvPath, "utf8")) : {};
757
+ const existingPanelEnv = existsSync(panelEnvPath)
758
+ ? parseDotEnv(readFileSync(panelEnvPath, "utf8"))
759
+ : {};
297
760
  const panelEnv = {
298
761
  ...existingPanelEnv,
299
762
  TANDEM_CONTROL_PANEL_PORT: String(PORTAL_PORT),
@@ -365,10 +828,14 @@ WantedBy=multi-user.target
365
828
 
366
829
  await runCmd("systemctl", ["daemon-reload"], { stdio: "inherit" });
367
830
  if (installEngine) {
368
- await runCmd("systemctl", ["enable", "--now", `${engineServiceName}.service`], { stdio: "inherit" });
831
+ await runCmd("systemctl", ["enable", "--now", `${engineServiceName}.service`], {
832
+ stdio: "inherit",
833
+ });
369
834
  }
370
835
  if (installPanel) {
371
- await runCmd("systemctl", ["enable", "--now", `${panelServiceName}.service`], { stdio: "inherit" });
836
+ await runCmd("systemctl", ["enable", "--now", `${panelServiceName}.service`], {
837
+ stdio: "inherit",
838
+ });
372
839
  }
373
840
 
374
841
  log("Services installed.");
@@ -381,6 +848,45 @@ WantedBy=multi-user.target
381
848
  log(`Token: ${token}`);
382
849
  }
383
850
 
851
+ function selectedServiceUnits(mode) {
852
+ const normalized = String(mode || "both")
853
+ .trim()
854
+ .toLowerCase();
855
+ if (normalized === "engine") return ["tandem-engine.service"];
856
+ if (normalized === "panel") return ["tandem-control-panel.service"];
857
+ return ["tandem-engine.service", "tandem-control-panel.service"];
858
+ }
859
+
860
+ async function operateServices(operation, mode) {
861
+ const op = String(operation || "")
862
+ .trim()
863
+ .toLowerCase();
864
+ if (!op) return;
865
+ if (process.platform !== "linux") {
866
+ throw new Error("--service-op currently supports Linux/systemd only.");
867
+ }
868
+ const units = selectedServiceUnits(mode);
869
+ if (op === "logs") {
870
+ const args = units
871
+ .flatMap((unit) => ["-u", unit])
872
+ .concat(["-n", "120", "-f", "-o", "short-iso"]);
873
+ await runCmd("journalctl", args, { stdio: "inherit" });
874
+ return;
875
+ }
876
+ if (op === "status") {
877
+ await runCmd("systemctl", ["--no-pager", "--full", "status", ...units], { stdio: "inherit" });
878
+ return;
879
+ }
880
+ if (!["start", "stop", "restart", "enable", "disable"].includes(op)) {
881
+ throw new Error(
882
+ "Invalid --service-op. Expected one of: status,start,stop,restart,enable,disable,logs"
883
+ );
884
+ }
885
+ for (const unit of units) {
886
+ await runCmd("systemctl", [op, unit], { stdio: "inherit" });
887
+ }
888
+ }
889
+
384
890
  function isLocalEngineUrl(url) {
385
891
  try {
386
892
  const u = new URL(url);
@@ -472,7 +978,9 @@ function pushSwarmEvent(kind, payload = {}) {
472
978
  }
473
979
 
474
980
  function appendSwarmLog(stream, text) {
475
- const lines = String(text || "").split(/\r?\n/).filter(Boolean);
981
+ const lines = String(text || "")
982
+ .split(/\r?\n/)
983
+ .filter(Boolean);
476
984
  for (const line of lines) {
477
985
  swarmState.logs.push({ at: Date.now(), stream, line });
478
986
  if (swarmState.logs.length > 800) swarmState.logs.shift();
@@ -480,78 +988,6 @@ function appendSwarmLog(stream, text) {
480
988
  }
481
989
  }
482
990
 
483
- function appendSwarmReason(reason) {
484
- const item = { at: Date.now(), ...reason };
485
- swarmState.reasons.push(item);
486
- if (swarmState.reasons.length > 500) swarmState.reasons.shift();
487
- pushSwarmEvent("reason", item);
488
- }
489
-
490
- function compareRegistryTransitions(previousRegistry, nextRegistry) {
491
- const prevTasks = previousRegistry?.tasks || {};
492
- const nextTasks = nextRegistry?.tasks || {};
493
- const transitions = [];
494
-
495
- for (const [taskId, next] of Object.entries(nextTasks)) {
496
- const prev = prevTasks[taskId];
497
- if (!prev) {
498
- transitions.push({
499
- kind: "task_transition",
500
- taskId,
501
- from: "new",
502
- to: next.status || "unknown",
503
- role: next.ownerRole || "",
504
- reason: next.statusReason || "task registered",
505
- });
506
- continue;
507
- }
508
- if ((prev.status || "") !== (next.status || "")) {
509
- transitions.push({
510
- kind: "task_transition",
511
- taskId,
512
- from: prev.status || "unknown",
513
- to: next.status || "unknown",
514
- role: next.ownerRole || "",
515
- reason: next.statusReason || `${prev.status || "unknown"} -> ${next.status || "unknown"}`,
516
- });
517
- } else if ((prev.statusReason || "") !== (next.statusReason || "") && next.statusReason) {
518
- transitions.push({
519
- kind: "task_reason",
520
- taskId,
521
- from: next.status || "unknown",
522
- to: next.status || "unknown",
523
- role: next.ownerRole || "",
524
- reason: next.statusReason,
525
- });
526
- }
527
- }
528
-
529
- return transitions;
530
- }
531
-
532
- async function monitorSwarmRegistry(token) {
533
- try {
534
- const latest = await readSwarmRegistry(token);
535
- const latestValue = latest?.value || { tasks: {} };
536
- const previous = swarmState.registryCache?.value || { tasks: {} };
537
- const transitions = compareRegistryTransitions(previous, latestValue);
538
- if (transitions.length > 0) {
539
- for (const t of transitions) appendSwarmReason(t);
540
- pushSwarmEvent("registry_update", { count: transitions.length });
541
- }
542
- swarmState.registryCache = latest;
543
- } catch (e) {
544
- appendSwarmLog("stderr", `[swarm-monitor] ${e instanceof Error ? e.message : String(e)}`);
545
- }
546
- }
547
-
548
- function clearSwarmMonitor() {
549
- if (swarmState.monitorTimer) {
550
- clearInterval(swarmState.monitorTimer);
551
- swarmState.monitorTimer = null;
552
- }
553
- }
554
-
555
991
  async function engineHealth(token = "") {
556
992
  try {
557
993
  const response = await fetch(`${ENGINE_URL}/global/health`, {
@@ -595,7 +1031,9 @@ async function ensureEngineRunning() {
595
1031
  log(
596
1032
  "Note: TANDEM_CONTROL_PANEL_ENGINE_TOKEN is only applied when control panel starts a new engine process."
597
1033
  );
598
- log("Use the existing engine's token, or stop that engine to let control panel start one with your configured token.");
1034
+ log(
1035
+ "Use the existing engine's token, or stop that engine to let control panel start one with your configured token."
1036
+ );
599
1037
  }
600
1038
  return;
601
1039
  }
@@ -615,25 +1053,28 @@ async function ensureEngineRunning() {
615
1053
  log(`Starting Tandem Engine at ${ENGINE_URL}...`);
616
1054
  engineProcess = spawn(
617
1055
  process.execPath,
618
- [engineEntrypoint, "serve", "--hostname", url.hostname, "--port", String(url.port || ENGINE_PORT)],
1056
+ [
1057
+ engineEntrypoint,
1058
+ "serve",
1059
+ "--hostname",
1060
+ url.hostname,
1061
+ "--port",
1062
+ String(url.port || ENGINE_PORT),
1063
+ ],
619
1064
  {
620
1065
  env: {
621
1066
  ...process.env,
622
1067
  TANDEM_API_TOKEN: managedEngineToken,
623
- TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
624
- process.env.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
625
- TANDEM_TOOL_ROUTER_ENABLED:
626
- process.env.TANDEM_TOOL_ROUTER_ENABLED || "0",
1068
+ TANDEM_DISABLE_TOOL_GUARD_BUDGETS: process.env.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
1069
+ TANDEM_TOOL_ROUTER_ENABLED: process.env.TANDEM_TOOL_ROUTER_ENABLED || "0",
627
1070
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
628
1071
  process.env.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
629
1072
  TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
630
1073
  process.env.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
631
1074
  TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
632
1075
  process.env.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
633
- TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
634
- process.env.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
635
- TANDEM_TOOL_EXEC_TIMEOUT_MS:
636
- process.env.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
1076
+ TANDEM_PERMISSION_WAIT_TIMEOUT_MS: process.env.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
1077
+ TANDEM_TOOL_EXEC_TIMEOUT_MS: process.env.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
637
1078
  TANDEM_BASH_TIMEOUT_MS: process.env.TANDEM_BASH_TIMEOUT_MS || "30000",
638
1079
  },
639
1080
  stdio: "inherit",
@@ -641,7 +1082,9 @@ async function ensureEngineRunning() {
641
1082
  );
642
1083
  log(`Engine API token for this process: ${managedEngineToken}`);
643
1084
  if (!CONFIGURED_ENGINE_TOKEN) {
644
- log("Token was auto-generated. Set TANDEM_CONTROL_PANEL_ENGINE_TOKEN (or TANDEM_API_TOKEN) to keep it stable.");
1085
+ log(
1086
+ "Token was auto-generated. Set TANDEM_CONTROL_PANEL_ENGINE_TOKEN (or TANDEM_API_TOKEN) to keep it stable."
1087
+ );
645
1088
  }
646
1089
 
647
1090
  engineProcess.on("error", (e) => err(`Failed to start engine: ${e.message}`));
@@ -676,7 +1119,8 @@ function toSafeRelPath(raw) {
676
1119
  if (normalized.includes("\0")) return null;
677
1120
  const full = resolve(FILES_ROOT, normalized);
678
1121
  if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
679
- if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`)) return null;
1122
+ if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`))
1123
+ return null;
680
1124
  return normalized;
681
1125
  }
682
1126
 
@@ -931,7 +1375,11 @@ async function handleAuthLogin(req, res) {
931
1375
  sendJson(res, 200, {
932
1376
  ok: true,
933
1377
  requiresToken: !!health.apiTokenRequired,
934
- engine: { url: ENGINE_URL, version: health.version || "unknown", local: isLocalEngineUrl(ENGINE_URL) },
1378
+ engine: {
1379
+ url: ENGINE_URL,
1380
+ version: health.version || "unknown",
1381
+ local: isLocalEngineUrl(ENGINE_URL),
1382
+ },
935
1383
  });
936
1384
  } catch (e) {
937
1385
  sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) });
@@ -976,7 +1424,10 @@ async function proxyEngineRequest(req, res, session) {
976
1424
  duplex: hasBody ? "half" : undefined,
977
1425
  });
978
1426
  } catch (e) {
979
- sendJson(res, 502, { ok: false, error: `Engine unreachable: ${e instanceof Error ? e.message : String(e)}` });
1427
+ sendJson(res, 502, {
1428
+ ok: false,
1429
+ error: `Engine unreachable: ${e instanceof Error ? e.message : String(e)}`,
1430
+ });
980
1431
  return;
981
1432
  }
982
1433
 
@@ -1023,183 +1474,2870 @@ async function proxyEngineRequest(req, res, session) {
1023
1474
  }
1024
1475
  }
1025
1476
 
1026
- async function readSwarmRegistry(token) {
1027
- const keys = ["swarm.active_tasks", "project/swarm.active_tasks"];
1028
- for (const key of keys) {
1477
+ function contextStepStatusToLegacyTaskStatus(status) {
1478
+ switch (
1479
+ String(status || "")
1480
+ .trim()
1481
+ .toLowerCase()
1482
+ ) {
1483
+ case "in_progress":
1484
+ return "running";
1485
+ case "runnable":
1486
+ case "pending":
1487
+ return "pending";
1488
+ case "blocked":
1489
+ return "blocked";
1490
+ case "done":
1491
+ return "complete";
1492
+ case "failed":
1493
+ return "failed";
1494
+ default:
1495
+ return "pending";
1496
+ }
1497
+ }
1498
+
1499
+ function contextRunStatusToSwarmStatus(status) {
1500
+ const normalized = String(status || "")
1501
+ .trim()
1502
+ .toLowerCase();
1503
+ if (
1504
+ [
1505
+ "queued",
1506
+ "planning",
1507
+ "awaiting_approval",
1508
+ "running",
1509
+ "paused",
1510
+ "blocked",
1511
+ "completed",
1512
+ "failed",
1513
+ "cancelled",
1514
+ ].includes(normalized)
1515
+ ) {
1516
+ return normalized;
1517
+ }
1518
+ return "idle";
1519
+ }
1520
+
1521
+ function buildWorkspaceId(workspaceRoot) {
1522
+ const digest = createHash("sha1")
1523
+ .update(String(workspaceRoot || ""))
1524
+ .digest("hex");
1525
+ return `ws-${digest.slice(0, 16)}`;
1526
+ }
1527
+
1528
+ function workspaceExistsAsDirectory(workspaceRoot) {
1529
+ const normalized = resolve(String(workspaceRoot || "").trim());
1530
+ if (!existsSync(normalized)) return null;
1531
+ return stat(normalized)
1532
+ .then((info) => (info.isDirectory() ? normalized : null))
1533
+ .catch(() => null);
1534
+ }
1535
+
1536
+ async function engineRequestJson(session, path, options = {}) {
1537
+ const method = String(options.method || "GET").toUpperCase();
1538
+ const body = options.body;
1539
+ const maxNetworkRetries = options.maxNetworkRetries ?? 2;
1540
+ const isEngineStarting = (value) =>
1541
+ typeof value === "string" &&
1542
+ (value.includes("ENGINE_STARTING") ||
1543
+ value.includes("Engine starting") ||
1544
+ value.includes("Service Unavailable"));
1545
+ let response = null;
1546
+ let lastError = null;
1547
+ for (let attempt = 0; attempt <= maxNetworkRetries; attempt += 1) {
1548
+ if (attempt > 0) await sleep(1000 * attempt);
1029
1549
  try {
1030
- const response = await fetch(`${ENGINE_URL}/resource/${encodeURIComponent(key)}`, {
1550
+ response = await fetch(`${ENGINE_URL}${path}`, {
1551
+ method,
1031
1552
  headers: {
1032
- authorization: `Bearer ${token}`,
1033
- "x-tandem-token": token,
1553
+ authorization: `Bearer ${session.token}`,
1554
+ "x-tandem-token": session.token,
1555
+ ...(body ? { "content-type": "application/json" } : {}),
1556
+ ...(options.headers || {}),
1034
1557
  },
1035
- signal: AbortSignal.timeout(1200),
1558
+ body: body ? JSON.stringify(body) : undefined,
1559
+ signal: AbortSignal.timeout(options.timeoutMs || 8000),
1036
1560
  });
1037
- if (!response.ok) continue;
1038
- const record = await response.json();
1039
- if (record?.value && typeof record.value === "object") {
1040
- return { key, value: record.value };
1561
+ } catch (error) {
1562
+ const name = String(error?.name || "");
1563
+ const isTimeout = name === "AbortError" || name === "TimeoutError";
1564
+ if (isTimeout || attempt >= maxNetworkRetries) throw error;
1565
+ lastError = error;
1566
+ continue;
1567
+ }
1568
+ if (!response.ok) {
1569
+ const rawText = await response.text().catch(() => "");
1570
+ if (
1571
+ (response.status === 503 || isEngineStarting(rawText)) &&
1572
+ attempt < maxNetworkRetries
1573
+ ) {
1574
+ lastError = new Error(rawText || `${method} ${path} failed: ${response.status}`);
1575
+ response = null;
1576
+ continue;
1041
1577
  }
1042
- } catch {
1043
- // ignore
1578
+ let detail = `${method} ${path} failed: ${response.status}`;
1579
+ if (rawText.trim()) {
1580
+ try {
1581
+ const payload = JSON.parse(rawText);
1582
+ detail = String(payload?.error || payload?.message || detail);
1583
+ } catch {
1584
+ detail = rawText || detail;
1585
+ }
1586
+ }
1587
+ throw new Error(detail);
1044
1588
  }
1589
+ break;
1045
1590
  }
1046
- return { key: "swarm.active_tasks", value: { version: 1, updatedAtMs: Date.now(), tasks: {} } };
1591
+ if (!response) throw lastError || new Error(`${method} ${path} failed`);
1592
+ if (response.status === 204) return {};
1593
+ const text = await response.text();
1594
+ if (!text.trim()) return {};
1595
+ return JSON.parse(text);
1047
1596
  }
1048
1597
 
1049
- function stopSwarm() {
1050
- if (!swarmState.process) return;
1051
- swarmState.status = "stopping";
1052
- clearSwarmMonitor();
1053
- swarmState.process.kill("SIGTERM");
1054
- pushSwarmEvent("status", { status: swarmState.status });
1598
+ function contextRunToTasks(run) {
1599
+ const steps = Array.isArray(run?.steps) ? run.steps : [];
1600
+ return steps.map((step) => ({
1601
+ taskId: String(step.step_id || ""),
1602
+ title: String(step.title || step.step_id || "Untitled step"),
1603
+ ownerRole: "context_driver",
1604
+ status: contextStepStatusToLegacyTaskStatus(step.status),
1605
+ stepStatus: String(step.status || "pending"),
1606
+ statusReason: String(run?.why_next_step || ""),
1607
+ lastUpdateMs: Number(run?.updated_at_ms || Date.now()),
1608
+ runId: String(run?.run_id || ""),
1609
+ sessionId: `context-${String(run?.run_id || "")}`,
1610
+ branch: "",
1611
+ worktreePath: "",
1612
+ }));
1055
1613
  }
1056
1614
 
1057
- function startSwarm(session, config = {}) {
1058
- if (!isLocalEngineUrl(ENGINE_URL)) {
1059
- throw new Error("Swarm orchestration is disabled when using a remote engine URL.");
1060
- }
1061
- if (swarmState.process) {
1062
- throw new Error("Swarm runtime is already running.");
1063
- }
1615
+ function eventsToReasons(events = []) {
1616
+ return events
1617
+ .map((evt) => {
1618
+ const payload = evt?.payload && typeof evt.payload === "object" ? evt.payload : {};
1619
+ return {
1620
+ at: Number(evt?.ts_ms || Date.now()),
1621
+ kind: "task_transition",
1622
+ taskId: String(evt?.step_id || payload?.step_id || "run"),
1623
+ role: "context_driver",
1624
+ from: "",
1625
+ to: String(evt?.status || ""),
1626
+ reason: String(payload?.why_next_step || payload?.error || evt?.type || "updated"),
1627
+ };
1628
+ })
1629
+ .slice(-250);
1630
+ }
1064
1631
 
1065
- const objective = String(config.objective || "Ship a small feature end-to-end").trim();
1066
- const workspaceRoot = String(config.workspaceRoot || REPO_ROOT).trim();
1067
- const maxTasks = Math.max(1, Number.parseInt(String(config.maxTasks || 3), 10) || 3);
1632
+ function eventsToLogs(events = []) {
1633
+ return events
1634
+ .map((evt) => {
1635
+ const payload = evt?.payload && typeof evt.payload === "object" ? evt.payload : {};
1636
+ const details = payload?.error || payload?.why_next_step || "";
1637
+ const line = details
1638
+ ? `${evt?.type || "event"} ${evt?.status || ""}: ${details}`
1639
+ : `${evt?.type || "event"} ${evt?.status || ""}`.trim();
1640
+ return {
1641
+ at: Number(evt?.ts_ms || Date.now()),
1642
+ stream: "event",
1643
+ line,
1644
+ };
1645
+ })
1646
+ .slice(-300);
1647
+ }
1648
+
1649
+ async function contextRunSnapshot(session, runId) {
1650
+ const [runPayload, eventsPayload, blackboardPayload, replayPayload, patchesPayload] =
1651
+ await Promise.all([
1652
+ engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}`),
1653
+ engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/events?tail=300`),
1654
+ engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/blackboard`).catch(
1655
+ () => ({})
1656
+ ),
1657
+ engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/replay`).catch(
1658
+ () => ({})
1659
+ ),
1660
+ engineRequestJson(
1661
+ session,
1662
+ `/context/runs/${encodeURIComponent(runId)}/blackboard/patches?tail=300`
1663
+ ).catch(() => ({})),
1664
+ ]);
1665
+ const run = runPayload?.run || {};
1666
+ const events = Array.isArray(eventsPayload?.events) ? eventsPayload.events : [];
1667
+ const blackboardPatches = Array.isArray(patchesPayload?.patches) ? patchesPayload.patches : [];
1668
+ const tasks = contextRunToTasks(run);
1669
+ const taskMap = Object.fromEntries(tasks.map((task) => [task.taskId, task]));
1670
+ return {
1671
+ run,
1672
+ events,
1673
+ blackboard: blackboardPayload?.blackboard || null,
1674
+ blackboardPatches,
1675
+ replay: replayPayload || null,
1676
+ registry: {
1677
+ key: "context.run.steps",
1678
+ value: {
1679
+ version: 1,
1680
+ updatedAtMs: Number(run?.updated_at_ms || Date.now()),
1681
+ tasks: taskMap,
1682
+ },
1683
+ },
1684
+ reasons: eventsToReasons(events),
1685
+ logs: eventsToLogs(events),
1686
+ };
1687
+ }
1068
1688
 
1069
- const managerPath = join(REPO_ROOT, "examples", "agent-swarm", "src", "manager.mjs");
1070
- if (!existsSync(managerPath)) {
1071
- throw new Error(`Missing swarm manager at ${managerPath}`);
1072
- }
1073
-
1074
- swarmState.logs = [];
1075
- swarmState.reasons = [];
1076
- swarmState.status = "starting";
1077
- swarmState.startedAt = Date.now();
1078
- swarmState.stoppedAt = null;
1079
- swarmState.objective = objective;
1080
- swarmState.workspaceRoot = workspaceRoot;
1081
- swarmState.maxTasks = maxTasks;
1082
- swarmState.lastError = "";
1083
- swarmState.registryCache = null;
1084
-
1085
- pushSwarmEvent("status", { status: swarmState.status, objective, workspaceRoot, maxTasks });
1086
-
1087
- const child = spawn(process.execPath, [managerPath, objective], {
1088
- cwd: workspaceRoot,
1089
- env: {
1090
- ...process.env,
1091
- TANDEM_BASE_URL: ENGINE_URL,
1092
- TANDEM_API_TOKEN: session.token,
1093
- SWARM_MAX_TASKS: String(maxTasks),
1094
- SWARM_OBJECTIVE: objective,
1689
+ async function appendContextRunEvent(
1690
+ session,
1691
+ runId,
1692
+ eventType,
1693
+ status,
1694
+ payload = {},
1695
+ stepId = null
1696
+ ) {
1697
+ return engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/events`, {
1698
+ method: "POST",
1699
+ body: {
1700
+ type: eventType,
1701
+ status,
1702
+ step_id: stepId || undefined,
1703
+ payload,
1095
1704
  },
1096
- stdio: ["ignore", "pipe", "pipe"],
1097
1705
  });
1706
+ }
1098
1707
 
1099
- swarmState.process = child;
1708
+ function parseObjectiveTodos(objective, max = 6) {
1709
+ const compact = String(objective || "")
1710
+ .split(/\r?\n/)
1711
+ .map((line) => line.replace(/^[-*#\d\.\)\[\]\s]+/, "").trim())
1712
+ .filter(Boolean)
1713
+ .join(" ");
1714
+ const normalized = compact.replace(/\s+/g, " ").trim();
1715
+ if (!normalized) return ["Execute requested objective"];
1716
+ const maxChars = Math.max(80, Number(max || 6) * 80);
1717
+ const content =
1718
+ normalized.length > maxChars ? `${normalized.slice(0, maxChars).trimEnd()}...` : normalized;
1719
+ return [content];
1720
+ }
1100
1721
 
1101
- child.stdout.on("data", (chunk) => appendSwarmLog("stdout", chunk));
1102
- child.stderr.on("data", (chunk) => appendSwarmLog("stderr", chunk));
1722
+ function textFromMessageParts(parts) {
1723
+ if (!Array.isArray(parts)) return "";
1724
+ return parts
1725
+ .map((part) => {
1726
+ if (!part) return "";
1727
+ if (typeof part === "string") return part;
1728
+ if (typeof part.text === "string") return part.text;
1729
+ if (typeof part.delta === "string") return part.delta;
1730
+ if (typeof part.content === "string") return part.content;
1731
+ return "";
1732
+ })
1733
+ .filter(Boolean)
1734
+ .join("\n")
1735
+ .trim();
1736
+ }
1103
1737
 
1104
- child.on("spawn", () => {
1105
- swarmState.status = "running";
1106
- pushSwarmEvent("status", { status: swarmState.status });
1107
- void monitorSwarmRegistry(session.token);
1108
- swarmState.monitorTimer = setInterval(() => {
1109
- if (swarmState.status === "running") void monitorSwarmRegistry(session.token);
1110
- }, 2000);
1111
- });
1738
+ function roleOfMessage(row) {
1739
+ return String(
1740
+ row?.info?.role || row?.role || row?.message_role || row?.type || row?.author || "assistant"
1741
+ )
1742
+ .trim()
1743
+ .toLowerCase();
1744
+ }
1112
1745
 
1113
- child.on("error", (e) => {
1114
- swarmState.status = "error";
1115
- swarmState.lastError = e.message;
1116
- clearSwarmMonitor();
1117
- appendSwarmLog("stderr", e.message);
1118
- pushSwarmEvent("status", { status: swarmState.status, error: e.message });
1119
- });
1746
+ function textOfMessage(row) {
1747
+ const fromParts = textFromMessageParts(row?.parts);
1748
+ if (fromParts) return fromParts;
1749
+ const direct = [row?.content, row?.text, row?.message, row?.delta, row?.body].find(
1750
+ (value) => typeof value === "string" && value.trim().length > 0
1751
+ );
1752
+ if (typeof direct === "string") return direct.trim();
1753
+ if (Array.isArray(row?.content)) {
1754
+ return row.content
1755
+ .map((chunk) => {
1756
+ if (!chunk) return "";
1757
+ if (typeof chunk === "string") return chunk;
1758
+ if (typeof chunk?.text === "string") return chunk.text;
1759
+ if (typeof chunk?.content === "string") return chunk.content;
1760
+ return "";
1761
+ })
1762
+ .filter(Boolean)
1763
+ .join("\n")
1764
+ .trim();
1765
+ }
1766
+ return "";
1767
+ }
1768
+
1769
+ function extractAssistantText(rows) {
1770
+ const list = Array.isArray(rows) ? rows : [];
1771
+ for (let i = list.length - 1; i >= 0; i -= 1) {
1772
+ if (roleOfMessage(list[i]) !== "assistant") continue;
1773
+ const text = textOfMessage(list[i]);
1774
+ if (text) return text;
1775
+ }
1776
+ return "";
1777
+ }
1120
1778
 
1121
- child.on("exit", (code, signal) => {
1122
- const failed = code && code !== 0;
1123
- swarmState.status = failed ? "error" : "idle";
1124
- swarmState.stoppedAt = Date.now();
1125
- swarmState.lastError = failed ? `Exited with code ${code}` : "";
1126
- swarmState.process = null;
1127
- clearSwarmMonitor();
1128
- pushSwarmEvent("status", {
1129
- status: swarmState.status,
1130
- code,
1131
- signal,
1132
- error: swarmState.lastError || undefined,
1779
+ function normalizePlannerTasks(rawTasks, maxTasks = 8, options = {}) {
1780
+ const normalizedMax = Math.max(1, Number(maxTasks) || 8);
1781
+ const linearFallback = options?.linearFallback === true;
1782
+ const candidates = Array.isArray(rawTasks) ? rawTasks : [];
1783
+ const normalizeTaskKind = (value, outputTarget) => {
1784
+ const raw = String(value || "").trim().toLowerCase();
1785
+ if (["implementation", "inspection", "research", "validation"].includes(raw)) return raw;
1786
+ return outputTarget?.path ? "implementation" : "inspection";
1787
+ };
1788
+ const normalizeOutputTarget = (value) => {
1789
+ if (!value || typeof value !== "object") return null;
1790
+ const path = String(value?.path || value?.file || value?.file_path || value?.target || "").trim();
1791
+ if (!path) return null;
1792
+ const kind = String(value?.kind || value?.type || "artifact").trim().toLowerCase() || "artifact";
1793
+ const operation = String(value?.operation || value?.mode || "").trim().toLowerCase() || "create_or_update";
1794
+ return { path, kind, operation };
1795
+ };
1796
+ const provisional = candidates
1797
+ .map((row, index) => {
1798
+ if (typeof row === "string") {
1799
+ const title = String(row || "").trim();
1800
+ if (title.length < 6) return null;
1801
+ return {
1802
+ id: `task-${index + 1}`,
1803
+ title,
1804
+ dependsOnTaskIds: [],
1805
+ outputTarget: null,
1806
+ };
1807
+ }
1808
+ if (!row || typeof row !== "object") return null;
1809
+ const title = String(row?.title || row?.task || row?.content || "").trim();
1810
+ if (title.length < 6) return null;
1811
+ const rawId = String(row?.id || row?.task_id || row?.taskId || `task-${index + 1}`).trim();
1812
+ const id = rawId || `task-${index + 1}`;
1813
+ const dependencySource = Array.isArray(row?.depends_on_task_ids)
1814
+ ? row.depends_on_task_ids
1815
+ : Array.isArray(row?.dependsOnTaskIds)
1816
+ ? row.dependsOnTaskIds
1817
+ : Array.isArray(row?.dependsOn)
1818
+ ? row.dependsOn
1819
+ : Array.isArray(row?.dependencies)
1820
+ ? row.dependencies
1821
+ : [];
1822
+ const dependsOnTaskIds = dependencySource
1823
+ .map((dep) => String(dep || "").trim())
1824
+ .filter(Boolean);
1825
+ const outputTarget = normalizeOutputTarget(
1826
+ row?.output_target || row?.outputTarget || row?.artifact || row?.target_file || null
1827
+ );
1828
+ return {
1829
+ id,
1830
+ title,
1831
+ dependsOnTaskIds,
1832
+ outputTarget,
1833
+ taskKind: normalizeTaskKind(row?.task_kind || row?.taskKind || row?.kind, outputTarget),
1834
+ };
1835
+ })
1836
+ .filter(Boolean)
1837
+ .slice(0, normalizedMax);
1838
+ const withUniqueIds = [];
1839
+ const idCounts = new Map();
1840
+ for (const row of provisional) {
1841
+ const base = String(row?.id || "task").trim() || "task";
1842
+ const count = Number(idCounts.get(base) || 0) + 1;
1843
+ idCounts.set(base, count);
1844
+ const uniqueId = count > 1 ? `${base}-${count}` : base;
1845
+ withUniqueIds.push({
1846
+ id: uniqueId,
1847
+ title: String(row?.title || "").trim(),
1848
+ dependsOnTaskIds: Array.isArray(row?.dependsOnTaskIds) ? row.dependsOnTaskIds : [],
1849
+ outputTarget: row?.outputTarget || null,
1850
+ taskKind: String(row?.taskKind || "").trim() || "inspection",
1133
1851
  });
1852
+ }
1853
+ const knownIds = new Set(withUniqueIds.map((row) => row.id));
1854
+ return withUniqueIds.map((row, index) => {
1855
+ let dependsOnTaskIds = (Array.isArray(row?.dependsOnTaskIds) ? row.dependsOnTaskIds : [])
1856
+ .map((dep) => String(dep || "").trim())
1857
+ .filter((dep) => dep && dep !== row.id && knownIds.has(dep));
1858
+ if (!dependsOnTaskIds.length && linearFallback && index > 0) {
1859
+ dependsOnTaskIds = [withUniqueIds[index - 1].id];
1860
+ }
1861
+ return {
1862
+ id: row.id,
1863
+ title: row.title,
1864
+ dependsOnTaskIds,
1865
+ outputTarget: row?.outputTarget || null,
1866
+ taskKind: String(row?.taskKind || "").trim() || "inspection",
1867
+ };
1134
1868
  });
1135
1869
  }
1136
1870
 
1137
- async function handleSwarmApi(req, res, session) {
1138
- const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
1139
-
1140
- if (pathname === "/api/swarm/status" && req.method === "GET") {
1141
- sendJson(res, 200, {
1142
- ok: true,
1143
- status: swarmState.status,
1144
- objective: swarmState.objective,
1145
- workspaceRoot: swarmState.workspaceRoot,
1146
- maxTasks: swarmState.maxTasks,
1147
- startedAt: swarmState.startedAt,
1148
- stoppedAt: swarmState.stoppedAt,
1149
- localEngine: isLocalEngineUrl(ENGINE_URL),
1150
- lastError: swarmState.lastError || null,
1151
- });
1152
- return true;
1871
+ function inferOutputTargetFromText(text) {
1872
+ const source = String(text || "").trim();
1873
+ if (!source) return null;
1874
+ const backtickMatch = source.match(/`([^`\n]+?\.[A-Za-z0-9_-]{1,12})`/);
1875
+ if (backtickMatch?.[1]) {
1876
+ return {
1877
+ path: backtickMatch[1].trim(),
1878
+ kind: "artifact",
1879
+ operation: "create_or_update",
1880
+ };
1153
1881
  }
1882
+ const saveAsMatch = source.match(/\bsave as\s+([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]{1,12})\b/i);
1883
+ if (saveAsMatch?.[1]) {
1884
+ return {
1885
+ path: saveAsMatch[1].trim(),
1886
+ kind: "artifact",
1887
+ operation: "create_or_update",
1888
+ };
1889
+ }
1890
+ return null;
1891
+ }
1154
1892
 
1155
- if (pathname === "/api/swarm/start" && req.method === "POST") {
1893
+ function ensurePlannerTaskOutputTargets(tasks, objective) {
1894
+ const list = Array.isArray(tasks) ? tasks : [];
1895
+ return list.map((task) => {
1896
+ const taskKind = String(task?.taskKind || "").trim().toLowerCase() || "inspection";
1897
+ const existing = task?.outputTarget && typeof task.outputTarget === "object" ? task.outputTarget : null;
1898
+ return {
1899
+ ...task,
1900
+ taskKind,
1901
+ outputTarget: existing || null,
1902
+ };
1903
+ });
1904
+ }
1905
+
1906
+ function validateStrictPlannerTasks(tasks) {
1907
+ const list = Array.isArray(tasks) ? tasks : [];
1908
+ const invalidTaskKinds = list
1909
+ .filter((task) => !["implementation", "inspection", "research", "validation"].includes(String(task?.taskKind || "").trim().toLowerCase()))
1910
+ .map((task) => String(task?.id || task?.title || "task").trim())
1911
+ .filter(Boolean);
1912
+ const missing = list
1913
+ .filter((task) => {
1914
+ const taskKind = String(task?.taskKind || "").trim().toLowerCase();
1915
+ return taskKind === "implementation" && !String(task?.outputTarget?.path || "").trim();
1916
+ })
1917
+ .map((task) => String(task?.id || task?.title || "task").trim())
1918
+ .filter(Boolean);
1919
+ return {
1920
+ ok: missing.length === 0 && invalidTaskKinds.length === 0,
1921
+ missing,
1922
+ invalidTaskKinds,
1923
+ };
1924
+ }
1925
+
1926
+ function parsePlanTasksFromAssistant(assistantText, maxTasks = 8, options = {}) {
1927
+ const normalizedMax = Math.max(1, Number(maxTasks) || 8);
1928
+ const allowTextFallback = options?.allowTextFallback !== false;
1929
+ const fencedJson = String(assistantText || "").match(/```(?:json)?\s*([\s\S]*?)```/i);
1930
+ const candidateJson = (fencedJson?.[1] || String(assistantText || "")).trim();
1931
+ const parsedTodos = (() => {
1156
1932
  try {
1157
- const body = await readJsonBody(req);
1158
- startSwarm(session, body || {});
1159
- sendJson(res, 200, { ok: true });
1160
- } catch (e) {
1161
- sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) });
1933
+ const payload = JSON.parse(candidateJson);
1934
+ if (Array.isArray(payload)) return payload;
1935
+ if (Array.isArray(payload?.tasks)) return payload.tasks;
1936
+ if (Array.isArray(payload?.plan)) return payload.plan;
1937
+ if (Array.isArray(payload?.steps)) return payload.steps;
1938
+ if (Array.isArray(payload?.items)) return payload.items;
1939
+ return [];
1940
+ } catch {
1941
+ return [];
1162
1942
  }
1163
- return true;
1164
- }
1943
+ })();
1944
+ const fromJson = normalizePlannerTasks(parsedTodos, normalizedMax, { linearFallback: false });
1945
+ if (fromJson.length) return fromJson;
1946
+ if (!allowTextFallback) return [];
1947
+ const fromText = String(assistantText || "")
1948
+ .split(/\r?\n/)
1949
+ .map((line) => line.replace(/^[-*#\d\.\)\[\]\s]+/, "").trim())
1950
+ .filter((line) => line.length >= 6)
1951
+ .slice(0, normalizedMax);
1952
+ return normalizePlannerTasks(fromText, normalizedMax, { linearFallback: true });
1953
+ }
1165
1954
 
1166
- if (pathname === "/api/swarm/stop" && req.method === "POST") {
1167
- stopSwarm();
1168
- sendJson(res, 200, { ok: true, status: swarmState.status });
1169
- return true;
1955
+ function extractPlannerFailureText(text) {
1956
+ const value = String(text || "").trim();
1957
+ if (!value) return "";
1958
+ const firstLine = value.split(/\r?\n/, 1)[0] || "";
1959
+ const normalized = firstLine.toUpperCase();
1960
+ if (normalized.startsWith("ENGINE_ERROR:")) return value;
1961
+ if (
1962
+ normalized.includes("AUTHENTICATION_ERROR") ||
1963
+ normalized.includes("RATE_LIMIT_EXCEEDED") ||
1964
+ normalized.includes("CONTEXT_LENGTH_EXCEEDED") ||
1965
+ normalized.includes("PROVIDER_SERVER_ERROR") ||
1966
+ normalized.includes("PROVIDER_REQUEST_FAILED")
1967
+ ) {
1968
+ return value;
1170
1969
  }
1970
+ if (/key limit exceeded|403 forbidden|monthly limit|rate limit/i.test(value)) {
1971
+ return value;
1972
+ }
1973
+ return "";
1974
+ }
1171
1975
 
1172
- if (pathname === "/api/swarm/snapshot" && req.method === "GET") {
1173
- const registry = await readSwarmRegistry(session.token);
1174
- sendJson(res, 200, {
1175
- ok: true,
1176
- status: swarmState.status,
1177
- registry,
1178
- logs: swarmState.logs.slice(-300),
1179
- reasons: swarmState.reasons.slice(-250),
1180
- startedAt: swarmState.startedAt,
1181
- stoppedAt: swarmState.stoppedAt,
1182
- lastError: swarmState.lastError || null,
1183
- localEngine: isLocalEngineUrl(ENGINE_URL),
1184
- });
1185
- return true;
1976
+ function fallbackPlannerTasks(objective, maxTasks = 8, assistantText = "") {
1977
+ const fromAssistantText = parsePlanTasksFromAssistant(assistantText, maxTasks, {
1978
+ allowTextFallback: true,
1979
+ });
1980
+ if (fromAssistantText.length) {
1981
+ return {
1982
+ source: "llm_text_recovery",
1983
+ note: "Recovered planner tasks from non-JSON assistant text.",
1984
+ tasks: fromAssistantText,
1985
+ };
1986
+ }
1987
+ const fromObjective = normalizePlannerTasks(parseObjectiveTodos(objective, maxTasks), maxTasks, {
1988
+ linearFallback: true,
1989
+ });
1990
+ if (fromObjective.length) {
1991
+ return {
1992
+ source: "local_objective_parser",
1993
+ note: "Synthesized planner tasks from the objective after planner failure.",
1994
+ tasks: fromObjective,
1995
+ };
1186
1996
  }
1997
+ return {
1998
+ source: "local_single_task_fallback",
1999
+ note: "Synthesized a single fallback task after planner failure.",
2000
+ tasks: normalizePlannerTasks(["Execute requested objective"], 1, {
2001
+ linearFallback: false,
2002
+ }),
2003
+ };
2004
+ }
1187
2005
 
1188
- if (pathname === "/api/swarm/events" && req.method === "GET") {
1189
- res.writeHead(200, {
1190
- "content-type": "text/event-stream",
1191
- "cache-control": "no-cache",
1192
- connection: "keep-alive",
1193
- });
1194
- res.write(`data: ${JSON.stringify({ kind: "hello", ts: Date.now(), status: swarmState.status })}\n\n`);
1195
- swarmSseClients.add(res);
1196
- req.on("close", () => swarmSseClients.delete(res));
1197
- return true;
2006
+ async function generatePlanTodosWithLLM(session, run, maxTasks) {
2007
+ const runId = String(run?.run_id || "").trim();
2008
+ if (!runId) throw new Error("Missing run id for plan generation.");
2009
+ const sessionId = await createExecutionSession(session, run);
2010
+ const prompt = [
2011
+ "You are planning a swarm run.",
2012
+ "",
2013
+ `Objective: ${String(run?.objective || "").trim()}`,
2014
+ `Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
2015
+ "",
2016
+ `Generate ${Math.max(1, Number(maxTasks) || 3)} concise, execution-ready tasks.`,
2017
+ "If the objective requires creating or updating a single file, prefer ONE implementation task that creates the complete file.",
2018
+ "Do not split creation and refinement of the same artifact into separate dependent tasks.",
2019
+ "Inspection tasks should only exist when the workspace has existing files that must be understood first.",
2020
+ "For greenfield or nearly empty workspaces, skip inspection and go straight to implementation.",
2021
+ "Return strict JSON only in this shape:",
2022
+ '{"tasks":[{"id":"task-1","title":"...","task_kind":"inspection","depends_on_task_ids":[]},{"id":"task-2","title":"...","task_kind":"implementation","depends_on_task_ids":["task-1"],"output_target":{"path":"relative/path.ext","kind":"source|spec|config|document|test|asset","operation":"create|update|create_or_update"}}]}',
2023
+ "Use depends_on_task_ids only when a task requires outputs from another task.",
2024
+ "Independent tasks must have an empty depends_on_task_ids array.",
2025
+ "Every task must include task_kind: implementation, inspection, research, or validation.",
2026
+ "Only implementation tasks must include output_target.path naming the concrete workspace file or artifact to create or update.",
2027
+ "Inspection/research tasks are read-only and should not require file writes.",
2028
+ "Keep output_target generic and file-type agnostic: Python, JS, HTML, Markdown, TOML, YAML, text, tests, and assets are all valid.",
2029
+ "Do not include explanations.",
2030
+ ].join("\n");
2031
+ const promptResponse = await engineRequestJson(
2032
+ session,
2033
+ `/session/${encodeURIComponent(sessionId)}/prompt_sync`,
2034
+ {
2035
+ method: "POST",
2036
+ timeoutMs: 3 * 60 * 1000,
2037
+ body: {
2038
+ parts: [{ type: "text", text: prompt }],
2039
+ },
2040
+ }
2041
+ );
2042
+ const syncRows = Array.isArray(promptResponse) ? promptResponse : [];
2043
+ const fromSync = extractAssistantText(syncRows);
2044
+ const syncFailure = extractPlannerFailureText(fromSync);
2045
+ if (syncFailure) {
2046
+ const error = new Error(syncFailure);
2047
+ error.sessionId = sessionId;
2048
+ throw error;
2049
+ }
2050
+ if (fromSync) {
2051
+ return {
2052
+ sessionId,
2053
+ tasks: parsePlanTasksFromAssistant(fromSync, maxTasks, { allowTextFallback: false }),
2054
+ assistantText: fromSync,
2055
+ };
1198
2056
  }
2057
+ const sessionSnapshot = await engineRequestJson(
2058
+ session,
2059
+ `/session/${encodeURIComponent(sessionId)}`
2060
+ ).catch(() => null);
2061
+ const messages = Array.isArray(sessionSnapshot?.messages) ? sessionSnapshot.messages : [];
2062
+ const fromSnapshot = extractAssistantText(messages);
2063
+ const snapshotFailure = extractPlannerFailureText(fromSnapshot);
2064
+ if (snapshotFailure) {
2065
+ const error = new Error(snapshotFailure);
2066
+ error.sessionId = sessionId;
2067
+ throw error;
2068
+ }
2069
+ return {
2070
+ sessionId,
2071
+ tasks: parsePlanTasksFromAssistant(fromSnapshot, maxTasks, { allowTextFallback: false }),
2072
+ assistantText: fromSnapshot,
2073
+ };
2074
+ }
1199
2075
 
1200
- return false;
2076
+ function isRunTerminal(status) {
2077
+ const normalized = String(status || "")
2078
+ .trim()
2079
+ .toLowerCase();
2080
+ return ["completed", "failed", "cancelled"].includes(normalized);
1201
2081
  }
1202
2082
 
2083
+ async function seedContextRunSteps(session, runId, objective) {
2084
+ const todoRows = parseObjectiveTodos(objective, 8).map((content, idx) => ({
2085
+ id: `step-${idx + 1}`,
2086
+ content,
2087
+ status: "pending",
2088
+ }));
2089
+ await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
2090
+ method: "POST",
2091
+ body: {
2092
+ replace: true,
2093
+ todos: todoRows,
2094
+ source_session_id: null,
2095
+ source_run_id: runId,
2096
+ },
2097
+ });
2098
+ }
2099
+
2100
+ async function seedContextRunStepsFromTitles(session, runId, titles = []) {
2101
+ const todoRows = (Array.isArray(titles) ? titles : [])
2102
+ .map((row) => String(row || "").trim())
2103
+ .filter((row) => row.length >= 3)
2104
+ .map((content, idx) => ({
2105
+ id: `step-${idx + 1}`,
2106
+ content,
2107
+ status: "pending",
2108
+ }));
2109
+ if (!todoRows.length) {
2110
+ throw new Error("No valid todo rows for context step seeding.");
2111
+ }
2112
+ await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
2113
+ method: "POST",
2114
+ body: {
2115
+ replace: true,
2116
+ todos: todoRows,
2117
+ source_session_id: null,
2118
+ source_run_id: runId,
2119
+ },
2120
+ });
2121
+ return todoRows.length;
2122
+ }
2123
+
2124
+ async function createExecutionSession(session, run) {
2125
+ const runId = String(run?.run_id || "").trim();
2126
+ const controller = getSwarmRunController(runId);
2127
+ const workspaceCandidates = [
2128
+ run?.workspace?.canonical_path,
2129
+ run?.workspace?.workspace_root,
2130
+ run?.workspace_root,
2131
+ controller?.workspaceRoot,
2132
+ controller?.repoRoot,
2133
+ swarmState.workspaceRoot,
2134
+ swarmState.repoRoot,
2135
+ REPO_ROOT,
2136
+ ];
2137
+ const workspaceRootRaw = workspaceCandidates
2138
+ .map((value) => String(value || "").trim())
2139
+ .find((value) => value.length > 0);
2140
+ const workspaceRoot = await workspaceExistsAsDirectory(workspaceRootRaw || REPO_ROOT);
2141
+ if (!workspaceRoot) {
2142
+ throw new Error(
2143
+ `Workspace root does not exist or is not a directory: ${resolve(String(workspaceRootRaw || REPO_ROOT))}`
2144
+ );
2145
+ }
2146
+ const resolved = await resolveExecutionModel(session, run);
2147
+ const modelProvider = resolved.provider;
2148
+ const modelId = resolved.model;
2149
+ upsertSwarmRunController(runId, {
2150
+ resolvedModelProvider: modelProvider,
2151
+ resolvedModelId: modelId,
2152
+ modelResolutionSource: resolved.source,
2153
+ modelProvider,
2154
+ modelId,
2155
+ });
2156
+ const payload = await engineRequestJson(session, "/session", {
2157
+ method: "POST",
2158
+ body: {
2159
+ title: `Swarm ${String(run?.run_id || "").trim()}`,
2160
+ directory: workspaceRoot,
2161
+ workspace_root: workspaceRoot,
2162
+ permission: [
2163
+ { permission: "write", pattern: "*", action: "allow" },
2164
+ { permission: "edit", pattern: "*", action: "allow" },
2165
+ { permission: "apply_patch", pattern: "*", action: "allow" },
2166
+ { permission: "read", pattern: "*", action: "allow" },
2167
+ { permission: "glob", pattern: "*", action: "allow" },
2168
+ { permission: "search", pattern: "*", action: "allow" },
2169
+ { permission: "grep", pattern: "*", action: "allow" },
2170
+ { permission: "codesearch", pattern: "*", action: "allow" },
2171
+ { permission: "ls", pattern: "*", action: "allow" },
2172
+ { permission: "list", pattern: "*", action: "allow" },
2173
+ { permission: "todowrite", pattern: "*", action: "allow" },
2174
+ { permission: "todo_write", pattern: "*", action: "allow" },
2175
+ { permission: "update_todo_list", pattern: "*", action: "allow" },
2176
+ { permission: "websearch", pattern: "*", action: "allow" },
2177
+ { permission: "webfetch", pattern: "*", action: "allow" },
2178
+ { permission: "webfetch_html", pattern: "*", action: "allow" },
2179
+ { permission: "bash", pattern: "*", action: "deny" },
2180
+ { permission: "task", pattern: "*", action: "deny" },
2181
+ { permission: "spawn_agent", pattern: "*", action: "deny" },
2182
+ { permission: "batch", pattern: "*", action: "deny" },
2183
+ ],
2184
+ provider: modelProvider || undefined,
2185
+ model:
2186
+ modelProvider && modelId
2187
+ ? {
2188
+ providerID: modelProvider,
2189
+ modelID: modelId,
2190
+ }
2191
+ : undefined,
2192
+ },
2193
+ });
2194
+ return String(payload?.id || "").trim();
2195
+ }
2196
+
2197
+ function workerExecutionToolAllowlist() {
2198
+ return [
2199
+ "ls",
2200
+ "list",
2201
+ "glob",
2202
+ "search",
2203
+ "grep",
2204
+ "codesearch",
2205
+ "read",
2206
+ "write",
2207
+ "edit",
2208
+ "apply_patch",
2209
+ ];
2210
+ }
2211
+
2212
+ function workerWriteRetryToolAllowlist() {
2213
+ return ["write", "edit", "apply_patch"];
2214
+ }
2215
+
2216
+ function strictWriteRetryEnabled() {
2217
+ const raw = String(process.env.TANDEM_STRICT_WRITE_RETRY_ENABLED || "").trim().toLowerCase();
2218
+ if (!raw) return true;
2219
+ return !["0", "false", "no", "off"].includes(raw);
2220
+ }
2221
+
2222
+ function strictWriteRetryMaxAttempts() {
2223
+ const parsed = Number.parseInt(
2224
+ String(process.env.TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS || "3"),
2225
+ 10
2226
+ );
2227
+ return Number.isFinite(parsed) ? Math.max(1, parsed) : 3;
2228
+ }
2229
+
2230
+ function nonWritingRetryMaxAttempts() {
2231
+ const parsed = Number.parseInt(
2232
+ String(process.env.TANDEM_NON_WRITING_RETRY_MAX_ATTEMPTS || "2"),
2233
+ 10
2234
+ );
2235
+ return Number.isFinite(parsed) ? Math.max(1, parsed) : 2;
2236
+ }
2237
+
2238
+ function classifyStrictWriteFailureReason(verification) {
2239
+ const reason = String(verification?.reason || "").trim().toUpperCase();
2240
+ if (!reason || reason === "VERIFIED") return "";
2241
+ if (
2242
+ reason === "WRITE_ARGS_EMPTY_FROM_PROVIDER" ||
2243
+ reason === "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER" ||
2244
+ reason === "FILE_PATH_MISSING" ||
2245
+ reason === "WRITE_CONTENT_MISSING"
2246
+ ) {
2247
+ return "malformed_write_args";
2248
+ }
2249
+ if (
2250
+ reason === "NO_WRITE_ACTIVITY_NO_WORKSPACE_CHANGE" ||
2251
+ reason === "WRITE_REQUIRED_NOT_SATISFIED" ||
2252
+ reason === "WRITE_TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE"
2253
+ ) {
2254
+ return "write_required_unsatisfied";
2255
+ }
2256
+ if (
2257
+ reason === "TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE" ||
2258
+ reason === "NO_TOOL_ACTIVITY_NO_WORKSPACE_CHANGE"
2259
+ ) {
2260
+ return "no_workspace_change";
2261
+ }
2262
+ return "";
2263
+ }
2264
+
2265
+ function buildStrictWriteRetryRequest(prompt, verification, attemptIndex, maxAttempts) {
2266
+ const failureClass = classifyStrictWriteFailureReason(verification);
2267
+ const needsInspection =
2268
+ Number(verification?.total_tool_calls || 0) === 0 &&
2269
+ Number(verification?.rejected_tool_calls || 0) === 0;
2270
+ const recoveryLines = [
2271
+ prompt,
2272
+ "",
2273
+ "[Strict Write Recovery]",
2274
+ `Attempt ${attemptIndex}/${maxAttempts} failed with ${failureClass || "strict_write_failure"}.`,
2275
+ "- You must satisfy strict write mode on this retry.",
2276
+ '- Valid write shape: {"path":"target-file","content":"full file contents"}',
2277
+ "- For file tools, include a non-empty `path` string.",
2278
+ "- For `write`, include a non-empty `content` string.",
2279
+ "- Do not use bash.",
2280
+ ];
2281
+ if (needsInspection) {
2282
+ recoveryLines.push("- Use tools on this retry.");
2283
+ recoveryLines.push("- Inspect minimally with ls/list/glob/search/read if needed.");
2284
+ recoveryLines.push("- Then create or modify the required file in the same turn.");
2285
+ } else {
2286
+ recoveryLines.push("- Do not inspect further with read/search/glob/ls/list.");
2287
+ recoveryLines.push("- Create or modify the required target directly with write/edit/apply_patch.");
2288
+ }
2289
+ return {
2290
+ parts: [{ type: "text", text: recoveryLines.join("\n") }],
2291
+ tool_mode: "required",
2292
+ tool_allowlist: needsInspection
2293
+ ? workerExecutionToolAllowlist()
2294
+ : workerWriteRetryToolAllowlist(),
2295
+ write_required: true,
2296
+ };
2297
+ }
2298
+
2299
+ function classifyNonWritingFailureReason(verification) {
2300
+ const reason = String(verification?.reason || "").trim().toUpperCase();
2301
+ if (!reason || reason === "VERIFIED") return "";
2302
+ if (reason === "NO_TOOL_ACTIVITY_NO_DECISION" || reason === "NO_TOOL_ACTIVITY") {
2303
+ return "no_tool_activity";
2304
+ }
2305
+ if (reason === "DECISION_PAYLOAD_MISSING") return "decision_payload_missing";
2306
+ return "non_writing_verification_failed";
2307
+ }
2308
+
2309
+ function buildNonWritingRetryRequest(prompt, verification, attemptIndex, maxAttempts) {
2310
+ const failureClass = classifyNonWritingFailureReason(verification);
2311
+ const hasToolCalls =
2312
+ Number(verification?.total_tool_calls || 0) > 0 ||
2313
+ Number(verification?.rejected_tool_calls || 0) > 0;
2314
+ const recoveryLines = [
2315
+ prompt,
2316
+ "",
2317
+ "[Non-Writing Recovery]",
2318
+ `Attempt ${attemptIndex}/${maxAttempts} failed with ${failureClass || "verification_failed"}.`,
2319
+ "- This task is read-only and must use tools.",
2320
+ "- Allowed tools: ls, list, glob, search, grep, codesearch, read.",
2321
+ "- Do not call write/edit/apply_patch for this task.",
2322
+ '- Return strict JSON only: {"decision":{"summary":"...","evidence":["..."],"output_target":{"path":"...","kind":"artifact","operation":"create_or_update"}}}.',
2323
+ ];
2324
+ if (!hasToolCalls) {
2325
+ recoveryLines.push("- Execute at least one read-only tool call before finalizing.");
2326
+ recoveryLines.push("- Keep tool usage minimal and directly relevant to the task.");
2327
+ } else {
2328
+ recoveryLines.push("- You already used tools; now return the required JSON decision payload.");
2329
+ recoveryLines.push("- Ensure the response is valid JSON and includes decision.summary.");
2330
+ }
2331
+ return {
2332
+ parts: [{ type: "text", text: recoveryLines.join("\n") }],
2333
+ tool_mode: "required",
2334
+ tool_allowlist: ["ls", "list", "glob", "search", "grep", "codesearch", "read"],
2335
+ };
2336
+ }
2337
+
2338
+ const WRITE_TOOL_NAMES = new Set(["write", "edit", "apply_patch"]);
2339
+ const REQUIRED_TOOL_MODE_REASON = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
2340
+ const REQUIRED_TOOL_REASON_PATTERN = new RegExp(
2341
+ `${REQUIRED_TOOL_MODE_REASON}:\\s*([A-Z_]+)\\b`
2342
+ );
2343
+
2344
+ function normalizeVerificationMode(mode) {
2345
+ return String(mode || "")
2346
+ .trim()
2347
+ .toLowerCase() === "lenient"
2348
+ ? "lenient"
2349
+ : "strict";
2350
+ }
2351
+
2352
+ function normalizeToolName(tool) {
2353
+ const raw = String(tool || "")
2354
+ .trim()
2355
+ .toLowerCase();
2356
+ if (!raw) return "";
2357
+ const parts = raw.split(":").filter(Boolean);
2358
+ return parts.length ? parts[parts.length - 1] : raw;
2359
+ }
2360
+
2361
+ function isWriteToolName(tool) {
2362
+ return WRITE_TOOL_NAMES.has(normalizeToolName(tool));
2363
+ }
2364
+
2365
+ function createExecutionError(message, details = {}) {
2366
+ const error = new Error(message);
2367
+ if (details?.sessionId) error.sessionId = String(details.sessionId || "").trim();
2368
+ if (details?.verification && typeof details.verification === "object") {
2369
+ error.verification = details.verification;
2370
+ }
2371
+ return error;
2372
+ }
2373
+
2374
+ function normalizeWorkspaceRelativePath(pathname) {
2375
+ return String(pathname || "")
2376
+ .replace(/\\/g, "/")
2377
+ .replace(/^\.?\//, "")
2378
+ .replace(/^\/+/, "")
2379
+ .trim();
2380
+ }
2381
+
2382
+ function extractRequiredToolFailureReason(text) {
2383
+ const raw = String(text || "").trim();
2384
+ if (!raw) return "";
2385
+ const match = raw.match(REQUIRED_TOOL_REASON_PATTERN);
2386
+ return String(match?.[1] || "").trim().toUpperCase();
2387
+ }
2388
+
2389
+ function shouldTrackWorkspacePath(pathname) {
2390
+ const normalized = normalizeWorkspaceRelativePath(pathname);
2391
+ return (
2392
+ !!normalized &&
2393
+ normalized !== ".git" &&
2394
+ normalized !== ".tandem" &&
2395
+ !normalized.startsWith(".git/") &&
2396
+ !normalized.startsWith(".tandem/") &&
2397
+ !normalized.startsWith(".swarm/") &&
2398
+ !normalized.startsWith("node_modules/") &&
2399
+ !normalized.startsWith("dist/") &&
2400
+ !normalized.startsWith("target/")
2401
+ );
2402
+ }
2403
+
2404
+ async function fingerprintWorkspaceFile(pathname) {
2405
+ const info = await stat(pathname).catch(() => null);
2406
+ if (!info?.isFile()) return null;
2407
+ let contentHash = "";
2408
+ if (Number(info.size || 0) <= 1024 * 1024) {
2409
+ const bytes = await readFile(pathname).catch(() => null);
2410
+ if (bytes) {
2411
+ contentHash = createHash("sha1").update(bytes).digest("hex");
2412
+ }
2413
+ }
2414
+ return {
2415
+ size: Number(info.size || 0),
2416
+ modifiedMs: Number(info.mtimeMs || 0),
2417
+ contentHash,
2418
+ };
2419
+ }
2420
+
2421
+ function parseGitStatusEntries(output) {
2422
+ const chunks = String(output || "").split("\0");
2423
+ const entries = [];
2424
+ for (let index = 0; index < chunks.length; index += 1) {
2425
+ const chunk = String(chunks[index] || "");
2426
+ if (!chunk) continue;
2427
+ const status = chunk.slice(0, 2);
2428
+ const primaryPath = normalizeWorkspaceRelativePath(chunk.slice(3));
2429
+ if (status.startsWith("R") || status.startsWith("C")) {
2430
+ const renamedPath = normalizeWorkspaceRelativePath(chunks[index + 1] || "");
2431
+ if (!renamedPath && !primaryPath) continue;
2432
+ entries.push({
2433
+ status,
2434
+ path: renamedPath || primaryPath,
2435
+ originalPath: primaryPath || "",
2436
+ });
2437
+ index += 1;
2438
+ continue;
2439
+ }
2440
+ if (!primaryPath) continue;
2441
+ entries.push({
2442
+ status,
2443
+ path: primaryPath,
2444
+ originalPath: "",
2445
+ });
2446
+ }
2447
+ return entries;
2448
+ }
2449
+
2450
+ function buildGitStatusIndex(entries) {
2451
+ const statusByPath = Object.create(null);
2452
+ for (const entry of Array.isArray(entries) ? entries : []) {
2453
+ const rawStatus = String(entry?.status || "")
2454
+ .trim()
2455
+ .toUpperCase();
2456
+ let normalized = "M";
2457
+ if (rawStatus === "??") normalized = "A";
2458
+ else if (rawStatus.startsWith("R") || rawStatus.startsWith("C"))
2459
+ normalized = rawStatus.slice(0, 1);
2460
+ else if (rawStatus.includes("D")) normalized = "D";
2461
+ else if (rawStatus.includes("A")) normalized = "A";
2462
+ if (entry?.originalPath && shouldTrackWorkspacePath(entry.originalPath)) {
2463
+ statusByPath[normalizeWorkspaceRelativePath(entry.originalPath)] = "D";
2464
+ }
2465
+ if (entry?.path && shouldTrackWorkspacePath(entry.path)) {
2466
+ statusByPath[normalizeWorkspaceRelativePath(entry.path)] = normalized;
2467
+ }
2468
+ }
2469
+ return statusByPath;
2470
+ }
2471
+
2472
+ function collectWorkspaceSnapshotSeedPaths(includePaths = []) {
2473
+ return Array.from(
2474
+ new Set(
2475
+ (Array.isArray(includePaths) ? includePaths : [])
2476
+ .map((pathname) => normalizeWorkspaceRelativePath(pathname))
2477
+ .filter((pathname) => shouldTrackWorkspacePath(pathname))
2478
+ )
2479
+ ).sort((a, b) => a.localeCompare(b));
2480
+ }
2481
+
2482
+ async function captureGitWorkspaceSnapshot(workspaceRoot, includePaths = []) {
2483
+ const repo = await isGitRepo(workspaceRoot);
2484
+ if (!repo?.ok || !repo.root) return null;
2485
+ const gitStatus = await runGit(
2486
+ ["-C", repo.root, "status", "--porcelain=v1", "--untracked-files=all", "-z"],
2487
+ {
2488
+ stdio: "pipe",
2489
+ safeDirectory: repo.root,
2490
+ }
2491
+ ).catch(() => null);
2492
+ if (!gitStatus) return null;
2493
+ const entries = parseGitStatusEntries(gitStatus.stdout || "");
2494
+ const files = Object.create(null);
2495
+ const candidatePaths = collectWorkspaceSnapshotSeedPaths([
2496
+ ...entries.flatMap((entry) => [entry?.path || "", entry?.originalPath || ""]),
2497
+ ...includePaths,
2498
+ ]);
2499
+ for (const relativePath of candidatePaths) {
2500
+ const fingerprint = await fingerprintWorkspaceFile(join(repo.root, relativePath));
2501
+ if (fingerprint) files[relativePath] = fingerprint;
2502
+ }
2503
+ return {
2504
+ mode: "git_status",
2505
+ root: repo.root,
2506
+ files,
2507
+ statusByPath: buildGitStatusIndex(entries),
2508
+ };
2509
+ }
2510
+
2511
+ async function captureWorkspaceSnapshot(workspaceRoot, options = {}) {
2512
+ const root = await workspaceExistsAsDirectory(workspaceRoot);
2513
+ if (!root) {
2514
+ throw new Error(
2515
+ `Workspace snapshot root not found: ${resolve(String(workspaceRoot || REPO_ROOT))}`
2516
+ );
2517
+ }
2518
+ const includePaths = collectWorkspaceSnapshotSeedPaths(options?.includePaths);
2519
+ const gitSnapshot = await captureGitWorkspaceSnapshot(root, includePaths);
2520
+ if (gitSnapshot) return gitSnapshot;
2521
+ const files = Object.create(null);
2522
+ async function walk(dirPath) {
2523
+ const entries = await readdir(dirPath, { withFileTypes: true }).catch(() => []);
2524
+ for (const entry of entries) {
2525
+ const absolutePath = join(dirPath, entry.name);
2526
+ const relativePath = normalizeWorkspaceRelativePath(relative(root, absolutePath));
2527
+ if (!shouldTrackWorkspacePath(relativePath)) continue;
2528
+ if (entry.isDirectory()) {
2529
+ await walk(absolutePath);
2530
+ continue;
2531
+ }
2532
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
2533
+ const fingerprint = await fingerprintWorkspaceFile(absolutePath);
2534
+ if (fingerprint) files[relativePath] = fingerprint;
2535
+ }
2536
+ }
2537
+ await walk(root);
2538
+ return {
2539
+ mode: "filesystem_fingerprint",
2540
+ root,
2541
+ files,
2542
+ statusByPath: Object.create(null),
2543
+ };
2544
+ }
2545
+
2546
+ function summarizeWorkspaceChanges(beforeSnapshot, afterSnapshot) {
2547
+ const beforeFiles =
2548
+ beforeSnapshot?.files && typeof beforeSnapshot.files === "object" ? beforeSnapshot.files : {};
2549
+ const afterFiles =
2550
+ afterSnapshot?.files && typeof afterSnapshot.files === "object" ? afterSnapshot.files : {};
2551
+ const beforeStatusByPath =
2552
+ beforeSnapshot?.statusByPath && typeof beforeSnapshot.statusByPath === "object"
2553
+ ? beforeSnapshot.statusByPath
2554
+ : {};
2555
+ const afterStatusByPath =
2556
+ afterSnapshot?.statusByPath && typeof afterSnapshot.statusByPath === "object"
2557
+ ? afterSnapshot.statusByPath
2558
+ : {};
2559
+ const created = [];
2560
+ const updated = [];
2561
+ const deleted = [];
2562
+ const allPaths = new Set([
2563
+ ...Object.keys(beforeFiles),
2564
+ ...Object.keys(afterFiles),
2565
+ ...Object.keys(beforeStatusByPath),
2566
+ ...Object.keys(afterStatusByPath),
2567
+ ]);
2568
+ for (const pathname of Array.from(allPaths)) {
2569
+ const beforeFingerprint = beforeFiles[pathname];
2570
+ const afterFingerprint = afterFiles[pathname];
2571
+ const afterStatus = String(afterStatusByPath[pathname] || "")
2572
+ .trim()
2573
+ .toUpperCase();
2574
+ const beforeStatus = String(beforeStatusByPath[pathname] || "")
2575
+ .trim()
2576
+ .toUpperCase();
2577
+ if (!beforeFingerprint && !afterFingerprint) {
2578
+ if (afterStatus === "D") deleted.push(pathname);
2579
+ else if (afterStatus || beforeStatus) updated.push(pathname);
2580
+ continue;
2581
+ }
2582
+ if (!beforeFingerprint && afterFingerprint) {
2583
+ if (afterStatus === "A" || afterStatus === "C" || afterStatus === "R") created.push(pathname);
2584
+ else updated.push(pathname);
2585
+ continue;
2586
+ }
2587
+ if (beforeFingerprint && !afterFingerprint) {
2588
+ deleted.push(pathname);
2589
+ continue;
2590
+ }
2591
+ if (
2592
+ Number(beforeFingerprint?.size || 0) !== Number(afterFingerprint?.size || 0) ||
2593
+ Number(beforeFingerprint?.modifiedMs || 0) !== Number(afterFingerprint?.modifiedMs || 0) ||
2594
+ String(beforeFingerprint?.contentHash || "") !== String(afterFingerprint?.contentHash || "")
2595
+ ) {
2596
+ updated.push(pathname);
2597
+ }
2598
+ }
2599
+ created.sort((a, b) => a.localeCompare(b));
2600
+ updated.sort((a, b) => a.localeCompare(b));
2601
+ deleted.sort((a, b) => a.localeCompare(b));
2602
+ const lines = [
2603
+ `Workspace changes: ${created.length} created, ${updated.length} updated, ${deleted.length} deleted`,
2604
+ ...created.slice(0, 20).map((pathname) => `+ ${pathname}`),
2605
+ ...updated.slice(0, 40).map((pathname) => `~ ${pathname}`),
2606
+ ...deleted.slice(0, 20).map((pathname) => `- ${pathname}`),
2607
+ ];
2608
+ return {
2609
+ mode: String(afterSnapshot?.mode || beforeSnapshot?.mode || "unknown"),
2610
+ hasChanges: created.length > 0 || updated.length > 0 || deleted.length > 0,
2611
+ created,
2612
+ updated,
2613
+ deleted,
2614
+ paths: [...created, ...updated, ...deleted],
2615
+ summary: lines.join("\n"),
2616
+ };
2617
+ }
2618
+
2619
+ function emptyToolActivityAudit(source = "") {
2620
+ return {
2621
+ source: source || "",
2622
+ totalToolCalls: 0,
2623
+ writeToolCalls: 0,
2624
+ rejectedToolCalls: 0,
2625
+ rejectedWriteToolCalls: 0,
2626
+ toolNames: [],
2627
+ rejectedToolNames: [],
2628
+ rejectionReasons: [],
2629
+ };
2630
+ }
2631
+
2632
+ function extractToolFailureSignalsFromText(text) {
2633
+ const raw = String(text || "").trim();
2634
+ if (!raw) return [];
2635
+ const failures = [];
2636
+ const knownReasons = [
2637
+ "FILE_PATH_MISSING",
2638
+ "WRITE_CONTENT_MISSING",
2639
+ "WRITE_ARGS_EMPTY_FROM_PROVIDER",
2640
+ "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
2641
+ "WEBFETCH_URL_MISSING",
2642
+ "WEBSEARCH_QUERY_MISSING",
2643
+ "BASH_COMMAND_MISSING",
2644
+ "PACK_BUILDER_PLAN_ID_MISSING",
2645
+ "PACK_BUILDER_GOAL_MISSING",
2646
+ "TOOL_ARGUMENTS_MISSING",
2647
+ ];
2648
+ for (const reason of knownReasons) {
2649
+ if (raw.includes(reason)) failures.push({ tool: "", reason });
2650
+ }
2651
+ for (const match of raw.matchAll(/Permission denied for tool `([^`]+)`/g)) {
2652
+ failures.push({ tool: match[1], reason: "PERMISSION_DENIED" });
2653
+ }
2654
+ for (const match of raw.matchAll(/Tool `([^`]+)` is not allowed/g)) {
2655
+ failures.push({ tool: match[1], reason: "TOOL_NOT_ALLOWED" });
2656
+ }
2657
+ for (const tool of ["read", "write", "edit", "apply_patch", "glob", "list", "ls"]) {
2658
+ const failedPattern = new RegExp(`(?:\`|\\b)${tool}(?:\`|\\b)[^\\n.]{0,120}\\bfailed\\b`, "i");
2659
+ if (failedPattern.test(raw)) {
2660
+ failures.push({ tool, reason: "TOOL_CALL_FAILED" });
2661
+ }
2662
+ }
2663
+ return failures;
2664
+ }
2665
+
2666
+ function collectToolActivity(rows, source = "") {
2667
+ if (!Array.isArray(rows)) return emptyToolActivityAudit(source);
2668
+ const toolNames = new Set();
2669
+ const rejectedToolNames = new Set();
2670
+ const rejectionReasons = new Set();
2671
+ let totalToolCalls = 0;
2672
+ let writeToolCalls = 0;
2673
+ let rejectedToolCalls = 0;
2674
+ let rejectedWriteToolCalls = 0;
2675
+ const recordTool = (tool, options = {}) => {
2676
+ const normalized = normalizeToolName(tool) || "tool";
2677
+ const rejected = options?.rejected === true;
2678
+ const reason = String(options?.reason || "").trim();
2679
+ if (rejected) {
2680
+ rejectedToolCalls += 1;
2681
+ rejectedToolNames.add(normalized);
2682
+ if (isWriteToolName(normalized)) rejectedWriteToolCalls += 1;
2683
+ if (reason) rejectionReasons.add(reason);
2684
+ return;
2685
+ }
2686
+ totalToolCalls += 1;
2687
+ toolNames.add(normalized);
2688
+ if (isWriteToolName(normalized)) writeToolCalls += 1;
2689
+ };
2690
+ for (const row of rows) {
2691
+ const parts = Array.isArray(row?.parts) ? row.parts : [];
2692
+ let rowRecorded = false;
2693
+ for (const part of parts) {
2694
+ const partType = String(part?.type || part?.part_type || "")
2695
+ .trim()
2696
+ .toLowerCase();
2697
+ const partTool = String(part?.tool || part?.name || "").trim();
2698
+ if (!partType.includes("tool") && !partTool) continue;
2699
+ const rejected =
2700
+ String(part?.state || "")
2701
+ .trim()
2702
+ .toLowerCase() === "failed" || !!String(part?.error || "").trim();
2703
+ recordTool(partTool, {
2704
+ rejected,
2705
+ reason: String(part?.error || "").trim(),
2706
+ });
2707
+ rowRecorded = true;
2708
+ }
2709
+ if (!rowRecorded) {
2710
+ const rowType = String(row?.type || "")
2711
+ .trim()
2712
+ .toLowerCase();
2713
+ const rowTool = String(row?.tool || row?.name || "").trim();
2714
+ if (rowType.includes("tool") || rowTool) {
2715
+ const rejected =
2716
+ String(row?.state || "")
2717
+ .trim()
2718
+ .toLowerCase() === "failed" || !!String(row?.error || "").trim();
2719
+ recordTool(rowTool, {
2720
+ rejected,
2721
+ reason: String(row?.error || "").trim(),
2722
+ });
2723
+ rowRecorded = true;
2724
+ }
2725
+ }
2726
+ if (rowRecorded) continue;
2727
+ for (const failure of extractToolFailureSignalsFromText(textOfMessage(row))) {
2728
+ recordTool(failure.tool, {
2729
+ rejected: true,
2730
+ reason: failure.reason,
2731
+ });
2732
+ }
2733
+ }
2734
+ return {
2735
+ source: source || "",
2736
+ totalToolCalls,
2737
+ writeToolCalls,
2738
+ rejectedToolCalls,
2739
+ rejectedWriteToolCalls,
2740
+ toolNames: Array.from(toolNames).sort((a, b) => a.localeCompare(b)),
2741
+ rejectedToolNames: Array.from(rejectedToolNames).sort((a, b) => a.localeCompare(b)),
2742
+ rejectionReasons: Array.from(rejectionReasons).sort((a, b) => a.localeCompare(b)),
2743
+ };
2744
+ }
2745
+
2746
+ function mergeToolActivityAudits(...audits) {
2747
+ const valid = audits.filter((audit) => audit && typeof audit === "object");
2748
+ const toolNames = new Set();
2749
+ const rejectedToolNames = new Set();
2750
+ const rejectionReasons = new Set();
2751
+ const sources = [];
2752
+ let totalToolCalls = 0;
2753
+ let writeToolCalls = 0;
2754
+ let rejectedToolCalls = 0;
2755
+ let rejectedWriteToolCalls = 0;
2756
+ for (const audit of valid) {
2757
+ totalToolCalls = Math.max(totalToolCalls, Number(audit?.totalToolCalls || 0));
2758
+ writeToolCalls = Math.max(writeToolCalls, Number(audit?.writeToolCalls || 0));
2759
+ rejectedToolCalls = Math.max(rejectedToolCalls, Number(audit?.rejectedToolCalls || 0));
2760
+ rejectedWriteToolCalls = Math.max(
2761
+ rejectedWriteToolCalls,
2762
+ Number(audit?.rejectedWriteToolCalls || 0)
2763
+ );
2764
+ if (
2765
+ audit?.source &&
2766
+ (audit.totalToolCalls ||
2767
+ audit.writeToolCalls ||
2768
+ audit.rejectedToolCalls ||
2769
+ audit.rejectedWriteToolCalls)
2770
+ ) {
2771
+ sources.push(String(audit.source));
2772
+ }
2773
+ for (const tool of Array.isArray(audit?.toolNames) ? audit.toolNames : []) {
2774
+ const normalized = normalizeToolName(tool);
2775
+ if (normalized) toolNames.add(normalized);
2776
+ }
2777
+ for (const tool of Array.isArray(audit?.rejectedToolNames) ? audit.rejectedToolNames : []) {
2778
+ const normalized = normalizeToolName(tool);
2779
+ if (normalized) rejectedToolNames.add(normalized);
2780
+ }
2781
+ for (const reason of Array.isArray(audit?.rejectionReasons) ? audit.rejectionReasons : []) {
2782
+ const normalized = String(reason || "").trim();
2783
+ if (normalized) rejectionReasons.add(normalized);
2784
+ }
2785
+ }
2786
+ return {
2787
+ totalToolCalls,
2788
+ writeToolCalls,
2789
+ rejectedToolCalls,
2790
+ rejectedWriteToolCalls,
2791
+ toolNames: Array.from(toolNames).sort((a, b) => a.localeCompare(b)),
2792
+ rejectedToolNames: Array.from(rejectedToolNames).sort((a, b) => a.localeCompare(b)),
2793
+ rejectionReasons: Array.from(rejectionReasons).sort((a, b) => a.localeCompare(b)),
2794
+ sources: Array.from(new Set(sources)),
2795
+ };
2796
+ }
2797
+
2798
+ function selectProviderWriteFailureReason(reasons) {
2799
+ const list = Array.isArray(reasons) ? reasons : [];
2800
+ if (list.includes("WRITE_ARGS_EMPTY_FROM_PROVIDER")) return "WRITE_ARGS_EMPTY_FROM_PROVIDER";
2801
+ if (list.includes("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER")) {
2802
+ return "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER";
2803
+ }
2804
+ return "";
2805
+ }
2806
+
2807
+ function summarizeExecutionRows(rows, limit = 12) {
2808
+ const list = Array.isArray(rows) ? rows : [];
2809
+ const out = [];
2810
+ for (const row of list) {
2811
+ if (out.length >= limit) break;
2812
+ const role = roleOfMessage(row);
2813
+ const type = String(row?.type || "").trim().toLowerCase();
2814
+ const text = textOfMessage(row).trim();
2815
+ const parts = Array.isArray(row?.parts) ? row.parts : [];
2816
+ const tools = [];
2817
+ for (const part of parts) {
2818
+ const tool = String(part?.tool || part?.name || "").trim();
2819
+ if (tool) tools.push(normalizeToolName(tool) || tool);
2820
+ }
2821
+ const rowTool = String(row?.tool || row?.name || "").trim();
2822
+ if (rowTool) tools.push(normalizeToolName(rowTool) || rowTool);
2823
+ out.push({
2824
+ role,
2825
+ type: type || null,
2826
+ tools: Array.from(new Set(tools)).slice(0, 8),
2827
+ error: String(row?.error || "").trim() || null,
2828
+ excerpt: text.slice(0, 240),
2829
+ });
2830
+ }
2831
+ return out;
2832
+ }
2833
+
2834
+ function buildAttemptTelemetry(name, request, rows, messages, startedAtMs, error = null, meta = {}) {
2835
+ const syncAudit = collectToolActivity(rows, `${name}_prompt_sync`);
2836
+ const sessionAudit = collectToolActivity(messages, `${name}_session_snapshot`);
2837
+ const merged = mergeToolActivityAudits(syncAudit, sessionAudit);
2838
+ const assistantText = extractAssistantText(rows) || extractAssistantText(messages);
2839
+ return {
2840
+ name,
2841
+ attempt_index: Number(meta?.attemptIndex || 0),
2842
+ strict_write_failure_class: String(meta?.failureClass || "").trim() || null,
2843
+ strict_write_retry_remaining: Number(meta?.retryRemaining || 0),
2844
+ started_at_ms: Number(startedAtMs || Date.now()),
2845
+ tool_mode: String(request?.tool_mode || "").trim() || null,
2846
+ tool_allowlist: Array.isArray(request?.tool_allowlist) ? request.tool_allowlist.slice() : [],
2847
+ prompt_excerpt: String(request?.parts?.[0]?.text || "")
2848
+ .trim()
2849
+ .slice(0, 600),
2850
+ assistant_present: !!assistantText.trim(),
2851
+ assistant_excerpt: assistantText.trim().slice(0, 400),
2852
+ any_tool_attempts: merged.totalToolCalls > 0 || merged.rejectedToolCalls > 0,
2853
+ total_tool_calls: merged.totalToolCalls,
2854
+ write_tool_calls: merged.writeToolCalls,
2855
+ rejected_tool_calls: merged.rejectedToolCalls,
2856
+ rejected_write_tool_calls: merged.rejectedWriteToolCalls,
2857
+ tool_names: merged.toolNames,
2858
+ rejected_tool_names: merged.rejectedToolNames,
2859
+ rejection_reasons: merged.rejectionReasons,
2860
+ detection_sources: merged.sources,
2861
+ sync_row_count: Array.isArray(rows) ? rows.length : 0,
2862
+ session_message_count: Array.isArray(messages) ? messages.length : 0,
2863
+ sync_rows: summarizeExecutionRows(rows),
2864
+ session_rows: summarizeExecutionRows(messages),
2865
+ error: error ? String(error?.message || error || "").trim() : "",
2866
+ };
2867
+ }
2868
+
2869
+ function buildVerificationSummary(syncRows, messages, workspaceChanges, sessionId, options = {}) {
2870
+ const syncAudit = collectToolActivity(syncRows, "prompt_sync");
2871
+ const sessionAudit = collectToolActivity(messages, "session_snapshot");
2872
+ const toolAudit = mergeToolActivityAudits(syncAudit, sessionAudit);
2873
+ const assistantText = extractAssistantText(syncRows) || extractAssistantText(messages);
2874
+ const mode = normalizeVerificationMode(options.verificationMode || swarmState.verificationMode);
2875
+ const requiredToolModeUnsatisfied = assistantText.includes(REQUIRED_TOOL_MODE_REASON);
2876
+ const requiredToolFailureReason = extractRequiredToolFailureReason(assistantText);
2877
+ const providerWriteFailureReason = selectProviderWriteFailureReason(toolAudit.rejectionReasons);
2878
+ const workspaceChanged = workspaceChanges?.hasChanges === true;
2879
+ const strictMode = mode === "strict";
2880
+ const passed = strictMode
2881
+ ? workspaceChanged || toolAudit.writeToolCalls > 0
2882
+ : workspaceChanged || toolAudit.totalToolCalls > 0;
2883
+ let reason = "VERIFIED";
2884
+ if (!passed) {
2885
+ if (providerWriteFailureReason) reason = providerWriteFailureReason;
2886
+ else if (requiredToolFailureReason) reason = requiredToolFailureReason;
2887
+ else if (requiredToolModeUnsatisfied) reason = REQUIRED_TOOL_MODE_REASON;
2888
+ else if (toolAudit.rejectedWriteToolCalls > 0)
2889
+ reason = "WRITE_TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE";
2890
+ else if (toolAudit.rejectedToolCalls > 0)
2891
+ reason = "TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE";
2892
+ else if (strictMode && toolAudit.totalToolCalls > 0)
2893
+ reason = "NO_WRITE_ACTIVITY_NO_WORKSPACE_CHANGE";
2894
+ else reason = "NO_TOOL_ACTIVITY_NO_WORKSPACE_CHANGE";
2895
+ }
2896
+ return {
2897
+ mode,
2898
+ reason,
2899
+ passed,
2900
+ session_id: String(sessionId || "").trim() || null,
2901
+ assistant_present: !!assistantText.trim(),
2902
+ assistant_excerpt: assistantText.trim().slice(0, 280),
2903
+ any_tool_attempts: toolAudit.totalToolCalls > 0 || toolAudit.rejectedToolCalls > 0,
2904
+ any_tool_calls: toolAudit.totalToolCalls > 0,
2905
+ total_tool_calls: toolAudit.totalToolCalls,
2906
+ write_tool_calls: toolAudit.writeToolCalls,
2907
+ tool_names: toolAudit.toolNames,
2908
+ rejected_tool_calls: toolAudit.rejectedToolCalls,
2909
+ rejected_write_tool_calls: toolAudit.rejectedWriteToolCalls,
2910
+ rejected_tool_names: toolAudit.rejectedToolNames,
2911
+ rejection_reasons: toolAudit.rejectionReasons,
2912
+ detection_sources: toolAudit.sources,
2913
+ workspace_changed: workspaceChanged,
2914
+ workspace_change_mode: String(workspaceChanges?.mode || "unknown"),
2915
+ workspace_change_paths: Array.isArray(workspaceChanges?.paths)
2916
+ ? workspaceChanges.paths.slice(0, 80)
2917
+ : [],
2918
+ workspace_change_summary: String(workspaceChanges?.summary || "").trim(),
2919
+ execution_trace:
2920
+ options?.executionTrace && typeof options.executionTrace === "object"
2921
+ ? options.executionTrace
2922
+ : undefined,
2923
+ };
2924
+ }
2925
+
2926
+ function hasToolActivity(rows) {
2927
+ if (!Array.isArray(rows)) return false;
2928
+ return rows.some((row) => {
2929
+ const rowType = String(row?.type || "")
2930
+ .trim()
2931
+ .toLowerCase();
2932
+ if (rowType.includes("tool")) return true;
2933
+ const rowTool = String(row?.tool || "")
2934
+ .trim()
2935
+ .toLowerCase();
2936
+ if (rowTool) return true;
2937
+ const parts = Array.isArray(row?.parts) ? row.parts : [];
2938
+ return parts.some((part) => {
2939
+ const partType = String(part?.type || part?.part_type || "")
2940
+ .trim()
2941
+ .toLowerCase();
2942
+ if (partType.includes("tool")) return true;
2943
+ return String(part?.tool || "").trim().length > 0;
2944
+ });
2945
+ });
2946
+ }
2947
+
2948
+ let cachedEngineDefaultModel = {
2949
+ provider: "",
2950
+ model: "",
2951
+ fetchedAtMs: 0,
2952
+ };
2953
+
2954
+ async function fetchEngineDefaultModel(session) {
2955
+ const now = Date.now();
2956
+ if (cachedEngineDefaultModel.fetchedAtMs && now - cachedEngineDefaultModel.fetchedAtMs < 15000) {
2957
+ return { provider: cachedEngineDefaultModel.provider, model: cachedEngineDefaultModel.model };
2958
+ }
2959
+ const payload = await engineRequestJson(session, "/config/providers").catch(() => ({}));
2960
+ const defaultProvider = String(payload?.default || "").trim();
2961
+ const providers =
2962
+ payload?.providers && typeof payload.providers === "object" ? payload.providers : {};
2963
+ const defaultModel = String(providers?.[defaultProvider]?.default_model || "").trim();
2964
+ cachedEngineDefaultModel = {
2965
+ provider: defaultProvider,
2966
+ model: defaultModel,
2967
+ fetchedAtMs: now,
2968
+ };
2969
+ return { provider: defaultProvider, model: defaultModel };
2970
+ }
2971
+
2972
+ async function resolveExecutionModel(session, run) {
2973
+ const runProvider = String(run?.model_provider || "").trim();
2974
+ const runModel = String(run?.model_id || "").trim();
2975
+ if (runProvider && runModel) {
2976
+ return { provider: runProvider, model: runModel, source: "run" };
2977
+ }
2978
+
2979
+ const controller = getSwarmRunController(String(run?.run_id || "").trim());
2980
+ const swarmProvider = String(controller?.modelProvider || swarmState.modelProvider || "").trim();
2981
+ const swarmModel = String(controller?.modelId || swarmState.modelId || "").trim();
2982
+ if (swarmProvider && swarmModel) {
2983
+ return { provider: swarmProvider, model: swarmModel, source: "swarm_state" };
2984
+ }
2985
+
2986
+ const defaults = await fetchEngineDefaultModel(session);
2987
+ if (defaults.provider && defaults.model) {
2988
+ return { provider: defaults.provider, model: defaults.model, source: "engine_default" };
2989
+ }
2990
+
2991
+ throw new Error("MODEL_SELECTION_REQUIRED: no provider/model configured for swarm execution.");
2992
+ }
2993
+
2994
+ function normalizeSessionModelRef(value) {
2995
+ if (typeof value === "string") return value.trim();
2996
+ if (value && typeof value === "object") {
2997
+ const model =
2998
+ String(
2999
+ value?.model_id || value?.id || value?.name || value?.slug || value?.model || ""
3000
+ ).trim();
3001
+ if (model) return model;
3002
+ }
3003
+ return "";
3004
+ }
3005
+
3006
+ function summarizeRunStepsForPrompt(run, currentStepId, limit = 12) {
3007
+ const steps = Array.isArray(run?.steps) ? run.steps : [];
3008
+ return steps
3009
+ .slice(0, Math.max(1, Number(limit) || 12))
3010
+ .map((row, index) => {
3011
+ const stepId = String(row?.step_id || `step-${index + 1}`).trim();
3012
+ const title = String(row?.title || stepId).trim();
3013
+ const status = String(row?.status || "unknown").trim().toLowerCase();
3014
+ const marker = stepId === currentStepId ? "*" : "-";
3015
+ return `${marker} ${stepId} [${status}]: ${title}`;
3016
+ })
3017
+ .join("\n")
3018
+ .trim();
3019
+ }
3020
+
3021
+ function stepPromptText(run, step, stepIndex, totalSteps) {
3022
+ const stepId = String(step?.step_id || "").trim() || `step-${stepIndex + 1}`;
3023
+ const stepTitle = String(step?.title || stepId).trim();
3024
+ const stepDetails =
3025
+ step && typeof step === "object" ? JSON.stringify(step, null, 2).trim() : "";
3026
+ const stepList = summarizeRunStepsForPrompt(run, stepId);
3027
+ return [
3028
+ "Execute this swarm step.",
3029
+ "",
3030
+ `Step (${stepIndex + 1}/${totalSteps}): ${stepTitle}`,
3031
+ `Step ID: ${stepId}`,
3032
+ `Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
3033
+ "",
3034
+ "This step is already planned and assigned.",
3035
+ "Treat the current assigned step as the authority for what to implement.",
3036
+ "Use the original objective and step list only to clarify the assigned step, not to re-plan the run.",
3037
+ "Do not create a new plan, do not restate the task graph, and do not describe future work.",
3038
+ "Use workspace tools to implement this step now.",
3039
+ "",
3040
+ "Current assigned step payload:",
3041
+ stepDetails || "{}",
3042
+ "",
3043
+ "Run step list:",
3044
+ stepList || "(no step list available)",
3045
+ "",
3046
+ `Original objective: ${String(run?.objective || "").trim()}`,
3047
+ "",
3048
+ "Requirements:",
3049
+ "- First inspect the relevant workspace files with read/glob/list/search if needed.",
3050
+ "- Use list/ls/glob for directories. Use read only for concrete file paths.",
3051
+ "- Make the required code/project changes for this step right now.",
3052
+ "- Create or edit files as needed for this step only.",
3053
+ "- If no relevant file exists yet, create the correct new file directly.",
3054
+ "- A write call must include both a path and the full content to write.",
3055
+ "- Use write/edit/apply_patch instead of a prose-only response.",
3056
+ "- Keep scope limited to this step.",
3057
+ "- Return a concise summary of the concrete file changes and blockers.",
3058
+ ].join("\n");
3059
+ }
3060
+
3061
+ function rowsSinceAttemptStart(rows, startIndex = 0) {
3062
+ const list = Array.isArray(rows) ? rows : [];
3063
+ const offset = Math.max(0, Number(startIndex) || 0);
3064
+ if (!offset) return list;
3065
+ return list.length > offset ? list.slice(offset) : list;
3066
+ }
3067
+
3068
+ function parseDecisionPayload(text) {
3069
+ const candidate = String(text || "").trim();
3070
+ if (!candidate) return null;
3071
+ const fencedJson = candidate.match(/```(?:json)?\s*([\s\S]*?)```/i);
3072
+ const raw = (fencedJson?.[1] || candidate).trim();
3073
+ try {
3074
+ const parsed = JSON.parse(raw);
3075
+ return parsed && typeof parsed === "object" && parsed.decision ? parsed : null;
3076
+ } catch {
3077
+ return null;
3078
+ }
3079
+ }
3080
+
3081
+ function buildNonWritingVerificationSummary(syncRows, messages, sessionId, options = {}) {
3082
+ const syncAudit = collectToolActivity(syncRows, "prompt_sync");
3083
+ const sessionAudit = collectToolActivity(messages, "session_snapshot");
3084
+ const toolAudit = mergeToolActivityAudits(syncAudit, sessionAudit);
3085
+ const assistantText = extractAssistantText(syncRows) || extractAssistantText(messages);
3086
+ const decisionPayload = parseDecisionPayload(assistantText);
3087
+ const passed = toolAudit.totalToolCalls > 0 && !!decisionPayload;
3088
+ const hasToolCalls = toolAudit.totalToolCalls > 0;
3089
+ const hasDecisionPayload = !!decisionPayload;
3090
+ let reason = "VERIFIED";
3091
+ if (!passed) {
3092
+ if (!hasToolCalls && !hasDecisionPayload) reason = "NO_TOOL_ACTIVITY_NO_DECISION";
3093
+ else if (!hasToolCalls) reason = "NO_TOOL_ACTIVITY";
3094
+ else reason = "DECISION_PAYLOAD_MISSING";
3095
+ }
3096
+ return {
3097
+ mode: normalizeVerificationMode(options.verificationMode || swarmState.verificationMode),
3098
+ reason,
3099
+ passed,
3100
+ session_id: String(sessionId || "").trim() || null,
3101
+ assistant_present: !!assistantText.trim(),
3102
+ assistant_excerpt: assistantText.trim().slice(0, 280),
3103
+ any_tool_attempts: toolAudit.totalToolCalls > 0 || toolAudit.rejectedToolCalls > 0,
3104
+ any_tool_calls: toolAudit.totalToolCalls > 0,
3105
+ total_tool_calls: toolAudit.totalToolCalls,
3106
+ write_tool_calls: toolAudit.writeToolCalls,
3107
+ tool_names: toolAudit.toolNames,
3108
+ rejected_tool_calls: toolAudit.rejectedToolCalls,
3109
+ rejected_write_tool_calls: toolAudit.rejectedWriteToolCalls,
3110
+ rejected_tool_names: toolAudit.rejectedToolNames,
3111
+ rejection_reasons: toolAudit.rejectionReasons,
3112
+ detection_sources: toolAudit.sources,
3113
+ workspace_changed: false,
3114
+ workspace_change_mode: "not_required",
3115
+ workspace_change_paths: [],
3116
+ workspace_change_summary: "Workspace changes not required for this task kind.",
3117
+ decision_payload: decisionPayload,
3118
+ execution_trace:
3119
+ options?.executionTrace && typeof options.executionTrace === "object"
3120
+ ? options.executionTrace
3121
+ : undefined,
3122
+ };
3123
+ }
3124
+
3125
+ async function runExecutionPromptWithVerification(session, run, prompt, sessionId = "", options = {}) {
3126
+ const activeSessionId =
3127
+ String(sessionId || "").trim() || (await createExecutionSession(session, run));
3128
+ if (!activeSessionId) throw new Error("Failed to create execution session.");
3129
+ const runId = String(run?.run_id || "").trim();
3130
+ const controller = getSwarmRunController(runId);
3131
+ const workspaceRoot = await workspaceExistsAsDirectory(
3132
+ String(
3133
+ run?.workspace?.canonical_path ||
3134
+ run?.workspace_root ||
3135
+ controller?.workspaceRoot ||
3136
+ swarmState.workspaceRoot ||
3137
+ REPO_ROOT
3138
+ ).trim()
3139
+ );
3140
+ let workspaceBefore = null;
3141
+ let workspaceAfter = null;
3142
+ let workspaceChanges = {
3143
+ mode: "unavailable",
3144
+ hasChanges: false,
3145
+ paths: [],
3146
+ summary: "",
3147
+ };
3148
+ const resolvedModel = await resolveExecutionModel(session, run);
3149
+ const writeRequired = options.writeRequired !== false;
3150
+ const maxAttempts = writeRequired
3151
+ ? strictWriteRetryEnabled()
3152
+ ? strictWriteRetryMaxAttempts()
3153
+ : 1
3154
+ : nonWritingRetryMaxAttempts();
3155
+ const attempts = [];
3156
+ const workspaceSeedPaths = () => [
3157
+ ...Object.keys(workspaceBefore?.files || {}),
3158
+ ...Object.keys(workspaceBefore?.statusByPath || {}),
3159
+ ];
3160
+ if (workspaceRoot) {
3161
+ workspaceBefore = await captureWorkspaceSnapshot(workspaceRoot).catch((error) => ({
3162
+ mode: "capture_failed",
3163
+ root: workspaceRoot,
3164
+ files: {},
3165
+ statusByPath: {},
3166
+ error: String(error?.message || error || "workspace snapshot failed"),
3167
+ }));
3168
+ }
3169
+ let previousSyncCount = 0;
3170
+ let previousMessageCount = 0;
3171
+ let syncRows = [];
3172
+ let messages = [];
3173
+ let verification = null;
3174
+ let attemptError = null;
3175
+ let hasAssistant = false;
3176
+ let persistedAssistant = false;
3177
+ let lastSessionSnapshot = null;
3178
+ for (let attemptIndex = 1; attemptIndex <= maxAttempts; attemptIndex += 1) {
3179
+ attemptError = null;
3180
+ const requestBody =
3181
+ attemptIndex === 1
3182
+ ? {
3183
+ parts: [{ type: "text", text: prompt }],
3184
+ tool_mode: "required",
3185
+ tool_allowlist:
3186
+ options.toolAllowlist ||
3187
+ (writeRequired
3188
+ ? workerExecutionToolAllowlist()
3189
+ : ["ls", "list", "glob", "search", "grep", "codesearch", "read"]),
3190
+ write_required: writeRequired ? true : undefined,
3191
+ }
3192
+ : writeRequired
3193
+ ? buildStrictWriteRetryRequest(prompt, verification, attemptIndex, maxAttempts)
3194
+ : buildNonWritingRetryRequest(prompt, verification, attemptIndex, maxAttempts);
3195
+ const promptResponse = await engineRequestJson(
3196
+ session,
3197
+ `/session/${encodeURIComponent(activeSessionId)}/prompt_sync`,
3198
+ {
3199
+ method: "POST",
3200
+ timeoutMs: 10 * 60 * 1000,
3201
+ body: requestBody,
3202
+ }
3203
+ ).catch((error) => {
3204
+ attemptError = error;
3205
+ return null;
3206
+ });
3207
+ const allSyncRows = Array.isArray(promptResponse) ? promptResponse : [];
3208
+ syncRows = rowsSinceAttemptStart(allSyncRows, previousSyncCount);
3209
+ previousSyncCount = allSyncRows.length;
3210
+ const sessionSnapshot = await engineRequestJson(
3211
+ session,
3212
+ `/session/${encodeURIComponent(activeSessionId)}`
3213
+ ).catch(() => null);
3214
+ lastSessionSnapshot = sessionSnapshot;
3215
+ const sessionMessages = Array.isArray(sessionSnapshot?.messages) ? sessionSnapshot.messages : [];
3216
+ messages = rowsSinceAttemptStart(sessionMessages, previousMessageCount);
3217
+ previousMessageCount = sessionMessages.length;
3218
+ hasAssistant = syncRows.some((row) => roleOfMessage(row) === "assistant");
3219
+ persistedAssistant = messages.some((message) => roleOfMessage(message) === "assistant");
3220
+
3221
+ if (workspaceRoot) {
3222
+ workspaceAfter = await captureWorkspaceSnapshot(workspaceRoot, {
3223
+ includePaths: workspaceSeedPaths(),
3224
+ }).catch((error) => ({
3225
+ mode: "capture_failed",
3226
+ root: workspaceRoot,
3227
+ files: {},
3228
+ statusByPath: {},
3229
+ error: String(error?.message || error || "workspace snapshot failed"),
3230
+ }));
3231
+ }
3232
+ if (workspaceBefore && workspaceAfter) {
3233
+ workspaceChanges = summarizeWorkspaceChanges(workspaceBefore, workspaceAfter);
3234
+ if (workspaceBefore?.error || workspaceAfter?.error) {
3235
+ const detail = [workspaceBefore?.error, workspaceAfter?.error].filter(Boolean).join(" | ");
3236
+ workspaceChanges.summary = [
3237
+ workspaceChanges.summary,
3238
+ detail ? `Workspace snapshot warnings: ${detail}` : "",
3239
+ ]
3240
+ .filter(Boolean)
3241
+ .join("\n");
3242
+ }
3243
+ }
3244
+ verification = writeRequired
3245
+ ? buildVerificationSummary(syncRows, messages, workspaceChanges, activeSessionId, {
3246
+ verificationMode: controller?.verificationMode || swarmState.verificationMode,
3247
+ })
3248
+ : buildNonWritingVerificationSummary(syncRows, messages, activeSessionId, {
3249
+ verificationMode: controller?.verificationMode || swarmState.verificationMode,
3250
+ });
3251
+ const failureClass = writeRequired
3252
+ ? classifyStrictWriteFailureReason(verification)
3253
+ : classifyNonWritingFailureReason(verification);
3254
+ attempts.push(
3255
+ buildAttemptTelemetry(
3256
+ attemptIndex === 1 ? "initial" : `retry_${attemptIndex - 1}`,
3257
+ requestBody,
3258
+ syncRows,
3259
+ messages,
3260
+ Date.now(),
3261
+ attemptError,
3262
+ {
3263
+ attemptIndex,
3264
+ failureClass,
3265
+ retryRemaining: maxAttempts - attemptIndex,
3266
+ }
3267
+ )
3268
+ );
3269
+ if (verification?.passed) break;
3270
+ if (!failureClass || attemptIndex >= maxAttempts) break;
3271
+ }
3272
+ const executionTrace = {
3273
+ session_id: activeSessionId,
3274
+ model: {
3275
+ provider: String(lastSessionSnapshot?.provider || resolvedModel?.provider || "").trim(),
3276
+ model_id:
3277
+ normalizeSessionModelRef(lastSessionSnapshot?.model) ||
3278
+ normalizeSessionModelRef(resolvedModel?.model),
3279
+ source: String(
3280
+ lastSessionSnapshot?.provider &&
3281
+ normalizeSessionModelRef(lastSessionSnapshot?.model)
3282
+ ? "session_snapshot"
3283
+ : resolvedModel?.source || ""
3284
+ ).trim(),
3285
+ },
3286
+ attempts,
3287
+ };
3288
+ verification = writeRequired
3289
+ ? buildVerificationSummary(syncRows, messages, workspaceChanges, activeSessionId, {
3290
+ verificationMode: controller?.verificationMode || swarmState.verificationMode,
3291
+ executionTrace,
3292
+ })
3293
+ : buildNonWritingVerificationSummary(syncRows, messages, activeSessionId, {
3294
+ verificationMode: controller?.verificationMode || swarmState.verificationMode,
3295
+ executionTrace,
3296
+ });
3297
+ if (attemptError && !hasAssistant && !persistedAssistant) {
3298
+ throw createExecutionError(`PROMPT_RETRY_FAILED: ${attemptError.message}`, {
3299
+ sessionId: activeSessionId,
3300
+ verification: {
3301
+ ...verification,
3302
+ reason: "PROMPT_RETRY_FAILED",
3303
+ passed: false,
3304
+ assistant_present: false,
3305
+ retry_error: String(attemptError?.message || attemptError || "").trim(),
3306
+ },
3307
+ });
3308
+ }
3309
+ if (!hasAssistant && !persistedAssistant) {
3310
+ throw createExecutionError(
3311
+ "PROMPT_DISPATCH_EMPTY_RESPONSE: prompt_sync returned no assistant output. Model route may be unresolved.",
3312
+ {
3313
+ sessionId: activeSessionId,
3314
+ verification: {
3315
+ ...verification,
3316
+ reason: "PROMPT_DISPATCH_EMPTY_RESPONSE",
3317
+ passed: false,
3318
+ assistant_present: false,
3319
+ },
3320
+ }
3321
+ );
3322
+ }
3323
+ if (!verification.passed) {
3324
+ throw createExecutionError(`TASK_NOT_VERIFIED: ${verification.reason}`, {
3325
+ sessionId: activeSessionId,
3326
+ verification,
3327
+ });
3328
+ }
3329
+ return { sessionId: activeSessionId, verification };
3330
+ }
3331
+
3332
+ async function runStepWithLLM(session, run, step, stepIndex, totalSteps, sessionId = "") {
3333
+ const prompt = stepPromptText(run, step, stepIndex, totalSteps);
3334
+ return runExecutionPromptWithVerification(session, run, prompt, sessionId);
3335
+ }
3336
+
3337
+ function extractBlackboardTasks(blackboardPayload) {
3338
+ const board =
3339
+ blackboardPayload?.blackboard && typeof blackboardPayload.blackboard === "object"
3340
+ ? blackboardPayload.blackboard
3341
+ : {};
3342
+ return Array.isArray(board?.tasks) ? board.tasks : [];
3343
+ }
3344
+
3345
+ function isTerminalTaskStatus(status) {
3346
+ const normalized = String(status || "")
3347
+ .trim()
3348
+ .toLowerCase();
3349
+ return ["done", "completed", "failed", "cancelled", "canceled"].includes(normalized);
3350
+ }
3351
+
3352
+ function isCompletedTaskStatus(status) {
3353
+ const normalized = String(status || "")
3354
+ .trim()
3355
+ .toLowerCase();
3356
+ return ["done", "completed"].includes(normalized);
3357
+ }
3358
+
3359
+ function extractClaimedTask(payload) {
3360
+ if (payload?.task && typeof payload.task === "object") return payload.task;
3361
+ if (Array.isArray(payload?.tasks) && payload.tasks[0] && typeof payload.tasks[0] === "object") {
3362
+ return payload.tasks[0];
3363
+ }
3364
+ if (payload && typeof payload === "object" && String(payload.id || "").trim()) {
3365
+ return payload;
3366
+ }
3367
+ return null;
3368
+ }
3369
+
3370
+ function taskTitleFromRecord(task) {
3371
+ const payload = task?.payload && typeof task.payload === "object" ? task.payload : {};
3372
+ return String(payload?.title || task?.title || task?.task_type || task?.id || "task").trim();
3373
+ }
3374
+
3375
+ function taskKindFromRecord(task) {
3376
+ const payload = task?.payload && typeof task.payload === "object" ? task.payload : {};
3377
+ const raw = String(payload?.task_kind || task?.task_type || "inspection").trim().toLowerCase();
3378
+ return ["implementation", "inspection", "research", "validation"].includes(raw)
3379
+ ? raw
3380
+ : "inspection";
3381
+ }
3382
+
3383
+ function isNonWritingTaskRecord(task) {
3384
+ return taskKindFromRecord(task) !== "implementation";
3385
+ }
3386
+
3387
+ function summarizeBlackboardTasksForPrompt(tasks, currentTaskId, limit = 16) {
3388
+ const list = Array.isArray(tasks) ? tasks : [];
3389
+ return list
3390
+ .slice(0, Math.max(1, Number(limit) || 16))
3391
+ .map((task, index) => {
3392
+ const taskId = String(task?.id || `task-${index + 1}`).trim();
3393
+ const title = taskTitleFromRecord(task);
3394
+ const status = String(task?.status || "unknown").trim().toLowerCase();
3395
+ const marker = taskId === currentTaskId ? "*" : "-";
3396
+ return `${marker} ${taskId} [${status}]: ${title}`;
3397
+ })
3398
+ .join("\n")
3399
+ .trim();
3400
+ }
3401
+
3402
+ function taskPromptText(run, task, workerId, workflowId) {
3403
+ const taskId = String(task?.id || "").trim();
3404
+ const taskTitle = taskTitleFromRecord(task);
3405
+ const taskKind = taskKindFromRecord(task);
3406
+ const taskDetails =
3407
+ task && typeof task === "object" ? JSON.stringify(task, null, 2).trim() : "";
3408
+ const taskList = summarizeBlackboardTasksForPrompt(run?.tasks, taskId);
3409
+ const outputTarget =
3410
+ task?.payload?.output_target && typeof task.payload.output_target === "object"
3411
+ ? task.payload.output_target
3412
+ : task?.output_target && typeof task.output_target === "object"
3413
+ ? task.output_target
3414
+ : null;
3415
+ const outputPath = String(outputTarget?.path || "").trim();
3416
+ const outputKind = String(outputTarget?.kind || "artifact").trim();
3417
+ const outputOperation = String(outputTarget?.operation || "create_or_update").trim();
3418
+ if (taskKind !== "implementation") {
3419
+ return [
3420
+ "Execute this swarm blackboard task.",
3421
+ "",
3422
+ `Task: ${taskTitle}`,
3423
+ `Task ID: ${taskId}`,
3424
+ `Task Kind: ${taskKind}`,
3425
+ `Workflow: ${String(workflowId || task?.workflow_id || "swarm.blackboard.default").trim()}`,
3426
+ `Agent: ${workerId}`,
3427
+ `Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
3428
+ "",
3429
+ "This is a non-writing research/inspection task.",
3430
+ "Treat the current assigned task as the authority for what to inspect or decide.",
3431
+ "Use the original objective and task list only to clarify the assigned task, not to re-plan the run.",
3432
+ "Do not create a new plan, do not restate the task graph, and do not describe future work.",
3433
+ "Use read-only tools to inspect the workspace now.",
3434
+ "",
3435
+ "Current assigned task payload:",
3436
+ taskDetails || "{}",
3437
+ "",
3438
+ "Run blackboard task list:",
3439
+ taskList || "(no task list available)",
3440
+ "",
3441
+ `Original objective: ${String(run?.objective || "").trim()}`,
3442
+ "",
3443
+ "Requirements:",
3444
+ "- You must use tools in this task.",
3445
+ "- Use only read-only tools such as ls/list/glob/search/grep/codesearch/read.",
3446
+ "- Do not call write/edit/apply_patch for this task.",
3447
+ "- Return a concise structured JSON decision object with your findings.",
3448
+ "- If you decide a future artifact path, include `output_target.path` in the JSON.",
3449
+ '- Output shape: {"decision":{"summary":"...","output_target":{"path":"...","kind":"artifact","operation":"create_or_update"},"evidence":["..."]}}',
3450
+ ].join("\n");
3451
+ }
3452
+ return [
3453
+ "Execute this swarm blackboard task.",
3454
+ "",
3455
+ `Task: ${taskTitle}`,
3456
+ `Task ID: ${taskId}`,
3457
+ `Task Kind: ${taskKind}`,
3458
+ `Workflow: ${String(workflowId || task?.workflow_id || "swarm.blackboard.default").trim()}`,
3459
+ `Agent: ${workerId}`,
3460
+ `Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
3461
+ "",
3462
+ "This task is already planned and assigned.",
3463
+ "Treat the current assigned task as the authority for what to implement.",
3464
+ "Use the original objective and task list only to clarify the assigned task, not to re-plan the run.",
3465
+ "Do not create a new plan, do not restate the task graph, and do not describe future work.",
3466
+ "Use workspace tools to implement this task now.",
3467
+ "",
3468
+ "Current assigned task payload:",
3469
+ taskDetails || "{}",
3470
+ "",
3471
+ "Required output target:",
3472
+ outputPath
3473
+ ? JSON.stringify(
3474
+ {
3475
+ path: outputPath,
3476
+ kind: outputKind,
3477
+ operation: outputOperation,
3478
+ },
3479
+ null,
3480
+ 2
3481
+ )
3482
+ : '{"path":"","kind":"artifact","operation":"create_or_update"}',
3483
+ "",
3484
+ "Run blackboard task list:",
3485
+ taskList || "(no task list available)",
3486
+ "",
3487
+ `Original objective: ${String(run?.objective || "").trim()}`,
3488
+ "",
3489
+ "Requirements:",
3490
+ "- First inspect the relevant workspace files with read/glob/list/search if needed.",
3491
+ "- If the target file does not exist, create the COMPLETE file in a single write call.",
3492
+ "- Do not split the implementation across multiple tool calls if the result should be one file.",
3493
+ "- Use list/ls/glob for directories. Use read only for concrete file paths.",
3494
+ "- Implement this task in the workspace right now.",
3495
+ "- Create or edit files as needed for this task only.",
3496
+ outputPath
3497
+ ? `- The required output for this task is \`${outputPath}\` (${outputKind}, ${outputOperation}).`
3498
+ : "- The required output target is missing; do not guess a file path.",
3499
+ "- If the target file does not exist yet, create it directly.",
3500
+ "- A write call must include both a path and the full content to write.",
3501
+ "- Use write/edit/apply_patch instead of a prose-only response.",
3502
+ "- Keep scope limited to this task.",
3503
+ "- Return a concise summary of the concrete file changes and blockers.",
3504
+ ].join("\n");
3505
+ }
3506
+
3507
+ async function runTaskWithLLM(session, run, task, workerId, workflowId, sessionId = "") {
3508
+ const prompt = taskPromptText(run, task, workerId, workflowId);
3509
+ return runExecutionPromptWithVerification(session, run, prompt, sessionId, {
3510
+ writeRequired: !isNonWritingTaskRecord(task),
3511
+ });
3512
+ }
3513
+
3514
+ async function fetchBlackboardTasks(session, runId) {
3515
+ const payload = await engineRequestJson(
3516
+ session,
3517
+ `/context/runs/${encodeURIComponent(runId)}/blackboard`
3518
+ ).catch(() => ({}));
3519
+ return extractBlackboardTasks(payload);
3520
+ }
3521
+
3522
+ async function seedBlackboardTasks(session, runId, objective, taskRows, workflowId) {
3523
+ const normalizedTasks = ensurePlannerTaskOutputTargets(
3524
+ normalizePlannerTasks(taskRows, 128, { linearFallback: true }),
3525
+ objective
3526
+ );
3527
+ const validTaskIds = new Set(normalizedTasks.map((task) => task.id));
3528
+ const prepared = normalizedTasks
3529
+ .map((task, idx, list) => ({
3530
+ id: String(task?.id || `task-${idx + 1}`).trim(),
3531
+ task_type: String(task?.taskKind || "inspection").trim(),
3532
+ workflow_id: workflowId,
3533
+ depends_on_task_ids: (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds : [])
3534
+ .map((dep) => String(dep || "").trim())
3535
+ .filter((dep) => dep && validTaskIds.has(dep)),
3536
+ payload: {
3537
+ title: String(task?.title || "").trim(),
3538
+ task_kind: String(task?.taskKind || "inspection").trim(),
3539
+ objective,
3540
+ step_index: idx + 1,
3541
+ total_steps: list.length,
3542
+ output_target: task?.outputTarget || null,
3543
+ },
3544
+ }))
3545
+ .filter((task) => String(task?.payload?.title || "").trim().length >= 6);
3546
+ if (!prepared.length) {
3547
+ throw new Error("No valid tasks to seed.");
3548
+ }
3549
+ const created = await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/tasks`, {
3550
+ method: "POST",
3551
+ body: { tasks: prepared },
3552
+ });
3553
+ if (created && created.ok === false) {
3554
+ throw new Error(String(created.error || created.code || "Task seeding failed."));
3555
+ }
3556
+ const seeded = await fetchBlackboardTasks(session, runId);
3557
+ if (!seeded.length) {
3558
+ throw new Error("Task seeding returned no blackboard tasks.");
3559
+ }
3560
+ return { mode: "blackboard", count: seeded.length };
3561
+ }
3562
+
3563
+ async function transitionBlackboardTask(session, runId, task, update = {}) {
3564
+ const taskId = String(task?.id || update.taskId || "").trim();
3565
+ if (!taskId) throw new Error("Missing task id for transition.");
3566
+ const expectedTaskRev = task?.task_rev ?? update.expectedTaskRev;
3567
+ const leaseToken = task?.lease_token || task?.leaseToken || update.leaseToken;
3568
+ const agentId = update.agentId || task?.assigned_agent || task?.lease_owner || undefined;
3569
+ return engineRequestJson(
3570
+ session,
3571
+ `/context/runs/${encodeURIComponent(runId)}/tasks/${encodeURIComponent(taskId)}/transition`,
3572
+ {
3573
+ method: "POST",
3574
+ body: {
3575
+ action: String(update.action || "status").trim() || "status",
3576
+ status: update.status || undefined,
3577
+ error: update.error || undefined,
3578
+ command_id: update.commandId || undefined,
3579
+ expected_task_rev: expectedTaskRev ?? undefined,
3580
+ lease_token: leaseToken || undefined,
3581
+ agent_id: agentId || undefined,
3582
+ },
3583
+ }
3584
+ );
3585
+ }
3586
+
3587
+ async function detectExecutorMode(session, runId) {
3588
+ const blackboardTasks = await fetchBlackboardTasks(session, runId);
3589
+ if (blackboardTasks.length) return "blackboard";
3590
+ const payload = await engineRequestJson(
3591
+ session,
3592
+ `/context/runs/${encodeURIComponent(runId)}`
3593
+ ).catch(() => null);
3594
+ const steps = Array.isArray(payload?.run?.steps) ? payload.run.steps : [];
3595
+ if (steps.length) return "context_steps";
3596
+ return String(
3597
+ getSwarmRunController(runId)?.executorMode || swarmState.executorMode || "context_steps"
3598
+ );
3599
+ }
3600
+
3601
+ const swarmExecutors = new Map();
3602
+
3603
+ function findStepByStatus(steps, status) {
3604
+ return (Array.isArray(steps) ? steps : []).find(
3605
+ (step) =>
3606
+ String(step?.status || "")
3607
+ .trim()
3608
+ .toLowerCase() === status
3609
+ );
3610
+ }
3611
+
3612
+ async function ensureStepMarkedDone(session, runId, stepId) {
3613
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3614
+ const payload = await engineRequestJson(
3615
+ session,
3616
+ `/context/runs/${encodeURIComponent(runId)}`
3617
+ ).catch(() => null);
3618
+ const run = payload?.run;
3619
+ const steps = Array.isArray(run?.steps) ? run.steps : [];
3620
+ const idx = steps.findIndex((step) => String(step?.step_id || "") === stepId);
3621
+ if (idx < 0) return true;
3622
+ const current = String(steps[idx]?.status || "").toLowerCase();
3623
+ if (current === "done") return true;
3624
+ if (attempt < 2) {
3625
+ await sleep(120);
3626
+ continue;
3627
+ }
3628
+ const patched = {
3629
+ ...run,
3630
+ status: "running",
3631
+ why_next_step: `reconciled completion for ${stepId}`,
3632
+ steps: steps.map((step, i) => (i === idx ? { ...step, status: "done" } : step)),
3633
+ };
3634
+ await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}`, {
3635
+ method: "PUT",
3636
+ body: patched,
3637
+ });
3638
+ const verify = await engineRequestJson(
3639
+ session,
3640
+ `/context/runs/${encodeURIComponent(runId)}`
3641
+ ).catch(() => null);
3642
+ const verifySteps = Array.isArray(verify?.run?.steps) ? verify.run.steps : [];
3643
+ const verifyStep = verifySteps.find((step) => String(step?.step_id || "") === stepId);
3644
+ return String(verifyStep?.status || "").toLowerCase() === "done";
3645
+ }
3646
+ return false;
3647
+ }
3648
+
3649
+ async function driveContextRunExecution(session, runId) {
3650
+ if (swarmExecutors.has(runId)) return false;
3651
+ upsertSwarmRunController(runId, {
3652
+ executorState: "running",
3653
+ executorReason: "",
3654
+ });
3655
+ const runner = (async () => {
3656
+ let completionStreak = 0;
3657
+ let lastCompletedStepId = "";
3658
+ for (let cycle = 0; cycle < 24; cycle += 1) {
3659
+ const runPayload = await engineRequestJson(
3660
+ session,
3661
+ `/context/runs/${encodeURIComponent(runId)}`
3662
+ );
3663
+ const run = runPayload?.run || {};
3664
+ if (isRunTerminal(run.status)) return;
3665
+
3666
+ const nextPayload = await engineRequestJson(
3667
+ session,
3668
+ `/context/runs/${encodeURIComponent(runId)}/driver/next`,
3669
+ {
3670
+ method: "POST",
3671
+ body: { dry_run: false },
3672
+ }
3673
+ );
3674
+ const selectedStepId = String(nextPayload?.selected_step_id || "").trim();
3675
+ const latestRun = nextPayload?.run || run;
3676
+ const steps = Array.isArray(latestRun?.steps) ? latestRun.steps : [];
3677
+ const inProgressStep = findStepByStatus(steps, "in_progress");
3678
+ const executionStepId = selectedStepId || String(inProgressStep?.step_id || "").trim();
3679
+
3680
+ if (!executionStepId) {
3681
+ if (
3682
+ steps.length &&
3683
+ steps.every((step) => String(step?.status || "").toLowerCase() === "done")
3684
+ ) {
3685
+ await appendContextRunEvent(session, runId, "run_completed", "completed", {
3686
+ why_next_step: "all steps completed",
3687
+ });
3688
+ upsertSwarmRunController(runId, {
3689
+ lastError: "",
3690
+ status: "completed",
3691
+ stoppedAt: Date.now(),
3692
+ executorState: "idle",
3693
+ executorReason: "run completed",
3694
+ });
3695
+ return;
3696
+ }
3697
+ const blockedReason = String(
3698
+ nextPayload?.why_next_step || latestRun?.why_next_step || "No actionable step selected."
3699
+ );
3700
+ upsertSwarmRunController(runId, {
3701
+ lastError: blockedReason,
3702
+ status: "blocked",
3703
+ executorState: "blocked",
3704
+ executorReason: blockedReason,
3705
+ });
3706
+ return;
3707
+ }
3708
+
3709
+ const stepIndex = steps.findIndex((step) => String(step?.step_id || "") === executionStepId);
3710
+ const step =
3711
+ stepIndex >= 0 ? steps[stepIndex] : { step_id: executionStepId, title: executionStepId };
3712
+ let stepSessionId = "";
3713
+
3714
+ try {
3715
+ stepSessionId = await createExecutionSession(session, latestRun);
3716
+ await appendContextRunEvent(
3717
+ session,
3718
+ runId,
3719
+ "step_started",
3720
+ "running",
3721
+ {
3722
+ step_status: "in_progress",
3723
+ step_title: String(step?.title || executionStepId),
3724
+ session_id: stepSessionId || null,
3725
+ why_next_step: selectedStepId
3726
+ ? `executing ${executionStepId}`
3727
+ : `resuming in_progress step ${executionStepId}`,
3728
+ },
3729
+ executionStepId
3730
+ );
3731
+ const { sessionId, verification } = await runStepWithLLM(
3732
+ session,
3733
+ latestRun,
3734
+ step,
3735
+ Math.max(stepIndex, 0),
3736
+ Math.max(steps.length, 1),
3737
+ stepSessionId
3738
+ );
3739
+ await appendContextRunEvent(
3740
+ session,
3741
+ runId,
3742
+ "step_completed",
3743
+ "running",
3744
+ {
3745
+ step_status: "done",
3746
+ step_title: String(step?.title || executionStepId),
3747
+ session_id: sessionId,
3748
+ verification,
3749
+ why_next_step: `completed ${executionStepId}`,
3750
+ },
3751
+ executionStepId
3752
+ );
3753
+ const refresh = await engineRequestJson(
3754
+ session,
3755
+ `/context/runs/${encodeURIComponent(runId)}`
3756
+ ).catch(() => null);
3757
+ const refreshedSteps = Array.isArray(refresh?.run?.steps) ? refresh.run.steps : [];
3758
+ const refreshedStep = refreshedSteps.find(
3759
+ (item) => String(item?.step_id || "") === executionStepId
3760
+ );
3761
+ const refreshedStatus = String(refreshedStep?.status || "").toLowerCase();
3762
+ if (refreshedStatus !== "done") {
3763
+ const reconciled = await ensureStepMarkedDone(session, runId, executionStepId);
3764
+ if (reconciled) {
3765
+ await appendContextRunEvent(
3766
+ session,
3767
+ runId,
3768
+ "step_completion_reconciled",
3769
+ "running",
3770
+ {
3771
+ step_status: "done",
3772
+ why_next_step: `reconciled stale step state for ${executionStepId}`,
3773
+ },
3774
+ executionStepId
3775
+ );
3776
+ } else {
3777
+ throw new Error(
3778
+ `STEP_STATE_NOT_ADVANCING: step \`${executionStepId}\` remained \`${refreshedStatus || "unknown"}\` after completion`
3779
+ );
3780
+ }
3781
+ }
3782
+ if (executionStepId === lastCompletedStepId) completionStreak += 1;
3783
+ else {
3784
+ lastCompletedStepId = executionStepId;
3785
+ completionStreak = 1;
3786
+ }
3787
+ if (completionStreak > 2) {
3788
+ throw new Error(`STEP_LOOP_GUARD: repeated completion on step \`${executionStepId}\``);
3789
+ }
3790
+ upsertSwarmRunController(runId, {
3791
+ lastError: "",
3792
+ status: "running",
3793
+ executorState: "running",
3794
+ executorReason: "",
3795
+ });
3796
+ } catch (error) {
3797
+ const message = String(error?.message || error || "Unknown step failure");
3798
+ const failureSessionId = String(error?.sessionId || stepSessionId || "").trim();
3799
+ const verification =
3800
+ error?.verification && typeof error.verification === "object"
3801
+ ? error.verification
3802
+ : undefined;
3803
+ upsertSwarmRunController(runId, {
3804
+ lastError: message,
3805
+ status: "failed",
3806
+ executorState: "error",
3807
+ executorReason: message,
3808
+ });
3809
+ await appendContextRunEvent(
3810
+ session,
3811
+ runId,
3812
+ "step_failed",
3813
+ "failed",
3814
+ {
3815
+ step_status: "failed",
3816
+ session_id: failureSessionId || null,
3817
+ verification,
3818
+ error: message,
3819
+ why_next_step: `step failed: ${executionStepId}`,
3820
+ },
3821
+ executionStepId
3822
+ );
3823
+ return;
3824
+ }
3825
+ }
3826
+ })()
3827
+ .catch((error) => {
3828
+ const message = String(error?.message || error || "Run executor failed");
3829
+ upsertSwarmRunController(runId, {
3830
+ lastError: message,
3831
+ status: "failed",
3832
+ executorState: "error",
3833
+ executorReason: message,
3834
+ });
3835
+ })
3836
+ .finally(() => {
3837
+ swarmExecutors.delete(runId);
3838
+ const controller = getSwarmRunController(runId);
3839
+ if (String(controller?.executorState || "") === "running") {
3840
+ upsertSwarmRunController(runId, {
3841
+ executorState: "idle",
3842
+ executorReason: "",
3843
+ });
3844
+ }
3845
+ });
3846
+ swarmExecutors.set(runId, runner);
3847
+ return true;
3848
+ }
3849
+
3850
+ async function driveBlackboardRunExecution(session, runId, options = {}) {
3851
+ if (swarmExecutors.has(runId)) return false;
3852
+ const controller = getSwarmRunController(runId);
3853
+ const workflowId = String(
3854
+ options.workflowId || controller?.workflowId || swarmState.workflowId || "swarm.blackboard.default"
3855
+ ).trim();
3856
+ const maxAgents = Math.max(
3857
+ 1,
3858
+ Math.min(
3859
+ 16,
3860
+ Number.parseInt(String(options.maxAgents || controller?.maxAgents || swarmState.maxAgents || 3), 10) || 3
3861
+ )
3862
+ );
3863
+ upsertSwarmRunController(runId, {
3864
+ executorState: "running",
3865
+ executorReason: "",
3866
+ executorMode: "blackboard",
3867
+ });
3868
+
3869
+ const runner = (async () => {
3870
+ let completionAnnounced = false;
3871
+ const markRunComplete = async (reason) => {
3872
+ if (completionAnnounced) return;
3873
+ completionAnnounced = true;
3874
+ await appendContextRunEvent(session, runId, "run_completed", "completed", {
3875
+ why_next_step: String(reason || "all tasks completed"),
3876
+ });
3877
+ upsertSwarmRunController(runId, {
3878
+ lastError: "",
3879
+ status: "completed",
3880
+ stoppedAt: Date.now(),
3881
+ executorState: "idle",
3882
+ executorReason: "run completed",
3883
+ });
3884
+ };
3885
+
3886
+ const workers = Array.from({ length: maxAgents }).map((_, index) => {
3887
+ const agentId = `swarm-agent-${index + 1}`;
3888
+ return (async () => {
3889
+ let idleSpins = 0;
3890
+ for (let cycle = 0; cycle < 96; cycle += 1) {
3891
+ const runPayload = await engineRequestJson(
3892
+ session,
3893
+ `/context/runs/${encodeURIComponent(runId)}`
3894
+ ).catch(() => null);
3895
+ const run = runPayload?.run || {};
3896
+ if (isRunTerminal(run.status)) return;
3897
+
3898
+ const boardTasks = await fetchBlackboardTasks(session, runId);
3899
+ const openTasks = boardTasks.filter((task) => !isTerminalTaskStatus(task?.status));
3900
+ if (!openTasks.length && boardTasks.length) {
3901
+ await markRunComplete("all blackboard tasks completed");
3902
+ return;
3903
+ }
3904
+
3905
+ const claimedPayload = await engineRequestJson(
3906
+ session,
3907
+ `/context/runs/${encodeURIComponent(runId)}/tasks/claim`,
3908
+ {
3909
+ method: "POST",
3910
+ body: {
3911
+ agent_id: agentId,
3912
+ workflow_id: workflowId || undefined,
3913
+ lease_ms: 45000,
3914
+ },
3915
+ }
3916
+ ).catch(() => null);
3917
+ const task = extractClaimedTask(claimedPayload);
3918
+ if (!task) {
3919
+ idleSpins += 1;
3920
+ if (idleSpins > 6) {
3921
+ const refreshedBoard = await fetchBlackboardTasks(session, runId);
3922
+ if (
3923
+ refreshedBoard.length &&
3924
+ refreshedBoard.every((row) => isCompletedTaskStatus(row?.status))
3925
+ ) {
3926
+ await markRunComplete("all blackboard tasks completed");
3927
+ return;
3928
+ }
3929
+ idleSpins = 0;
3930
+ }
3931
+ await sleep(500);
3932
+ continue;
3933
+ }
3934
+ idleSpins = 0;
3935
+ const taskId = String(task?.id || "").trim();
3936
+ const title = taskTitleFromRecord(task);
3937
+ let taskSessionId = "";
3938
+ try {
3939
+ taskSessionId = await createExecutionSession(session, run);
3940
+ await appendContextRunEvent(
3941
+ session,
3942
+ runId,
3943
+ "task_started",
3944
+ "running",
3945
+ {
3946
+ step_status: "in_progress",
3947
+ step_title: title,
3948
+ session_id: taskSessionId || null,
3949
+ workflow_id: String(task?.workflow_id || workflowId || "").trim(),
3950
+ assigned_agent: agentId,
3951
+ why_next_step: `executing ${taskId || title}`,
3952
+ },
3953
+ taskId || null
3954
+ );
3955
+ const runSnapshot = await engineRequestJson(
3956
+ session,
3957
+ `/context/runs/${encodeURIComponent(runId)}`
3958
+ ).catch(() => ({ run: {} }));
3959
+ const { sessionId, verification } = await runTaskWithLLM(
3960
+ session,
3961
+ runSnapshot?.run || run,
3962
+ task,
3963
+ agentId,
3964
+ workflowId,
3965
+ taskSessionId
3966
+ );
3967
+ await transitionBlackboardTask(session, runId, task, {
3968
+ status: "done",
3969
+ agentId,
3970
+ commandId: sessionId,
3971
+ }).catch(() => null);
3972
+ await appendContextRunEvent(
3973
+ session,
3974
+ runId,
3975
+ "task_completed",
3976
+ "running",
3977
+ {
3978
+ step_status: "done",
3979
+ step_title: title,
3980
+ session_id: sessionId,
3981
+ verification,
3982
+ workflow_id: String(task?.workflow_id || workflowId || "").trim(),
3983
+ assigned_agent: agentId,
3984
+ why_next_step: `completed ${taskId || title}`,
3985
+ },
3986
+ taskId || null
3987
+ );
3988
+ upsertSwarmRunController(runId, {
3989
+ lastError: "",
3990
+ status: "running",
3991
+ executorState: "running",
3992
+ executorReason: "",
3993
+ });
3994
+ } catch (error) {
3995
+ const message = String(error?.message || error || "Unknown task failure");
3996
+ const failureSessionId = String(error?.sessionId || taskSessionId || "").trim();
3997
+ const verification =
3998
+ error?.verification && typeof error.verification === "object"
3999
+ ? error.verification
4000
+ : undefined;
4001
+ await transitionBlackboardTask(session, runId, task, {
4002
+ status: "failed",
4003
+ error: message,
4004
+ agentId,
4005
+ }).catch(() => null);
4006
+ upsertSwarmRunController(runId, {
4007
+ lastError: message,
4008
+ status: "failed",
4009
+ executorState: "error",
4010
+ executorReason: message,
4011
+ });
4012
+ await appendContextRunEvent(
4013
+ session,
4014
+ runId,
4015
+ "task_failed",
4016
+ "failed",
4017
+ {
4018
+ step_status: "failed",
4019
+ step_title: title,
4020
+ session_id: failureSessionId || null,
4021
+ verification,
4022
+ workflow_id: String(task?.workflow_id || workflowId || "").trim(),
4023
+ assigned_agent: agentId,
4024
+ error: message,
4025
+ why_next_step: `task failed: ${taskId || title}`,
4026
+ },
4027
+ taskId || null
4028
+ );
4029
+ return;
4030
+ }
4031
+ }
4032
+ })();
4033
+ });
4034
+ await Promise.all(workers);
4035
+ })()
4036
+ .catch((error) => {
4037
+ const message = String(error?.message || error || "Run executor failed");
4038
+ upsertSwarmRunController(runId, {
4039
+ lastError: message,
4040
+ status: "failed",
4041
+ executorState: "error",
4042
+ executorReason: message,
4043
+ });
4044
+ })
4045
+ .finally(() => {
4046
+ swarmExecutors.delete(runId);
4047
+ const current = getSwarmRunController(runId);
4048
+ if (String(current?.executorState || "") === "running") {
4049
+ upsertSwarmRunController(runId, {
4050
+ executorState: "idle",
4051
+ executorReason: "",
4052
+ });
4053
+ }
4054
+ });
4055
+ swarmExecutors.set(runId, runner);
4056
+ return true;
4057
+ }
4058
+
4059
+ async function startRunExecutor(session, runId, options = {}) {
4060
+ const mode = String(options.mode || "").trim() || (await detectExecutorMode(session, runId));
4061
+ upsertSwarmRunController(runId, { executorMode: mode });
4062
+ if (mode === "blackboard") {
4063
+ return driveBlackboardRunExecution(session, runId, options);
4064
+ }
4065
+ return driveContextRunExecution(session, runId);
4066
+ }
4067
+
4068
+ async function requeueInProgressSteps(session, runId) {
4069
+ const payload = await engineRequestJson(
4070
+ session,
4071
+ `/context/runs/${encodeURIComponent(runId)}`
4072
+ ).catch(() => null);
4073
+ const run = payload?.run;
4074
+ const steps = Array.isArray(run?.steps) ? run.steps : [];
4075
+ const inProgress = steps.filter(
4076
+ (step) =>
4077
+ String(step?.status || "")
4078
+ .trim()
4079
+ .toLowerCase() === "in_progress"
4080
+ );
4081
+ for (const step of inProgress) {
4082
+ const stepId = String(step?.step_id || "").trim();
4083
+ if (!stepId) continue;
4084
+ await appendContextRunEvent(
4085
+ session,
4086
+ runId,
4087
+ "task_retry_requested",
4088
+ "running",
4089
+ {
4090
+ why_next_step: `requeued stale in_progress step \`${stepId}\` before continue`,
4091
+ },
4092
+ stepId
4093
+ );
4094
+ }
4095
+ return inProgress.length;
4096
+ }
4097
+
4098
+ async function startSwarm(session, config = {}) {
4099
+ const objective = String(config.objective || "Ship a small feature end-to-end").trim();
4100
+ const workspaceCandidates = [
4101
+ config.workspaceRoot,
4102
+ config.workspace_root,
4103
+ config.workspace?.canonical_path,
4104
+ config.workspace?.workspace_root,
4105
+ config.repoRoot,
4106
+ config.repo_root,
4107
+ ];
4108
+ const workspaceRootRaw = workspaceCandidates
4109
+ .map((value) => String(value || "").trim())
4110
+ .find((value) => value.length > 0);
4111
+ if (!workspaceRootRaw) {
4112
+ throw new Error(
4113
+ "WORKSPACE_SELECTION_REQUIRED: select a workspace folder before starting a swarm run."
4114
+ );
4115
+ }
4116
+ const workspaceRoot = await workspaceExistsAsDirectory(workspaceRootRaw);
4117
+ if (!workspaceRoot) {
4118
+ throw new Error(
4119
+ `Workspace root does not exist or is not a directory: ${resolve(String(workspaceRootRaw || REPO_ROOT))}`
4120
+ );
4121
+ }
4122
+ const maxTasks = Math.max(1, Number.parseInt(String(config.maxTasks || 3), 10) || 3);
4123
+ const maxAgents = Math.max(
4124
+ 1,
4125
+ Math.min(16, Number.parseInt(String(config.maxAgents || 3), 10) || 3)
4126
+ );
4127
+ const workflowId =
4128
+ String(config.workflowId || "swarm.blackboard.default").trim() || "swarm.blackboard.default";
4129
+ const verificationMode = normalizeVerificationMode(
4130
+ config?.verificationMode || config?.verification_mode
4131
+ );
4132
+ const requireLlmPlan = config?.requireLlmPlan === true || config?.require_llm_plan === true;
4133
+ const allowLocalPlannerFallback =
4134
+ config?.allowLocalPlannerFallback === true || config?.allow_local_planner_fallback === true;
4135
+ let modelProvider = String(config.modelProvider || "").trim();
4136
+ let modelId = String(config.modelId || "").trim();
4137
+ const mcpServers = (Array.isArray(config.mcpServers) ? config.mcpServers : [])
4138
+ .map((entry) => String(entry || "").trim())
4139
+ .filter(Boolean)
4140
+ .slice(0, 64);
4141
+
4142
+ const created = await engineRequestJson(session, "/context/runs", {
4143
+ method: "POST",
4144
+ body: {
4145
+ objective,
4146
+ run_type: "interactive",
4147
+ source_client: "control_panel",
4148
+ model_provider: modelProvider || undefined,
4149
+ model_id: modelId || undefined,
4150
+ mcp_servers: mcpServers,
4151
+ workspace: {
4152
+ workspace_id: buildWorkspaceId(workspaceRoot),
4153
+ canonical_path: workspaceRoot,
4154
+ lease_epoch: 1,
4155
+ },
4156
+ },
4157
+ });
4158
+ const run = created?.run;
4159
+ const runId = String(run?.run_id || "").trim();
4160
+ if (!runId) throw new Error("Context run creation failed (missing run_id).");
4161
+
4162
+ await appendContextRunEvent(session, runId, "planning_started", "planning", {
4163
+ source_client: "control_panel",
4164
+ max_tasks: maxTasks,
4165
+ max_agents: maxAgents,
4166
+ workflow_id: workflowId,
4167
+ verification_mode: verificationMode,
4168
+ model_provider: modelProvider || undefined,
4169
+ model_id: modelId || undefined,
4170
+ mcp_servers: mcpServers,
4171
+ });
4172
+ const synced = (() =>
4173
+ engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
4174
+ method: "POST",
4175
+ body: {
4176
+ replace: true,
4177
+ todos: [],
4178
+ source_session_id: null,
4179
+ source_run_id: runId,
4180
+ },
4181
+ }))();
4182
+ await synced;
4183
+ let plannerTasks = [];
4184
+ let planSeedMode = "fallback_local";
4185
+ const enforceStrictTaskOutputs = String(verificationMode || "strict").trim().toLowerCase() === "strict";
4186
+ try {
4187
+ const llmPlan = await generatePlanTodosWithLLM(session, run, maxTasks);
4188
+ let plannerSource = "llm_objective_planner";
4189
+ let plannerNote = "";
4190
+ plannerTasks = ensurePlannerTaskOutputTargets(
4191
+ normalizePlannerTasks(llmPlan.tasks, maxTasks, { linearFallback: false }),
4192
+ objective
4193
+ );
4194
+ if (!plannerTasks.length) {
4195
+ const recovered = fallbackPlannerTasks(objective, maxTasks, llmPlan?.assistantText || "");
4196
+ plannerTasks = ensurePlannerTaskOutputTargets(recovered.tasks, objective);
4197
+ plannerSource = recovered.source;
4198
+ plannerNote = recovered.note;
4199
+ }
4200
+ if (enforceStrictTaskOutputs) {
4201
+ const strictCheck = validateStrictPlannerTasks(plannerTasks);
4202
+ if (!strictCheck.ok) {
4203
+ throw new Error(
4204
+ `STRICT_TASK_PLAN_INVALID: missing_output_target=${strictCheck.missing.join(", ")} invalid_task_kind=${strictCheck.invalidTaskKinds.join(", ")}`
4205
+ );
4206
+ }
4207
+ }
4208
+ const seeded = await seedBlackboardTasks(session, runId, objective, plannerTasks, workflowId);
4209
+ planSeedMode = "blackboard_llm";
4210
+ await appendContextRunEvent(session, runId, "plan_seeded_llm", "planning", {
4211
+ source: plannerSource,
4212
+ session_id: llmPlan.sessionId || null,
4213
+ task_count: Number(seeded?.count || 0),
4214
+ dependency_edges: plannerTasks.reduce(
4215
+ (sum, task) =>
4216
+ sum + (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds.length : 0),
4217
+ 0
4218
+ ),
4219
+ workflow_id: workflowId,
4220
+ planner_target: planSeedMode,
4221
+ note: plannerNote || undefined,
4222
+ });
4223
+ } catch (planningError) {
4224
+ const plannerFailureReason = String(
4225
+ planningError?.message || planningError || "unknown planning failure"
4226
+ );
4227
+ const recovered = fallbackPlannerTasks(objective, maxTasks, planningError?.assistantText || "");
4228
+ plannerTasks = ensurePlannerTaskOutputTargets(recovered.tasks, objective);
4229
+ const forcedFallback = requireLlmPlan && !allowLocalPlannerFallback;
4230
+ if (forcedFallback) {
4231
+ await appendContextRunEvent(session, runId, "plan_failed_llm_required", "planning", {
4232
+ reason: plannerFailureReason,
4233
+ recovered: false,
4234
+ recovery_source: recovered.source,
4235
+ }).catch(() => null);
4236
+ throw new Error(`LLM planning failed and fallback is disabled: ${plannerFailureReason}`);
4237
+ }
4238
+ if (enforceStrictTaskOutputs) {
4239
+ const strictCheck = validateStrictPlannerTasks(plannerTasks);
4240
+ if (!strictCheck.ok) {
4241
+ await appendContextRunEvent(session, runId, "plan_failed_output_target_missing", "planning", {
4242
+ reason: plannerFailureReason,
4243
+ missing_tasks: strictCheck.missing,
4244
+ invalid_task_kind_tasks: strictCheck.invalidTaskKinds,
4245
+ recovery_source: recovered.source,
4246
+ }).catch(() => null);
4247
+ throw new Error(
4248
+ `Strict orchestration requires valid task kinds and output targets where needed: missing_output_target=${strictCheck.missing.join(", ")} invalid_task_kind=${strictCheck.invalidTaskKinds.join(", ")}`
4249
+ );
4250
+ }
4251
+ }
4252
+ try {
4253
+ const seeded = await seedBlackboardTasks(session, runId, objective, plannerTasks, workflowId);
4254
+ planSeedMode = "blackboard_local";
4255
+ await appendContextRunEvent(session, runId, "plan_seeded_local", "planning", {
4256
+ source: recovered.source,
4257
+ task_count: Number(seeded?.count || 0),
4258
+ dependency_edges: plannerTasks.reduce(
4259
+ (sum, task) =>
4260
+ sum + (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds.length : 0),
4261
+ 0
4262
+ ),
4263
+ workflow_id: workflowId,
4264
+ planner_target: planSeedMode,
4265
+ note: `${recovered.note} Planner fallback used: ${plannerFailureReason}`,
4266
+ });
4267
+ } catch (blackboardError) {
4268
+ throw blackboardError;
4269
+ }
4270
+ }
4271
+ await appendContextRunEvent(session, runId, "plan_ready_for_approval", "awaiting_approval", {
4272
+ source_client: "control_panel",
4273
+ workflow_id: workflowId,
4274
+ max_agents: maxAgents,
4275
+ verification_mode: verificationMode,
4276
+ planner_mode: planSeedMode,
4277
+ });
4278
+ upsertSwarmRunController(runId, {
4279
+ status: "awaiting_approval",
4280
+ startedAt: Date.now(),
4281
+ stoppedAt: null,
4282
+ objective,
4283
+ workspaceRoot,
4284
+ maxTasks,
4285
+ maxAgents,
4286
+ workflowId,
4287
+ modelProvider,
4288
+ modelId,
4289
+ resolvedModelProvider: "",
4290
+ resolvedModelId: "",
4291
+ modelResolutionSource: "deferred",
4292
+ mcpServers,
4293
+ repoRoot: workspaceRoot,
4294
+ verificationMode,
4295
+ lastError: "",
4296
+ executorMode: planSeedMode.startsWith("blackboard") ? "blackboard" : "context_steps",
4297
+ executorState: "idle",
4298
+ executorReason: "",
4299
+ attachedPid: null,
4300
+ registryCache: null,
4301
+ });
4302
+ setActiveSwarmRunId(runId);
4303
+ setSwarmPreflight({
4304
+ gitAvailable: null,
4305
+ repoReady: true,
4306
+ autoInitialized: false,
4307
+ code: "workspace_ready",
4308
+ reason: "",
4309
+ guidance: "",
4310
+ });
4311
+ pushSwarmEvent("status", { status: swarmState.status, runId: runId });
4312
+ return runId;
4313
+ }
4314
+
4315
+ const handleSwarmApi = createSwarmApiHandler({
4316
+ PORTAL_PORT,
4317
+ REPO_ROOT,
4318
+ ENGINE_URL,
4319
+ swarmState,
4320
+ isLocalEngineUrl,
4321
+ sendJson,
4322
+ readJsonBody,
4323
+ workspaceExistsAsDirectory,
4324
+ loadHiddenSwarmRunIds,
4325
+ saveHiddenSwarmRunIds,
4326
+ engineRequestJson,
4327
+ appendContextRunEvent,
4328
+ contextRunStatusToSwarmStatus,
4329
+ startSwarm,
4330
+ detectExecutorMode,
4331
+ startRunExecutor,
4332
+ requeueInProgressSteps,
4333
+ transitionBlackboardTask,
4334
+ contextRunSnapshot,
4335
+ contextRunToTasks,
4336
+ getSwarmRunController,
4337
+ upsertSwarmRunController,
4338
+ setActiveSwarmRunId,
4339
+ });
4340
+
1203
4341
  async function handleApi(req, res) {
1204
4342
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
1205
4343
 
@@ -1235,7 +4373,10 @@ async function handleApi(req, res) {
1235
4373
  if (!health) {
1236
4374
  sessions.delete(session.sid);
1237
4375
  clearSessionCookie(res);
1238
- sendJson(res, 401, { ok: false, error: "Session token is no longer valid for the configured engine." });
4376
+ sendJson(res, 401, {
4377
+ ok: false,
4378
+ error: "Session token is no longer valid for the configured engine.",
4379
+ });
1239
4380
  return true;
1240
4381
  }
1241
4382
  sendJson(res, 200, {
@@ -1247,7 +4388,7 @@ async function handleApi(req, res) {
1247
4388
  return true;
1248
4389
  }
1249
4390
 
1250
- if (pathname.startsWith("/api/swarm")) {
4391
+ if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
1251
4392
  const session = requireSession(req, res);
1252
4393
  if (!session) return true;
1253
4394
  return handleSwarmApi(req, res, session);
@@ -1302,7 +4443,6 @@ function shutdown(signal) {
1302
4443
  }
1303
4444
  if (swarmState.process && !swarmState.process.killed) {
1304
4445
  try {
1305
- clearSwarmMonitor();
1306
4446
  swarmState.process.kill("SIGTERM");
1307
4447
  } catch {}
1308
4448
  }
@@ -1318,6 +4458,13 @@ process.on("SIGINT", () => shutdown("SIGINT"));
1318
4458
  process.on("SIGTERM", () => shutdown("SIGTERM"));
1319
4459
 
1320
4460
  async function main() {
4461
+ if (serviceOp) {
4462
+ await operateServices(serviceOp, serviceMode);
4463
+ if (serviceSetupOnly && !installServicesRequested) {
4464
+ return;
4465
+ }
4466
+ }
4467
+
1321
4468
  if (installServicesRequested) {
1322
4469
  await installServices();
1323
4470
  if (serviceSetupOnly) {
@@ -1352,13 +4499,15 @@ async function main() {
1352
4499
  process.exit(1);
1353
4500
  });
1354
4501
 
1355
- server.listen(PORTAL_PORT, () => {
4502
+ server.listen(PORTAL_PORT, PANEL_HOST, () => {
1356
4503
  log("=========================================");
1357
- log(`Control Panel: http://localhost:${PORTAL_PORT}`);
4504
+ log(`Control Panel: http://${PANEL_HOST}:${PORTAL_PORT}`);
4505
+ if (PANEL_PUBLIC_URL) log(`Public URL: ${PANEL_PUBLIC_URL}`);
1358
4506
  log(`Engine URL: ${ENGINE_URL}`);
1359
4507
  log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
1360
4508
  log(`Files root: ${FILES_ROOT}`);
1361
4509
  log(`Files scope: ${FILES_SCOPE || "(full root)"}`);
4510
+ log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
1362
4511
  log("=========================================");
1363
4512
  });
1364
4513
  }