@runfusion/fusion 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/bin.js +949 -138
  2. package/dist/client/assets/AgentDetailView-B1zViykq.js +18 -0
  3. package/dist/client/assets/{AgentsView-DkX0tzrN.js → AgentsView-Bl9JH5C8.js} +3 -3
  4. package/dist/client/assets/{ChatView-CEm2Hw6m.js → ChatView-liNErE53.js} +1 -1
  5. package/dist/client/assets/{DevServerView-Bumvo_ge.js → DevServerView-CV_PpbnZ.js} +1 -1
  6. package/dist/client/assets/{DirectoryPicker-CXN11cBp.js → DirectoryPicker-DPfkGnj5.js} +1 -1
  7. package/dist/client/assets/{DocumentsView-B71IqAxA.js → DocumentsView-CESb6RI7.js} +1 -1
  8. package/dist/client/assets/{InsightsView-Bs4Rldu6.js → InsightsView-BKhvyEyQ.js} +1 -1
  9. package/dist/client/assets/{MemoryView-Bs7b_L2Q.js → MemoryView-DB-l2miV.js} +1 -1
  10. package/dist/client/assets/{NodesView-BvAGTXbO.js → NodesView-DgTXO8mm.js} +1 -1
  11. package/dist/client/assets/{PiExtensionsManager-3Kcc4uhA.js → PiExtensionsManager-C4fTzemh.js} +1 -1
  12. package/dist/client/assets/{PluginManager-Ch-Xynlm.js → PluginManager-C2-dExUL.js} +1 -1
  13. package/dist/client/assets/{ResearchView-Bj6Saqf6.js → ResearchView-CkVwRDVA.js} +1 -1
  14. package/dist/client/assets/{RoadmapsView-9qT8Vwd0.js → RoadmapsView-Cu85_XrQ.js} +1 -1
  15. package/dist/client/assets/{SettingsModal-D4ERGQNQ.js → SettingsModal-BGnSAeqa.js} +1 -1
  16. package/dist/client/assets/SettingsModal-C0DokcId.js +31 -0
  17. package/dist/client/assets/{SetupWizardModal-Dv0rX2_o.js → SetupWizardModal-C_d9clJp.js} +1 -1
  18. package/dist/client/assets/{SkillMultiselect-CSkXQzdv.js → SkillMultiselect-DwGWYZi6.js} +1 -1
  19. package/dist/client/assets/{SkillsView-2srXMOzj.js → SkillsView-C096TB7i.js} +1 -1
  20. package/dist/client/assets/{TodoView-CxPPIvw2.js → TodoView-CUiAt2mR.js} +1 -1
  21. package/dist/client/assets/{folder-open-FA1PwpXV.js → folder-open-CKivQd8c.js} +1 -1
  22. package/dist/client/assets/index-B4StE1qN.js +662 -0
  23. package/dist/client/assets/index-DYJk0WDc.css +1 -0
  24. package/dist/client/assets/{list-checks-6EktkUso.js → list-checks-B3oufblU.js} +1 -1
  25. package/dist/client/assets/{star-B6Th07jw.js → star-damu_EYz.js} +1 -1
  26. package/dist/client/assets/{upload-BJwuErhV.js → upload-uH6CHlEw.js} +1 -1
  27. package/dist/client/assets/{users-BrnPTF8H.js → users-CUySbfji.js} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/client/version.json +1 -1
  30. package/dist/extension.js +722 -120
  31. package/dist/pi-claude-cli/index.ts +2 -2
  32. package/dist/pi-claude-cli/package.json +1 -1
  33. package/package.json +1 -1
  34. package/dist/client/assets/AgentDetailView-C2Iik3Qf.js +0 -18
  35. package/dist/client/assets/SettingsModal-Zo5qDGOq.js +0 -31
  36. package/dist/client/assets/index-CEavim6l.js +0 -662
  37. package/dist/client/assets/index-D1gTSlYB.css +0 -1
package/dist/bin.js CHANGED
@@ -77,6 +77,7 @@ var init_settings_schema = __esm({
77
77
  favoriteModels: void 0,
78
78
  openrouterModelSync: true,
79
79
  updateCheckEnabled: true,
80
+ fnBinaryCheckEnabled: true,
80
81
  updateCheckFrequency: "daily",
81
82
  showGitHubStarButton: true,
82
83
  modelOnboardingComplete: void 0,
@@ -241,7 +242,8 @@ var init_settings_schema = __esm({
241
242
  maxPostReviewFixes: 1,
242
243
  maxSpawnedAgentsPerParent: 5,
243
244
  maxSpawnedAgentsGlobal: 20,
244
- maintenanceIntervalMs: 9e5,
245
+ // Run maintenance (including WAL checkpointing) every 5 minutes by default.
246
+ maintenanceIntervalMs: 3e5,
245
247
  autoArchiveDoneTasksEnabled: true,
246
248
  autoArchiveDoneAfterMs: 48 * 60 * 60 * 1e3,
247
249
  archiveAgentLogMode: "compact",
@@ -2652,6 +2654,7 @@ var init_sqlite_adapter = __esm({
2652
2654
  // ../core/src/db.ts
2653
2655
  import { isAbsolute, join as join2 } from "node:path";
2654
2656
  import { mkdirSync, existsSync } from "node:fs";
2657
+ import { spawnSync } from "node:child_process";
2655
2658
  function toJson(value) {
2656
2659
  if (value === void 0 || value === null) return "[]";
2657
2660
  if (Array.isArray(value) && value.length === 0) return "[]";
@@ -3283,15 +3286,24 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3283
3286
  Database = class {
3284
3287
  db;
3285
3288
  dbPath;
3289
+ inMemory;
3290
+ corruptionDetected = false;
3286
3291
  /** Tracks transaction nesting depth for savepoint-based nested transactions. */
3287
3292
  transactionDepth = 0;
3288
3293
  _fts5Available;
3289
3294
  constructor(fusionDir, options) {
3290
3295
  const inMemory = options?.inMemory === true;
3296
+ this.inMemory = inMemory;
3291
3297
  this.dbPath = inMemory ? ":memory:" : join2(fusionDir, "fusion.db");
3292
3298
  if (!inMemory && !isAbsolute(fusionDir)) {
3293
3299
  throw new Error(`[fusion] Database constructor requires an absolute fusionDir path, got: ${fusionDir}`);
3294
3300
  }
3301
+ if (!inMemory && /\.fusion[\\/]\.fusion(?:[\\/]|$)/.test(fusionDir)) {
3302
+ throw new Error(
3303
+ `[fusion] Refusing to open Database at nested .fusion/.fusion path: ${fusionDir}
3304
+ This means a caller passed a .fusion directory where a project root was expected. Audit the call site for an extra \`join(rootDir, '.fusion')\` step.`
3305
+ );
3306
+ }
3295
3307
  if (!inMemory && !existsSync(fusionDir)) {
3296
3308
  mkdirSync(fusionDir, { recursive: true });
3297
3309
  }
@@ -3303,8 +3315,13 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3303
3315
  }
3304
3316
  if (!inMemory) {
3305
3317
  this.db.exec("PRAGMA journal_mode = WAL");
3318
+ this.db.exec("PRAGMA busy_timeout = 5000");
3319
+ this.db.exec("PRAGMA synchronous = NORMAL");
3320
+ this.db.exec("PRAGMA wal_autocheckpoint = 100");
3321
+ this.db.exec("PRAGMA journal_size_limit = 4194304");
3322
+ } else {
3323
+ this.db.exec("PRAGMA busy_timeout = 5000");
3306
3324
  }
3307
- this.db.exec("PRAGMA busy_timeout = 5000");
3308
3325
  this.db.exec("PRAGMA foreign_keys = ON");
3309
3326
  this._fts5Available = probeFts5(this.db);
3310
3327
  }
@@ -3385,6 +3402,35 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3385
3402
  return false;
3386
3403
  }
3387
3404
  }
3405
+ integrityCheck() {
3406
+ if (this.inMemory) {
3407
+ return { ok: true };
3408
+ }
3409
+ const rows = this.db.prepare("PRAGMA integrity_check(100)").all();
3410
+ const errors = rows.map((row) => row.integrity_check).filter((value) => typeof value === "string" && value !== "ok");
3411
+ if (errors.length > 0) {
3412
+ return { ok: false, errors };
3413
+ }
3414
+ return { ok: true };
3415
+ }
3416
+ recoverDatabase(outputPath) {
3417
+ if (this.inMemory) {
3418
+ return false;
3419
+ }
3420
+ const recoveredSql = spawnSync("sqlite3", ["-cmd", ".recover main", this.dbPath], {
3421
+ encoding: "utf-8",
3422
+ maxBuffer: 50 * 1024 * 1024
3423
+ });
3424
+ if (recoveredSql.status !== 0 || !recoveredSql.stdout) {
3425
+ return false;
3426
+ }
3427
+ const rebuilt = spawnSync("sqlite3", [outputPath], {
3428
+ input: recoveredSql.stdout,
3429
+ encoding: "utf-8",
3430
+ maxBuffer: 50 * 1024 * 1024
3431
+ });
3432
+ return rebuilt.status === 0;
3433
+ }
3388
3434
  /**
3389
3435
  * Initialize the database: create tables if they don't exist
3390
3436
  * and seed meta values.
@@ -3403,6 +3449,11 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3403
3449
  this.db.exec(
3404
3450
  `INSERT OR IGNORE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt) VALUES (1, 1, 1, '${JSON.stringify(DEFAULT_PROJECT_SETTINGS)}', '[]', '${configNow}')`
3405
3451
  );
3452
+ const integrity = this.integrityCheck();
3453
+ if (!integrity.ok) {
3454
+ this.corruptionDetected = true;
3455
+ console.warn("[fusion:db] Database integrity check FAILED \u2014 corruption detected");
3456
+ }
3406
3457
  }
3407
3458
  /**
3408
3459
  * Run incremental schema migrations based on the stored schema version.
@@ -6018,6 +6069,7 @@ var init_agent_store = __esm({
6018
6069
  };
6019
6070
  const line = JSON.stringify(safeEntry) + "\n";
6020
6071
  await appendFile(this.runLogPath(agentId, runId), line, "utf-8");
6072
+ this.emit("run:log", agentId, runId, safeEntry);
6021
6073
  }
6022
6074
  /**
6023
6075
  * Read all log entries for a given run from its JSONL file.
@@ -19071,7 +19123,7 @@ __export(migration_exports, {
19071
19123
  MigrationCoordinator: () => MigrationCoordinator,
19072
19124
  ProjectRequiredError: () => ProjectRequiredError
19073
19125
  });
19074
- import { existsSync as existsSync9, readFileSync as readFileSync2 } from "node:fs";
19126
+ import { existsSync as existsSync9, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
19075
19127
  import { homedir as homedir2, tmpdir } from "node:os";
19076
19128
  import { isAbsolute as isAbsolute3, join as join12, resolve as resolve5, basename as basename3, dirname as dirname4 } from "node:path";
19077
19129
  function getHomeDir2() {
@@ -19172,12 +19224,30 @@ var init_migration = __esm({
19172
19224
  const projects = [];
19173
19225
  const visited = /* @__PURE__ */ new Set();
19174
19226
  let current = resolve5(startDir);
19175
- const home = getHomeDir2();
19227
+ const home = resolve5(getHomeDir2());
19176
19228
  const root = dirname4(current) === current ? current : "/";
19177
19229
  const systemTmp = resolve5(tmpdir());
19178
- while (current !== home && current !== root && current !== systemTmp) {
19230
+ const normalizePath2 = (path5) => {
19231
+ try {
19232
+ return realpathSync2(path5);
19233
+ } catch {
19234
+ return resolve5(path5);
19235
+ }
19236
+ };
19237
+ const normalizedHome = normalizePath2(home);
19238
+ const normalizedSystemTmp = normalizePath2(systemTmp);
19239
+ const normalizedStartDir = normalizePath2(current);
19240
+ while (true) {
19179
19241
  if (visited.has(current)) break;
19180
19242
  visited.add(current);
19243
+ const normalizedCurrent = normalizePath2(current);
19244
+ const isRootBoundary = current === root;
19245
+ const isHomeBoundary = normalizedCurrent === normalizedHome;
19246
+ const isTmpBoundary = normalizedCurrent === normalizedSystemTmp;
19247
+ const isStopBoundary = isRootBoundary || isHomeBoundary || isTmpBoundary;
19248
+ if (isRootBoundary || isTmpBoundary || isHomeBoundary && normalizedCurrent !== normalizedStartDir) {
19249
+ break;
19250
+ }
19181
19251
  if (this.hasFusionProject(current)) {
19182
19252
  const name = await this.generateProjectName(current);
19183
19253
  projects.push({
@@ -19187,6 +19257,9 @@ var init_migration = __esm({
19187
19257
  });
19188
19258
  break;
19189
19259
  }
19260
+ if (isStopBoundary) {
19261
+ break;
19262
+ }
19190
19263
  const parent2 = dirname4(current);
19191
19264
  if (parent2 === current) break;
19192
19265
  current = parent2;
@@ -34495,6 +34568,39 @@ ${task.description}
34495
34568
  this.db.bumpLastModified();
34496
34569
  this.emit("agent:log", entry);
34497
34570
  }
34571
+ async appendAgentLogBatch(entries) {
34572
+ if (entries.length === 0) {
34573
+ return;
34574
+ }
34575
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
34576
+ const stmt = this.db.prepare(`
34577
+ INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
34578
+ VALUES (?, ?, ?, ?, ?, ?)
34579
+ `);
34580
+ this.db.transaction(() => {
34581
+ for (const entry of entries) {
34582
+ stmt.run(
34583
+ entry.taskId,
34584
+ timestamp,
34585
+ entry.text,
34586
+ entry.type,
34587
+ entry.detail ?? null,
34588
+ entry.agent ?? null
34589
+ );
34590
+ }
34591
+ });
34592
+ this.db.bumpLastModified();
34593
+ for (const entry of entries) {
34594
+ this.emit("agent:log", {
34595
+ timestamp,
34596
+ taskId: entry.taskId,
34597
+ text: entry.text,
34598
+ type: entry.type,
34599
+ ...entry.detail !== void 0 && { detail: entry.detail },
34600
+ ...entry.agent !== void 0 && { agent: entry.agent }
34601
+ });
34602
+ }
34603
+ }
34498
34604
  mapAgentLogRow(row) {
34499
34605
  return {
34500
34606
  timestamp: row.timestamp,
@@ -36344,12 +36450,16 @@ var init_gh_cli = __esm({
36344
36450
 
36345
36451
  // ../core/src/fn-binary.ts
36346
36452
  import { spawn as spawn2 } from "node:child_process";
36347
- import { platform as platform2 } from "node:os";
36453
+ import { platform as platform2, tmpdir as tmpdir2 } from "node:os";
36348
36454
  function runProbe(command, args, timeoutMs) {
36349
36455
  return new Promise((resolve42) => {
36350
36456
  let stdout = "";
36351
36457
  let stderr = "";
36352
- const child = spawn2(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
36458
+ const child = spawn2(command, args, {
36459
+ stdio: ["ignore", "pipe", "pipe"],
36460
+ shell: false,
36461
+ cwd: tmpdir2()
36462
+ });
36353
36463
  const timer = setTimeout(() => {
36354
36464
  try {
36355
36465
  child.kill("SIGKILL");
@@ -37480,6 +37590,20 @@ var init_plugin_loader = __esm({
37480
37590
  }
37481
37591
  return slots;
37482
37592
  }
37593
+ /**
37594
+ * Get all top-level dashboard view definitions from loaded plugins.
37595
+ */
37596
+ getPluginDashboardViews() {
37597
+ const views = [];
37598
+ for (const [pluginId, plugin4] of this.plugins) {
37599
+ if (plugin4.dashboardViews) {
37600
+ for (const view of plugin4.dashboardViews) {
37601
+ views.push({ pluginId, view });
37602
+ }
37603
+ }
37604
+ }
37605
+ return views;
37606
+ }
37483
37607
  /**
37484
37608
  * Get all runtime registrations from loaded plugins.
37485
37609
  * Returns plugin ownership metadata along with the runtime registration.
@@ -37667,7 +37791,7 @@ async function syncBackupAutomation(automationStore, settings) {
37667
37791
  if (!AutomationStore2.isValidCron(schedule)) {
37668
37792
  throw new Error(`Invalid backup schedule: ${schedule}`);
37669
37793
  }
37670
- const command = "npx runfusion.ai backup --create";
37794
+ const command = "fn backup --create";
37671
37795
  if (existingSchedule) {
37672
37796
  return await automationStore.updateSchedule(existingSchedule.id, {
37673
37797
  scheduleType: "custom",
@@ -37700,7 +37824,7 @@ async function syncBackupRoutine(routineStore, settings) {
37700
37824
  if (!RoutineStore2.isValidCron(schedule)) {
37701
37825
  throw new Error(`Invalid backup schedule: ${schedule}`);
37702
37826
  }
37703
- const command = "npx runfusion.ai backup --create";
37827
+ const command = "fn backup --create";
37704
37828
  const input = {
37705
37829
  name: BACKUP_SCHEDULE_NAME,
37706
37830
  description: "Automatic database backup based on project settings",
@@ -49575,7 +49699,7 @@ var require_dist3 = __commonJS({
49575
49699
 
49576
49700
  // ../core/src/agent-companies-parser.ts
49577
49701
  import { existsSync as existsSync17, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, statSync as statSync4 } from "node:fs";
49578
- import { tmpdir as tmpdir2 } from "node:os";
49702
+ import { tmpdir as tmpdir3 } from "node:os";
49579
49703
  import { join as join20, resolve as resolve9 } from "node:path";
49580
49704
  function slugifyAgentReference(value) {
49581
49705
  return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
@@ -49898,7 +50022,7 @@ async function extractTarArchive(archivePath, outputDir) {
49898
50022
  }
49899
50023
  async function parseCompanyArchive(archivePath) {
49900
50024
  const resolvedArchivePath = resolve9(archivePath);
49901
- const tempDir = mkdtempSync(join20(tmpdir2(), "agent-companies-"));
50025
+ const tempDir = mkdtempSync(join20(tmpdir3(), "agent-companies-"));
49902
50026
  try {
49903
50027
  if (resolvedArchivePath.endsWith(".tar.gz") || resolvedArchivePath.endsWith(".tgz")) {
49904
50028
  await extractTarArchive(resolvedArchivePath, tempDir);
@@ -51975,18 +52099,21 @@ function summarizeToolArgs(name, args) {
51975
52099
  }
51976
52100
  return void 0;
51977
52101
  }
51978
- var FLUSH_SIZE_BYTES, FLUSH_INTERVAL_MS, AgentLogger;
52102
+ var FLUSH_SIZE_BYTES, FLUSH_INTERVAL_MS, ENTRY_BATCH_SIZE, AgentLogger;
51979
52103
  var init_agent_logger = __esm({
51980
52104
  "../engine/src/agent-logger.ts"() {
51981
52105
  "use strict";
51982
52106
  init_logger2();
51983
52107
  FLUSH_SIZE_BYTES = 1024;
51984
52108
  FLUSH_INTERVAL_MS = 500;
52109
+ ENTRY_BATCH_SIZE = 50;
51985
52110
  AgentLogger = class {
51986
52111
  textBuffer = "";
51987
52112
  thinkingBuffer = "";
51988
52113
  flushTimer = null;
51989
52114
  thinkingFlushTimer = null;
52115
+ entryFlushTimer = null;
52116
+ pendingEntries = [];
51990
52117
  flushSizeBytes;
51991
52118
  flushIntervalMs;
51992
52119
  store;
@@ -52091,8 +52218,13 @@ var init_agent_logger = __esm({
52091
52218
  clearTimeout(this.thinkingFlushTimer);
52092
52219
  this.thinkingFlushTimer = null;
52093
52220
  }
52221
+ if (this.entryFlushTimer) {
52222
+ clearTimeout(this.entryFlushTimer);
52223
+ this.entryFlushTimer = null;
52224
+ }
52094
52225
  await this.flushTextBuffer();
52095
52226
  await this.flushThinkingBuffer();
52227
+ await this.flushPendingEntries();
52096
52228
  }
52097
52229
  // ── Internal helpers ───────────────────────────────────────────────
52098
52230
  /**
@@ -52101,7 +52233,7 @@ var init_agent_logger = __esm({
52101
52233
  * When only `appendLogCb` is set (no store/taskId), only the callback is used.
52102
52234
  * @param storeWarnMsg - Warning message prefix used when the task-store write fails.
52103
52235
  */
52104
- writeEntry(text, type, detail, storeWarnMsg) {
52236
+ writeEntry(text, type, detail, _storeWarnMsg, immediate = false) {
52105
52237
  const entry = {
52106
52238
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
52107
52239
  taskId: this.taskId,
@@ -52110,72 +52242,38 @@ var init_agent_logger = __esm({
52110
52242
  ...detail !== void 0 && { detail },
52111
52243
  ...this.agent !== void 0 && { agent: this.agent }
52112
52244
  };
52113
- if (this.store && this.taskId) {
52114
- this.store.appendAgentLog(this.taskId, text, type, detail, this.agent).catch((err) => {
52115
- this.log.warn(`${storeWarnMsg}: ${err instanceof Error ? err.message : String(err)}`);
52116
- });
52245
+ this.pendingEntries.push(entry);
52246
+ if (immediate || type !== "text" && type !== "thinking") {
52247
+ if (this.entryFlushTimer) {
52248
+ clearTimeout(this.entryFlushTimer);
52249
+ this.entryFlushTimer = null;
52250
+ }
52251
+ void this.flushPendingEntries();
52252
+ return;
52117
52253
  }
52118
- if (this.appendLogCb) {
52119
- this.appendLogCb(entry).catch((err) => {
52120
- this.log.warn(`appendLog callback failed for entry (${type}): ${err instanceof Error ? err.message : String(err)}`);
52121
- });
52254
+ if (this.pendingEntries.length >= ENTRY_BATCH_SIZE) {
52255
+ if (this.entryFlushTimer) {
52256
+ clearTimeout(this.entryFlushTimer);
52257
+ this.entryFlushTimer = null;
52258
+ }
52259
+ void this.flushPendingEntries();
52260
+ return;
52122
52261
  }
52262
+ this.scheduleEntryFlush();
52123
52263
  }
52124
52264
  flushTextBuffer() {
52125
52265
  if (this.textBuffer.length === 0) return Promise.resolve();
52126
52266
  const chunk = this.textBuffer;
52127
52267
  this.textBuffer = "";
52128
- const entry = {
52129
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
52130
- taskId: this.taskId,
52131
- text: chunk,
52132
- type: "text",
52133
- ...this.agent !== void 0 && { agent: this.agent }
52134
- };
52135
- const promises = [];
52136
- if (this.store && this.taskId) {
52137
- promises.push(
52138
- this.store.appendAgentLog(this.taskId, chunk, "text", void 0, this.agent).catch((err) => {
52139
- this.log.warn(`Failed to flush text buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
52140
- })
52141
- );
52142
- }
52143
- if (this.appendLogCb) {
52144
- promises.push(
52145
- this.appendLogCb(entry).catch((err) => {
52146
- this.log.warn(`appendLog callback failed for text flush: ${err instanceof Error ? err.message : String(err)}`);
52147
- })
52148
- );
52149
- }
52150
- return Promise.all(promises).then(() => void 0);
52268
+ this.writeEntry(chunk, "text", void 0, `Failed to flush text buffer for ${this.taskId}`, true);
52269
+ return this.flushPendingEntries();
52151
52270
  }
52152
52271
  flushThinkingBuffer() {
52153
52272
  if (this.thinkingBuffer.length === 0) return Promise.resolve();
52154
52273
  const chunk = this.thinkingBuffer;
52155
52274
  this.thinkingBuffer = "";
52156
- const entry = {
52157
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
52158
- taskId: this.taskId,
52159
- text: chunk,
52160
- type: "thinking",
52161
- ...this.agent !== void 0 && { agent: this.agent }
52162
- };
52163
- const promises = [];
52164
- if (this.store && this.taskId) {
52165
- promises.push(
52166
- this.store.appendAgentLog(this.taskId, chunk, "thinking", void 0, this.agent).catch((err) => {
52167
- this.log.warn(`Failed to flush thinking buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
52168
- })
52169
- );
52170
- }
52171
- if (this.appendLogCb) {
52172
- promises.push(
52173
- this.appendLogCb(entry).catch((err) => {
52174
- this.log.warn(`appendLog callback failed for thinking flush: ${err instanceof Error ? err.message : String(err)}`);
52175
- })
52176
- );
52177
- }
52178
- return Promise.all(promises).then(() => void 0);
52275
+ this.writeEntry(chunk, "thinking", void 0, `Failed to flush thinking buffer for ${this.taskId}`, true);
52276
+ return this.flushPendingEntries();
52179
52277
  }
52180
52278
  scheduleFlush() {
52181
52279
  if (this.flushTimer) return;
@@ -52191,6 +52289,52 @@ var init_agent_logger = __esm({
52191
52289
  this.flushThinkingBuffer();
52192
52290
  }, this.flushIntervalMs);
52193
52291
  }
52292
+ scheduleEntryFlush() {
52293
+ if (this.entryFlushTimer) return;
52294
+ this.entryFlushTimer = setTimeout(() => {
52295
+ this.entryFlushTimer = null;
52296
+ void this.flushPendingEntries();
52297
+ }, this.flushIntervalMs);
52298
+ }
52299
+ async flushPendingEntries() {
52300
+ if (this.pendingEntries.length === 0) {
52301
+ return;
52302
+ }
52303
+ const entries = this.pendingEntries;
52304
+ this.pendingEntries = [];
52305
+ if (this.store && this.taskId) {
52306
+ if (typeof this.store.appendAgentLogBatch === "function") {
52307
+ await this.store.appendAgentLogBatch(
52308
+ entries.map((entry) => ({
52309
+ taskId: entry.taskId,
52310
+ text: entry.text,
52311
+ type: entry.type,
52312
+ detail: entry.detail,
52313
+ agent: entry.agent
52314
+ }))
52315
+ ).catch((err) => {
52316
+ this.log.warn(`Failed to flush agent log batch for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
52317
+ });
52318
+ } else {
52319
+ await Promise.all(
52320
+ entries.map(
52321
+ (entry) => this.store.appendAgentLog(entry.taskId, entry.text, entry.type, entry.detail, entry.agent).catch((err) => {
52322
+ this.log.warn(`Failed to flush agent log entry for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
52323
+ })
52324
+ )
52325
+ );
52326
+ }
52327
+ }
52328
+ if (this.appendLogCb) {
52329
+ await Promise.all(
52330
+ entries.map(
52331
+ (entry) => this.appendLogCb(entry).catch((err) => {
52332
+ this.log.warn(`appendLog callback failed for entry (${entry.type}): ${err instanceof Error ? err.message : String(err)}`);
52333
+ })
52334
+ )
52335
+ );
52336
+ }
52337
+ }
52194
52338
  };
52195
52339
  }
52196
52340
  });
@@ -65767,6 +65911,20 @@ The tool prevents your session from being killed by the inactivity watchdog duri
65767
65911
  );
65768
65912
  this.activeStepExecutors.delete(task.id);
65769
65913
  }
65914
+ if (this.activeWorkflowStepSessions.has(task.id)) {
65915
+ executorLog.log(`${task.id} moved from in-progress to ${to} \u2014 terminating workflow step session`);
65916
+ this.pausedAborted.add(task.id);
65917
+ this.options.stuckTaskDetector?.untrackTask(task.id);
65918
+ const workflowSession = this.activeWorkflowStepSessions.get(task.id);
65919
+ const sessionWithAbort = workflowSession;
65920
+ if (typeof sessionWithAbort.abort === "function") {
65921
+ void sessionWithAbort.abort().catch((err) => {
65922
+ executorLog.warn(`Failed to abort workflow step session for ${task.id}: ${err}`);
65923
+ });
65924
+ }
65925
+ workflowSession.dispose();
65926
+ this.activeWorkflowStepSessions.delete(task.id);
65927
+ }
65770
65928
  this.disposeSubagentsForTask(task.id, `parent moved from in-progress to ${to}`);
65771
65929
  this.loopRecoveryState.delete(task.id);
65772
65930
  this.spawnedAgents.delete(task.id);
@@ -65799,8 +65957,42 @@ The tool prevents your session from being killed by the inactivity watchdog duri
65799
65957
  this.disposeSubagentsForTask(task.id, "task paused");
65800
65958
  return;
65801
65959
  }
65802
- if (!task.paused && task.column === "in-progress" && !this.activeSessions.has(task.id) && !this.activeStepExecutors.has(task.id)) {
65960
+ if (task.paused && this.activeWorkflowStepSessions.has(task.id)) {
65961
+ executorLog.log(`Pausing ${task.id} \u2014 terminating workflow step session`);
65962
+ this.pausedAborted.add(task.id);
65963
+ this.options.stuckTaskDetector?.untrackTask(task.id);
65964
+ const workflowSession = this.activeWorkflowStepSessions.get(task.id);
65965
+ const sessionWithAbort = workflowSession;
65966
+ if (typeof sessionWithAbort.abort === "function") {
65967
+ await sessionWithAbort.abort().catch(
65968
+ (err) => executorLog.warn(`Failed to abort workflow step session for pause ${task.id}: ${err}`)
65969
+ );
65970
+ }
65971
+ workflowSession.dispose();
65972
+ this.activeWorkflowStepSessions.delete(task.id);
65973
+ this.loopRecoveryState.delete(task.id);
65974
+ this.spawnedAgents.delete(task.id);
65975
+ this.stuckAborted.delete(task.id);
65976
+ this.disposeSubagentsForTask(task.id, "task paused");
65977
+ return;
65978
+ }
65979
+ if (!task.paused && task.column === "in-progress" && !this.activeSessions.has(task.id) && !this.activeStepExecutors.has(task.id) && !this.activeWorkflowStepSessions.has(task.id)) {
65803
65980
  if (!this.executing.has(task.id) && !this.resumingUnpaused.has(task.id) && !this.recoveringCompleted.has(task.id)) {
65981
+ const pauseLabel = await this.getExecutionPauseLabel();
65982
+ if (pauseLabel) {
65983
+ executorLog.log(`Skipping unpause resume for ${task.id} \u2014 ${pauseLabel} active`);
65984
+ return;
65985
+ }
65986
+ if (this.isTaskWorkComplete(task) && !task.mergeDetails) {
65987
+ this.recoveringCompleted.add(task.id);
65988
+ executorLog.log(`${task.id} unpaused with completed work and no session \u2014 recovering directly to in-review`);
65989
+ void this.recoverCompletedTask(task).catch(
65990
+ (err) => executorLog.error(`Failed to recover completed unpaused task ${task.id}:`, err)
65991
+ ).finally(() => {
65992
+ this.recoveringCompleted.delete(task.id);
65993
+ });
65994
+ return;
65995
+ }
65804
65996
  this.resumingUnpaused.add(task.id);
65805
65997
  executorLog.log(`Unpaused ${task.id} in-progress with no session \u2014 resuming execution`);
65806
65998
  try {
@@ -65919,6 +66111,22 @@ The tool prevents your session from being killed by the inactivity watchdog duri
65919
66111
  this.spawnedAgents.delete(taskId);
65920
66112
  this.stuckAborted.delete(taskId);
65921
66113
  }
66114
+ for (const [taskId, workflowSession] of this.activeWorkflowStepSessions) {
66115
+ executorLog.log(`Global pause \u2014 terminating workflow step session for ${taskId}`);
66116
+ this.pausedAborted.add(taskId);
66117
+ this.options.stuckTaskDetector?.untrackTask(taskId);
66118
+ const sessionWithAbort = workflowSession;
66119
+ if (typeof sessionWithAbort.abort === "function") {
66120
+ void sessionWithAbort.abort().catch((err) => {
66121
+ executorLog.warn(`Failed to abort workflow step session for ${taskId}: ${err}`);
66122
+ });
66123
+ }
66124
+ workflowSession.dispose();
66125
+ this.activeWorkflowStepSessions.delete(taskId);
66126
+ this.loopRecoveryState.delete(taskId);
66127
+ this.spawnedAgents.delete(taskId);
66128
+ this.stuckAborted.delete(taskId);
66129
+ }
65922
66130
  }
65923
66131
  });
65924
66132
  }
@@ -65936,6 +66144,8 @@ The tool prevents your session from being killed by the inactivity watchdog duri
65936
66144
  activeSessions = /* @__PURE__ */ new Map();
65937
66145
  /** Active step-session executors per task (mutually exclusive with activeSessions). */
65938
66146
  activeStepExecutors = /* @__PURE__ */ new Map();
66147
+ /** Active pre-merge workflow step sessions per task. */
66148
+ activeWorkflowStepSessions = /* @__PURE__ */ new Map();
65939
66149
  /**
65940
66150
  * Reviewer subagent sessions per task. Reviewers (`reviewer.ts`) create their
65941
66151
  * own AgentSessions that aren't part of `activeSessions`/`activeStepExecutors`,
@@ -65982,6 +66192,69 @@ The tool prevents your session from being killed by the inactivity watchdog duri
65982
66192
  await this.store.mergeTask(taskId);
65983
66193
  return "merged";
65984
66194
  }
66195
+ async getExecutionPauseLabel() {
66196
+ const settings = await this.store.getSettings();
66197
+ if (settings.globalPause) return "global pause";
66198
+ if (settings.enginePaused) return "engine pause";
66199
+ return null;
66200
+ }
66201
+ async shouldDeferCompletionForGlobalPause(taskId, context) {
66202
+ const settings = await this.store.getSettings();
66203
+ if (!settings.globalPause) {
66204
+ return false;
66205
+ }
66206
+ this.clearCompletedTaskWatchdog(taskId);
66207
+ executorLog.log(`${taskId}: completion handoff deferred \u2014 global pause active (${context})`);
66208
+ await this.store.logEntry(
66209
+ taskId,
66210
+ `Completion handoff deferred \u2014 global pause active (${context})`,
66211
+ void 0,
66212
+ this.currentRunContext
66213
+ ).catch(() => void 0);
66214
+ return true;
66215
+ }
66216
+ async shouldDeferWorkflowStepCompletion(taskId, context) {
66217
+ let latestTask = null;
66218
+ try {
66219
+ latestTask = await this.store.getTask(taskId);
66220
+ } catch {
66221
+ latestTask = null;
66222
+ }
66223
+ if (latestTask?.paused || this.pausedAborted.has(taskId)) {
66224
+ this.clearCompletedTaskWatchdog(taskId);
66225
+ executorLog.log(`${taskId}: completion handoff deferred \u2014 task paused (${context})`);
66226
+ await this.store.logEntry(
66227
+ taskId,
66228
+ `Completion handoff deferred \u2014 task paused (${context})`,
66229
+ void 0,
66230
+ this.currentRunContext
66231
+ ).catch(() => void 0);
66232
+ return true;
66233
+ }
66234
+ return this.shouldDeferCompletionForGlobalPause(taskId, context);
66235
+ }
66236
+ async parkTaskAfterWorkflowStepPause(taskId) {
66237
+ let latestTask = null;
66238
+ try {
66239
+ latestTask = await this.store.getTask(taskId);
66240
+ } catch {
66241
+ latestTask = null;
66242
+ }
66243
+ if (!latestTask?.paused) {
66244
+ return false;
66245
+ }
66246
+ executorLog.log(`${taskId}: workflow step interrupted by task pause \u2014 moving to todo`);
66247
+ await this.store.logEntry(
66248
+ taskId,
66249
+ "Execution paused during pre-merge workflow step \u2014 moved to todo",
66250
+ void 0,
66251
+ this.currentRunContext
66252
+ ).catch(() => void 0);
66253
+ if (latestTask.column === "in-progress") {
66254
+ await this.store.moveTask(taskId, "todo", { preserveResumeState: true });
66255
+ }
66256
+ return true;
66257
+ }
65985
66258
  /** Child agent sessions keyed by agent ID. Used for termination. */
65986
66259
  childSessions = /* @__PURE__ */ new Map();
65987
66260
  /** Total count of currently spawned agents (across all parents). */
@@ -66176,11 +66449,15 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66176
66449
  this.clearCompletedTaskWatchdog(taskId);
66177
66450
  const handle = setTimeout(async () => {
66178
66451
  this.completedTaskWatchdogs.delete(taskId);
66179
- if (this.recoveringCompleted.has(taskId) || this.executing.has(taskId) || this.activeSessions.has(taskId) || this.activeStepExecutors.has(taskId) || this.resumingUnpaused.has(taskId)) {
66452
+ if (this.recoveringCompleted.has(taskId) || this.executing.has(taskId) || this.activeSessions.has(taskId) || this.activeStepExecutors.has(taskId) || this.activeWorkflowStepSessions.has(taskId) || this.resumingUnpaused.has(taskId)) {
66180
66453
  return;
66181
66454
  }
66182
66455
  this.recoveringCompleted.add(taskId);
66183
66456
  try {
66457
+ const pauseLabel = await this.getExecutionPauseLabel();
66458
+ if (pauseLabel) {
66459
+ return;
66460
+ }
66184
66461
  let currentTask = null;
66185
66462
  try {
66186
66463
  currentTask = await this.store.getTask(taskId);
@@ -66226,6 +66503,11 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66226
66503
  * stuck.
66227
66504
  */
66228
66505
  async performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState = true) {
66506
+ const pauseLabel = await this.getExecutionPauseLabel();
66507
+ if (pauseLabel) {
66508
+ executorLog.log(`${taskId}: workflow rerun deferred \u2014 ${pauseLabel} active`);
66509
+ return "deferred-paused";
66510
+ }
66229
66511
  if (this.workflowRerunPending.has(taskId)) {
66230
66512
  executorLog.warn(`${taskId}: workflow rerun bounce already in flight \u2014 skipping re-entry`);
66231
66513
  return "skipped-pending";
@@ -66236,6 +66518,10 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66236
66518
  if (!latestTask) {
66237
66519
  throw new Error("task missing during workflow rerun bounce");
66238
66520
  }
66521
+ if (latestTask.paused) {
66522
+ executorLog.log(`${taskId}: workflow rerun deferred \u2014 task is paused`);
66523
+ return "deferred-paused";
66524
+ }
66239
66525
  if (latestTask.column === "in-progress") {
66240
66526
  const originalExecutionStartedAt = latestTask.executionStartedAt;
66241
66527
  if (preserveResumeState) {
@@ -66247,11 +66533,21 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66247
66533
  worktree: worktreePath,
66248
66534
  executionStartedAt: originalExecutionStartedAt ?? null
66249
66535
  });
66536
+ const pauseLabelAfterTodo = await this.getExecutionPauseLabel();
66537
+ if (pauseLabelAfterTodo) {
66538
+ executorLog.log(`${taskId}: workflow rerun parked in todo \u2014 ${pauseLabelAfterTodo} became active during bounce`);
66539
+ return "deferred-paused";
66540
+ }
66250
66541
  await this.store.moveTask(taskId, "in-progress");
66251
66542
  return "bounced";
66252
66543
  }
66253
66544
  if (latestTask.column === "todo") {
66254
66545
  await this.store.updateTask(taskId, { worktree: worktreePath });
66546
+ const pauseLabelBeforeResume = await this.getExecutionPauseLabel();
66547
+ if (pauseLabelBeforeResume) {
66548
+ executorLog.log(`${taskId}: workflow rerun parked in todo \u2014 ${pauseLabelBeforeResume} became active before resume`);
66549
+ return "deferred-paused";
66550
+ }
66255
66551
  await this.store.moveTask(taskId, "in-progress");
66256
66552
  return "bounced";
66257
66553
  }
@@ -66267,8 +66563,10 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66267
66563
  const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
66268
66564
  if (outcome === "bounced") {
66269
66565
  executorLog.log(successMessage);
66270
- } else {
66566
+ } else if (outcome === "skipped-pending") {
66271
66567
  executorLog.warn(`${taskId}: rerun bounce skipped \u2014 another bounce already in flight`);
66568
+ } else {
66569
+ executorLog.log(`${taskId}: rerun bounce deferred while pause is active`);
66272
66570
  }
66273
66571
  } catch (err) {
66274
66572
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -66277,6 +66575,11 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66277
66575
  }, 0);
66278
66576
  const watchdog = setTimeout(async () => {
66279
66577
  this.workflowRerunWatchdogs.delete(taskId);
66578
+ const pauseLabel = await this.getExecutionPauseLabel();
66579
+ if (pauseLabel) {
66580
+ executorLog.log(`${taskId}: workflow rerun watchdog skipped \u2014 ${pauseLabel} active`);
66581
+ return;
66582
+ }
66280
66583
  let currentTask = null;
66281
66584
  try {
66282
66585
  currentTask = await this.store.getTask(taskId);
@@ -66299,7 +66602,7 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66299
66602
  const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
66300
66603
  if (outcome === "bounced") {
66301
66604
  executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`);
66302
- } else {
66605
+ } else if (outcome === "skipped-pending") {
66303
66606
  executorLog.error(
66304
66607
  `${taskId}: workflow rerun watchdog retry skipped \u2014 original bounce still in flight after ${WORKFLOW_RERUN_WATCHDOG_MS / 1e3}s; task may be stuck`
66305
66608
  );
@@ -66307,6 +66610,8 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66307
66610
  taskId,
66308
66611
  `Workflow rerun watchdog retry skipped \u2014 original bounce still in flight after ${WORKFLOW_RERUN_WATCHDOG_MS / 1e3}s; task may be stuck`
66309
66612
  ).catch(() => void 0);
66613
+ } else {
66614
+ executorLog.log(`${taskId}: workflow rerun watchdog retry deferred while pause is active`);
66310
66615
  }
66311
66616
  } catch (err) {
66312
66617
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -66429,11 +66734,17 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66429
66734
  */
66430
66735
  async recoverCompletedTask(task) {
66431
66736
  try {
66432
- if (this.executing.has(task.id) || this.activeSessions.has(task.id) || this.activeStepExecutors.has(task.id) || this.resumingUnpaused.has(task.id)) {
66737
+ if (this.executing.has(task.id) || this.activeSessions.has(task.id) || this.activeStepExecutors.has(task.id) || this.activeWorkflowStepSessions.has(task.id) || this.resumingUnpaused.has(task.id)) {
66433
66738
  executorLog.log(`${task.id}: skipping recoverCompletedTask \u2014 task has active execution in flight`);
66434
66739
  return false;
66435
66740
  }
66436
66741
  const settings = await this.store.getSettings();
66742
+ if (settings.globalPause || settings.enginePaused) {
66743
+ executorLog.log(
66744
+ `${task.id}: skipping recoverCompletedTask \u2014 ${settings.globalPause ? "global pause" : "engine pause"} active`
66745
+ );
66746
+ return false;
66747
+ }
66437
66748
  if (task.worktree && existsSync27(task.worktree)) {
66438
66749
  const modifiedFiles = await this.captureModifiedFiles(task.worktree, task.baseCommitSha);
66439
66750
  if (modifiedFiles.length > 0) {
@@ -66441,7 +66752,16 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66441
66752
  executorLog.log(`${task.id}: recovered ${modifiedFiles.length} modified files`);
66442
66753
  }
66443
66754
  if (task.executionMode !== "fast") {
66755
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps during completed-task recovery")) {
66756
+ return false;
66757
+ }
66444
66758
  const workflowResult = await this.runWorkflowSteps(task, task.worktree, settings);
66759
+ if (workflowResult === "deferred-paused") {
66760
+ if (this.pausedAborted.has(task.id)) {
66761
+ this.pausedAborted.delete(task.id);
66762
+ }
66763
+ return false;
66764
+ }
66445
66765
  if (!workflowResult.allPassed) {
66446
66766
  await this.sendTaskBackForFix(task, task.worktree, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery", false);
66447
66767
  return true;
@@ -66450,6 +66770,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66450
66770
  executorLog.log(`${task.id}: fast mode \u2014 skipping workflow steps on auto-recovery`);
66451
66771
  }
66452
66772
  }
66773
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition during completed-task recovery")) {
66774
+ return false;
66775
+ }
66453
66776
  await this.persistTokenUsage(task.id);
66454
66777
  await this.store.moveTask(task.id, "in-review");
66455
66778
  this.clearCompletedTaskWatchdog(task.id);
@@ -66515,6 +66838,13 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66515
66838
  * directly to in-review without spawning a new agent session.
66516
66839
  */
66517
66840
  async resumeOrphaned() {
66841
+ const settings = await this.store.getSettings();
66842
+ if (settings.globalPause || settings.enginePaused) {
66843
+ executorLog.log(
66844
+ `resumeOrphaned skipped \u2014 ${settings.globalPause ? "global pause" : "engine pause"} is active`
66845
+ );
66846
+ return;
66847
+ }
66518
66848
  const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
66519
66849
  const inProgress = tasks.filter(
66520
66850
  (t) => t.column === "in-progress" && !this.executing.has(t.id) && !t.paused
@@ -66953,8 +67283,21 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66953
67283
  await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: modifiedFiles } });
66954
67284
  }
66955
67285
  this.scheduleCompletedTaskWatchdog(task.id, "step-session completion");
67286
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) {
67287
+ return;
67288
+ }
66956
67289
  if (executionMode !== "fast") {
66957
67290
  const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
67291
+ if (workflowResult === "deferred-paused") {
67292
+ if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
67293
+ this.pausedAborted.delete(task.id);
67294
+ return;
67295
+ }
67296
+ if (this.pausedAborted.has(task.id)) {
67297
+ this.pausedAborted.delete(task.id);
67298
+ }
67299
+ return;
67300
+ }
66958
67301
  if (!workflowResult.allPassed) {
66959
67302
  if (workflowResult.revisionRequested) {
66960
67303
  await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
@@ -66972,6 +67315,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
66972
67315
  await this.store.logEntry(task.id, "Fast mode \u2014 pre-merge workflow steps skipped", void 0, this.currentRunContext);
66973
67316
  }
66974
67317
  await this.store.updateTask(task.id, { workflowStepRetries: void 0, taskDoneRetryCount: null });
67318
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after step-session completion")) {
67319
+ return;
67320
+ }
66975
67321
  await this.store.moveTask(task.id, "in-review");
66976
67322
  this.clearCompletedTaskWatchdog(task.id);
66977
67323
  await audit.database({ type: "task:move", target: task.id, metadata: { to: "in-review" } });
@@ -67324,6 +67670,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67324
67670
  this.pausedAborted.delete(task.id);
67325
67671
  wasPaused = true;
67326
67672
  if (await this.shouldFinalizeCompletedTask(task.id, taskDone)) {
67673
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "paused after completion")) {
67674
+ return;
67675
+ }
67327
67676
  executorLog.log(`${task.id} paused after completion (graceful session exit) \u2014 finalizing to in-review`);
67328
67677
  await this.store.logEntry(task.id, "Execution paused after completion \u2014 finalizing to in-review");
67329
67678
  await this.persistTokenUsage(task.id);
@@ -67360,8 +67709,23 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67360
67709
  executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
67361
67710
  }
67362
67711
  this.scheduleCompletedTaskWatchdog(task.id, "task completion");
67712
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion")) {
67713
+ return;
67714
+ }
67363
67715
  if (executionMode !== "fast") {
67364
67716
  const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
67717
+ if (workflowResult === "deferred-paused") {
67718
+ if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
67719
+ this.pausedAborted.delete(task.id);
67720
+ wasPaused = true;
67721
+ return;
67722
+ }
67723
+ if (this.pausedAborted.has(task.id)) {
67724
+ this.pausedAborted.delete(task.id);
67725
+ wasPaused = true;
67726
+ }
67727
+ return;
67728
+ }
67365
67729
  if (!workflowResult.allPassed) {
67366
67730
  if (workflowResult.revisionRequested) {
67367
67731
  await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
@@ -67379,6 +67743,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67379
67743
  await this.store.logEntry(task.id, "Fast mode \u2014 pre-merge workflow steps skipped", void 0, this.currentRunContext);
67380
67744
  }
67381
67745
  await this.store.updateTask(task.id, { workflowStepRetries: void 0, taskDoneRetryCount: null });
67746
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion")) {
67747
+ return;
67748
+ }
67382
67749
  await this.persistTokenUsage(task.id);
67383
67750
  await this.store.moveTask(task.id, "in-review");
67384
67751
  this.clearCompletedTaskWatchdog(task.id);
@@ -67503,8 +67870,23 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67503
67870
  executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
67504
67871
  }
67505
67872
  this.scheduleCompletedTaskWatchdog(task.id, "task completion retry");
67873
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion retry")) {
67874
+ return;
67875
+ }
67506
67876
  if (executionMode !== "fast") {
67507
67877
  const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
67878
+ if (workflowResult === "deferred-paused") {
67879
+ if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
67880
+ this.pausedAborted.delete(task.id);
67881
+ wasPaused = true;
67882
+ return;
67883
+ }
67884
+ if (this.pausedAborted.has(task.id)) {
67885
+ this.pausedAborted.delete(task.id);
67886
+ wasPaused = true;
67887
+ }
67888
+ return;
67889
+ }
67508
67890
  if (!workflowResult.allPassed) {
67509
67891
  if (workflowResult.revisionRequested) {
67510
67892
  await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
@@ -67518,6 +67900,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67518
67900
  await this.store.logEntry(task.id, "Fast mode \u2014 pre-merge workflow steps skipped", void 0, this.currentRunContext);
67519
67901
  }
67520
67902
  await this.store.updateTask(task.id, { workflowStepRetries: void 0, taskDoneRetryCount: null });
67903
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion retry")) {
67904
+ return;
67905
+ }
67521
67906
  await this.persistTokenUsage(task.id);
67522
67907
  await this.store.moveTask(task.id, "in-review");
67523
67908
  this.clearCompletedTaskWatchdog(task.id);
@@ -67610,6 +67995,9 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67610
67995
  } else if (this.pausedAborted.has(task.id)) {
67611
67996
  this.pausedAborted.delete(task.id);
67612
67997
  if (await this.shouldFinalizeCompletedTask(task.id, taskDone)) {
67998
+ if (await this.shouldDeferCompletionForGlobalPause(task.id, "paused after completion")) {
67999
+ return;
68000
+ }
67613
68001
  executorLog.log(`${task.id} paused after completion \u2014 finalizing to in-review`);
67614
68002
  await this.store.logEntry(task.id, "Execution paused after completion \u2014 finalizing to in-review", void 0, this.currentRunContext);
67615
68003
  await this.persistTokenUsage(task.id);
@@ -67968,22 +68356,28 @@ The tool prevents your session from being killed by the inactivity watchdog duri
67968
68356
  if (params.summary) {
67969
68357
  await store.updateTask(taskId, { summary: params.summary });
67970
68358
  }
67971
- await store.updateTask(taskId, { paused: false, status: null });
68359
+ const settings = await store.getSettings();
68360
+ const hardPauseActive = Boolean(task.paused || settings.globalPause);
68361
+ if (hardPauseActive) {
68362
+ await store.updateTask(taskId, { status: null });
68363
+ } else {
68364
+ await store.updateTask(taskId, { paused: false, status: null });
68365
+ }
67972
68366
  await store.logEntry(taskId, "Task marked done by agent");
67973
68367
  const latestTask = await store.getTask(taskId);
67974
68368
  let latestColumn = latestTask.column;
67975
68369
  if (latestColumn === "todo") {
67976
68370
  await store.logEntry(
67977
68371
  taskId,
67978
- "fn_task_done called while task was in todo \u2014 promoting to in-progress before completion handoff"
68372
+ hardPauseActive ? "fn_task_done called while task was in todo during pause \u2014 promoting to in-progress for deferred completion handoff" : "fn_task_done called while task was in todo \u2014 promoting to in-progress before completion handoff"
67979
68373
  );
67980
68374
  await store.moveTask(taskId, "in-progress");
67981
68375
  latestColumn = "in-progress";
67982
68376
  }
67983
- if (latestColumn === "in-progress") {
68377
+ if (latestColumn === "in-progress" && !hardPauseActive) {
67984
68378
  this.scheduleCompletedTaskWatchdog(taskId, "fn_task_done");
67985
68379
  }
67986
- const successMessage = params.summary ? "Task marked complete with summary. All steps done. Moving to in-review." : "Task marked complete. All steps done. Moving to in-review.";
68380
+ const successMessage = hardPauseActive ? "Task marked complete. Completion handoff deferred until pause is cleared." : params.summary ? "Task marked complete with summary. All steps done. Moving to in-review." : "Task marked complete. All steps done. Moving to in-review.";
67987
68381
  return {
67988
68382
  content: [{ type: "text", text: successMessage }],
67989
68383
  details: {}
@@ -68558,6 +68952,9 @@ ${failureFeedback}
68558
68952
  await this.store.updateTask(task.id, { workflowStepResults: results });
68559
68953
  continue;
68560
68954
  }
68955
+ if (await this.shouldDeferWorkflowStepCompletion(task.id, `before workflow step '${ws.name}'`)) {
68956
+ return "deferred-paused";
68957
+ }
68561
68958
  await this.store.logEntry(task.id, `[pre-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
68562
68959
  executorLog.log(`${task.id} \u2014 [pre-merge] running workflow step: ${ws.name} (${stepMode} mode)`);
68563
68960
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -68572,6 +68969,9 @@ ${failureFeedback}
68572
68969
  await this.store.updateTask(task.id, { workflowStepResults: results });
68573
68970
  try {
68574
68971
  const result = stepMode === "script" ? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings) : await this.executeWorkflowStep(task, ws, worktreePath, settings);
68972
+ if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) {
68973
+ return "deferred-paused";
68974
+ }
68575
68975
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
68576
68976
  if (result.success) {
68577
68977
  await this.store.logEntry(task.id, `[timing] Workflow step '${ws.name}' completed in ${Date.now() - stepStartedAtMs}ms`);
@@ -68637,6 +69037,9 @@ ${failureFeedback}
68637
69037
  };
68638
69038
  }
68639
69039
  } catch (err) {
69040
+ if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) {
69041
+ return "deferred-paused";
69042
+ }
68640
69043
  const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err);
68641
69044
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
68642
69045
  await this.store.logEntry(
@@ -68802,6 +69205,7 @@ and show an appropriate message to the user.\`
68802
69205
  task.id,
68803
69206
  `Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`
68804
69207
  );
69208
+ this.activeWorkflowStepSessions.set(task.id, session);
68805
69209
  let output = "";
68806
69210
  session.subscribe((event) => {
68807
69211
  if (event.type === "message_update") {
@@ -68874,6 +69278,10 @@ Review the work done in this worktree and evaluate it against the criteria in yo
68874
69278
  return { success: false, error: errorMessage };
68875
69279
  } finally {
68876
69280
  if (timeoutHandle) clearTimeout(timeoutHandle);
69281
+ const activeWorkflowStepSession = this.activeWorkflowStepSessions.get(task.id);
69282
+ if (activeWorkflowStepSession === session) {
69283
+ this.activeWorkflowStepSessions.delete(task.id);
69284
+ }
68877
69285
  void timedOut;
68878
69286
  }
68879
69287
  };
@@ -70617,6 +71025,15 @@ var init_scheduler = __esm({
70617
71025
  schedulerLog.log(`Task ${task.id} is paused \u2014 skipping dispatch`);
70618
71026
  continue;
70619
71027
  }
71028
+ const latestSettings = await this.store.getSettings();
71029
+ if (latestSettings.globalPause) {
71030
+ schedulerLog.log(`Task ${task.id} dispatch aborted \u2014 globalPause became active mid-pass`);
71031
+ continue;
71032
+ }
71033
+ if (latestSettings.enginePaused) {
71034
+ schedulerLog.log(`Task ${task.id} dispatch aborted \u2014 enginePaused became active mid-pass`);
71035
+ continue;
71036
+ }
70620
71037
  let effectiveNode = resolveEffectiveNode(freshTask, settings);
70621
71038
  schedulerLog.log(`Task ${task.id} routed to node=${effectiveNode.nodeId ?? "local"} (source=${effectiveNode.source})`);
70622
71039
  if (effectiveNode.nodeId !== void 0 && this.options.nodeHealthMonitor) {
@@ -75754,6 +76171,36 @@ function execCommand(command, options) {
75754
76171
  });
75755
76172
  });
75756
76173
  }
76174
+ function isInProcessBackupCommand(command) {
76175
+ if (!command) return false;
76176
+ const trimmed = command.trim();
76177
+ if (!trimmed) return false;
76178
+ if (SHELL_METACHARACTERS_REGEX.test(trimmed)) return false;
76179
+ const tokens = trimmed.split(/\s+/).map((tok) => tok.toLowerCase());
76180
+ let cursor = 0;
76181
+ if (tokens[cursor] === "npx") {
76182
+ cursor += 1;
76183
+ while (cursor < tokens.length) {
76184
+ const tok = tokens[cursor];
76185
+ if (tok === void 0 || !tok.startsWith("-")) break;
76186
+ const takesValue = (tok === "-p" || tok === "--package") && cursor + 1 < tokens.length && tokens[cursor + 1] !== void 0 && !tokens[cursor + 1].startsWith("-");
76187
+ cursor += takesValue ? 2 : 1;
76188
+ }
76189
+ }
76190
+ const binary = tokens[cursor];
76191
+ if (!binary || !FUSION_BINARY_TOKENS.has(binary)) return false;
76192
+ cursor += 1;
76193
+ if (tokens[cursor] !== "backup") return false;
76194
+ cursor += 1;
76195
+ if (tokens[cursor] !== "--create") return false;
76196
+ cursor += 1;
76197
+ for (; cursor < tokens.length; cursor += 1) {
76198
+ const tok = tokens[cursor];
76199
+ if (!tok) continue;
76200
+ if (!tok.startsWith("-")) return false;
76201
+ }
76202
+ return true;
76203
+ }
75757
76204
  async function createAiPromptExecutor(cwd) {
75758
76205
  const disposeLog = createLogger2("cron-runner");
75759
76206
  return async (prompt, modelProvider, modelId) => {
@@ -75793,7 +76240,7 @@ function truncateOutput(stdout, stderr) {
75793
76240
  }
75794
76241
  return combined;
75795
76242
  }
75796
- var log14, DEFAULT_TIMEOUT_MS6, MAX_BUFFER, MAX_OUTPUT_LENGTH, DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS, CronRunner, AI_AUTOMATION_SYSTEM_PROMPT;
76243
+ var log14, FUSION_BINARY_TOKENS, SHELL_METACHARACTERS_REGEX, DEFAULT_TIMEOUT_MS6, MAX_BUFFER, MAX_OUTPUT_LENGTH, DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS, CronRunner, AI_AUTOMATION_SYSTEM_PROMPT;
75797
76244
  var init_cron_runner = __esm({
75798
76245
  "../engine/src/cron-runner.ts"() {
75799
76246
  "use strict";
@@ -75802,6 +76249,14 @@ var init_cron_runner = __esm({
75802
76249
  init_shell_utils();
75803
76250
  init_pi();
75804
76251
  log14 = createLogger2("cron-runner");
76252
+ FUSION_BINARY_TOKENS = /* @__PURE__ */ new Set([
76253
+ "fn",
76254
+ "fusion",
76255
+ "runfusion",
76256
+ "runfusion.ai",
76257
+ "@runfusion/fusion"
76258
+ ]);
76259
+ SHELL_METACHARACTERS_REGEX = /[&|;<>`$()]/;
75805
76260
  DEFAULT_TIMEOUT_MS6 = 5 * 60 * 1e3;
75806
76261
  MAX_BUFFER = 1024 * 1024;
75807
76262
  MAX_OUTPUT_LENGTH = 10 * 1024;
@@ -75942,6 +76397,9 @@ var init_cron_runner = __esm({
75942
76397
  */
75943
76398
  async executeLegacyCommand(schedule, startedAt) {
75944
76399
  log14.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`);
76400
+ if (isInProcessBackupCommand(schedule.command)) {
76401
+ return this.executeBackupInProcess(schedule, startedAt);
76402
+ }
75945
76403
  try {
75946
76404
  const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS6;
75947
76405
  const { stdout, stderr } = await execCommand(schedule.command, {
@@ -75972,6 +76430,47 @@ var init_cron_runner = __esm({
75972
76430
  };
75973
76431
  }
75974
76432
  }
76433
+ /**
76434
+ * Run an auto-backup schedule in-process via the engine's open TaskStore,
76435
+ * bypassing the shell-out that would otherwise invoke an outdated fusion
76436
+ * binary on PATH. See `isInProcessBackupCommand` for the matching contract.
76437
+ */
76438
+ async executeBackupInProcess(schedule, startedAt) {
76439
+ const action = await this.runBackupActionInProcess();
76440
+ if (action.success) {
76441
+ log14.log(`\u2713 ${schedule.name} completed in-process`);
76442
+ } else {
76443
+ log14.warn(`\u2717 ${schedule.name} in-process backup ${action.error ? `threw: ${action.error}` : `reported failure: ${action.output}`}`);
76444
+ }
76445
+ return {
76446
+ success: action.success,
76447
+ output: action.output,
76448
+ error: action.error,
76449
+ startedAt,
76450
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
76451
+ };
76452
+ }
76453
+ /**
76454
+ * Shared in-process backup execution used by both the legacy-command path
76455
+ * and the command-step path. Returns the success/output/error tuple in
76456
+ * a shape that callers can wrap into either a run or a step result.
76457
+ */
76458
+ async runBackupActionInProcess() {
76459
+ try {
76460
+ const { runBackupCommand: runBackupCommand2 } = await Promise.resolve().then(() => (init_src(), src_exports));
76461
+ const fusionDir = this.store.getFusionDir();
76462
+ const settings = await this.store.getSettings();
76463
+ const result = await runBackupCommand2(fusionDir, settings);
76464
+ return {
76465
+ success: result.success,
76466
+ output: truncateOutput(result.output ?? "", ""),
76467
+ error: result.success ? void 0 : result.output
76468
+ };
76469
+ } catch (err) {
76470
+ const message = err instanceof Error ? err.message : String(err);
76471
+ return { success: false, output: "", error: message };
76472
+ }
76473
+ }
75975
76474
  /**
75976
76475
  * Execute multiple steps sequentially.
75977
76476
  * Aggregates per-step results into an overall AutomationRunResult.
@@ -76060,6 +76559,19 @@ var init_cron_runner = __esm({
76060
76559
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
76061
76560
  };
76062
76561
  }
76562
+ if (isInProcessBackupCommand(step.command)) {
76563
+ const action = await this.runBackupActionInProcess();
76564
+ return {
76565
+ stepId: step.id,
76566
+ stepName: step.name,
76567
+ stepIndex,
76568
+ success: action.success,
76569
+ output: action.output,
76570
+ error: action.error,
76571
+ startedAt,
76572
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
76573
+ };
76574
+ }
76063
76575
  try {
76064
76576
  const { stdout, stderr } = await execCommand(step.command, {
76065
76577
  timeout: timeoutMs,
@@ -76251,6 +76763,7 @@ var init_routine_runner = __esm({
76251
76763
  "../engine/src/routine-runner.ts"() {
76252
76764
  "use strict";
76253
76765
  import_cron_parser4 = __toESM(require_dist2(), 1);
76766
+ init_cron_runner();
76254
76767
  init_logger2();
76255
76768
  init_shell_utils();
76256
76769
  log15 = createLogger2("routine-runner");
@@ -76408,6 +76921,30 @@ var init_routine_runner = __esm({
76408
76921
  return this.executeCommand(routine.command ?? "", routine.timeoutMs, startedAt);
76409
76922
  }
76410
76923
  async executeCommand(command, timeoutMs, startedAt) {
76924
+ if (isInProcessBackupCommand(command) && this.options.taskStore) {
76925
+ try {
76926
+ const { runBackupCommand: runBackupCommand2 } = await Promise.resolve().then(() => (init_src(), src_exports));
76927
+ const fusionDir = this.options.taskStore.getFusionDir();
76928
+ const settings = await this.options.taskStore.getSettings();
76929
+ const result = await runBackupCommand2(fusionDir, settings);
76930
+ return {
76931
+ success: result.success,
76932
+ output: truncateOutput2(result.output ?? "", ""),
76933
+ error: result.success ? void 0 : result.output,
76934
+ startedAt,
76935
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
76936
+ };
76937
+ } catch (err) {
76938
+ const message = err instanceof Error ? err.message : String(err);
76939
+ return {
76940
+ success: false,
76941
+ output: "",
76942
+ error: message,
76943
+ startedAt,
76944
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
76945
+ };
76946
+ }
76947
+ }
76411
76948
  try {
76412
76949
  const { stdout, stderr } = await execAsync6(command, {
76413
76950
  timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS7,
@@ -77199,6 +77736,13 @@ var init_self_healing = __esm({
77199
77736
  * stale in-progress/planning tasks that no longer have a live worker.
77200
77737
  */
77201
77738
  async runStartupRecovery() {
77739
+ const settings = await this.store.getSettings();
77740
+ if (settings.globalPause || settings.enginePaused) {
77741
+ log16.log(
77742
+ `Startup recovery skipped \u2014 ${settings.globalPause ? "global pause" : "engine pause"} is active`
77743
+ );
77744
+ return;
77745
+ }
77202
77746
  const steps = [
77203
77747
  { name: "no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures().then(() => void 0) },
77204
77748
  { name: "completed-tasks", fn: () => this.recoverCompletedTasks().then(() => void 0) },
@@ -77549,27 +78093,34 @@ var init_self_healing = __esm({
77549
78093
  log16.error(`Maintenance batch 1 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
77550
78094
  }
77551
78095
  }
77552
- const batch2Fns = [
77553
- { name: "recover-completed-tasks", fn: () => this.recoverCompletedTasks() },
77554
- { name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() },
77555
- { name: "recover-failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps() },
77556
- { name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
77557
- { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
77558
- { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
77559
- { name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() },
77560
- { name: "recover-no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures() },
77561
- { name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
77562
- { name: "recover-orphaned-executions", fn: () => this.recoverOrphanedExecutions() },
77563
- { name: "recover-approved-triage", fn: () => this.recoverApprovedTriageTasks() },
77564
- { name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
77565
- { name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() }
77566
- ];
77567
- for (const fn of batch2Fns) {
77568
- try {
77569
- await fn.fn();
77570
- log16.log(`Maintenance batch 2 step "${fn.name}" succeeded`);
77571
- } catch (stepErr) {
77572
- log16.error(`Maintenance batch 2 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
78096
+ const recoverySettings = await this.store.getSettings();
78097
+ if (recoverySettings.globalPause || recoverySettings.enginePaused) {
78098
+ log16.log(
78099
+ `Maintenance batch 2 skipped \u2014 ${recoverySettings.globalPause ? "global pause" : "engine pause"} is active`
78100
+ );
78101
+ } else {
78102
+ const batch2Fns = [
78103
+ { name: "recover-completed-tasks", fn: () => this.recoverCompletedTasks() },
78104
+ { name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() },
78105
+ { name: "recover-failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps() },
78106
+ { name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
78107
+ { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
78108
+ { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
78109
+ { name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() },
78110
+ { name: "recover-no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures() },
78111
+ { name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
78112
+ { name: "recover-orphaned-executions", fn: () => this.recoverOrphanedExecutions() },
78113
+ { name: "recover-approved-triage", fn: () => this.recoverApprovedTriageTasks() },
78114
+ { name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
78115
+ { name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() }
78116
+ ];
78117
+ for (const fn of batch2Fns) {
78118
+ try {
78119
+ await fn.fn();
78120
+ log16.log(`Maintenance batch 2 step "${fn.name}" succeeded`);
78121
+ } catch (stepErr) {
78122
+ log16.error(`Maintenance batch 2 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
78123
+ }
77573
78124
  }
77574
78125
  }
77575
78126
  const batch3Fns = [
@@ -79302,6 +79853,10 @@ var init_in_process_runtime = __esm({
79302
79853
  * before `start()` via `setMergeEnqueuer`.
79303
79854
  */
79304
79855
  mergeEnqueuer;
79856
+ /** Tracks whether startup recovery was intentionally deferred due to pause state. */
79857
+ startupRecoveryDeferred = false;
79858
+ /** Prevent duplicate unpause recovery dispatches from racing each other. */
79859
+ resumeAfterUnpauseRunning = false;
79305
79860
  /**
79306
79861
  * Start the runtime and initialize all subsystems.
79307
79862
  *
@@ -79335,7 +79890,7 @@ var init_in_process_runtime = __esm({
79335
79890
  runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`);
79336
79891
  }
79337
79892
  this.messageStore = new MessageStoreClass(this.taskStore.getDatabase());
79338
- this.pluginStore = new PluginStoreClass(this.taskStore.getFusionDir());
79893
+ this.pluginStore = new PluginStoreClass(this.config.workingDirectory);
79339
79894
  await this.pluginStore.init();
79340
79895
  this.pluginLoader = new PluginLoaderClass({
79341
79896
  pluginStore: this.pluginStore,
@@ -79727,11 +80282,16 @@ var init_in_process_runtime = __esm({
79727
80282
  this.selfHealingManager.start();
79728
80283
  this.stuckTaskDetector.start();
79729
80284
  this.setupEventForwarding();
79730
- await this.selfHealingManager.recoverNoProgressNoTaskDoneFailures();
79731
- await this.executor.resumeOrphaned();
79732
- void this.selfHealingManager.runStartupRecovery().catch((err) => {
79733
- runtimeLog.error("Self-healing startup recovery failed:", err);
79734
- });
80285
+ const startupSettings = await this.taskStore.getSettings();
80286
+ if (startupSettings.globalPause || startupSettings.enginePaused) {
80287
+ this.startupRecoveryDeferred = true;
80288
+ runtimeLog.log(
80289
+ `Startup recovery deferred \u2014 ${startupSettings.globalPause ? "global pause" : "engine pause"} is active`
80290
+ );
80291
+ } else {
80292
+ this.startupRecoveryDeferred = false;
80293
+ await this.resumeStartupRecoverySequence();
80294
+ }
79735
80295
  this.scheduler.start();
79736
80296
  this.triageProcessor?.start();
79737
80297
  this.missionExecutionLoop = missionExecutionLoop;
@@ -79897,6 +80457,45 @@ var init_in_process_runtime = __esm({
79897
80457
  setMergeEnqueuer(enqueueMerge) {
79898
80458
  this.mergeEnqueuer = enqueueMerge;
79899
80459
  }
80460
+ /**
80461
+ * Resume executor/self-healing activity after an unpause transition.
80462
+ *
80463
+ * When startup recovery had been deferred, this replays the original startup
80464
+ * ordering so orphan resume and self-healing cannot race each other.
80465
+ */
80466
+ async resumeAfterUnpause() {
80467
+ if (!this.taskStore || !this.executor || !this.selfHealingManager) {
80468
+ return;
80469
+ }
80470
+ if (this.resumeAfterUnpauseRunning) {
80471
+ return;
80472
+ }
80473
+ this.resumeAfterUnpauseRunning = true;
80474
+ try {
80475
+ const settings = await this.taskStore.getSettings();
80476
+ if (settings.globalPause || settings.enginePaused) {
80477
+ runtimeLog.log(
80478
+ `Unpause recovery still blocked \u2014 ${settings.globalPause ? "global pause" : "engine pause"} remains active`
80479
+ );
80480
+ return;
80481
+ }
80482
+ if (this.startupRecoveryDeferred) {
80483
+ await this.resumeStartupRecoverySequence();
80484
+ this.startupRecoveryDeferred = false;
80485
+ return;
80486
+ }
80487
+ await this.executor.resumeOrphaned();
80488
+ } finally {
80489
+ this.resumeAfterUnpauseRunning = false;
80490
+ }
80491
+ }
80492
+ async resumeStartupRecoverySequence() {
80493
+ await this.selfHealingManager.recoverNoProgressNoTaskDoneFailures();
80494
+ await this.executor.resumeOrphaned();
80495
+ void this.selfHealingManager.runStartupRecovery().catch((err) => {
80496
+ runtimeLog.error("Self-healing startup recovery failed:", err);
80497
+ });
80498
+ }
79900
80499
  /**
79901
80500
  * Get the project's TaskStore instance.
79902
80501
  * @throws Error if runtime has not been started
@@ -83736,13 +84335,13 @@ ${detail}`
83736
84335
  if (prev.globalPause && !s.globalPause) {
83737
84336
  runtimeLog.log("Global unpause \u2014 resuming agentic activity");
83738
84337
  try {
83739
- const executor = this.runtime.executor;
83740
- executor?.resumeOrphaned?.().catch(
83741
- (err) => runtimeLog.error("Failed to resume orphaned tasks on unpause:", err)
84338
+ const runtime = this.runtime;
84339
+ runtime.resumeAfterUnpause?.().catch(
84340
+ (err) => runtimeLog.error("Failed to resume agentic activity on unpause:", err)
83742
84341
  );
83743
84342
  } catch (err) {
83744
84343
  runtimeLog.warn(
83745
- `Global unpause: failed to dispatch resumeOrphaned: ${err instanceof Error ? err.message : String(err)}`
84344
+ `Global unpause: failed to dispatch resumeAfterUnpause: ${err instanceof Error ? err.message : String(err)}`
83746
84345
  );
83747
84346
  }
83748
84347
  if (s.autoMerge) {
@@ -83766,13 +84365,13 @@ ${detail}`
83766
84365
  if (prev.enginePaused && !s.enginePaused) {
83767
84366
  runtimeLog.log("Engine unpaused \u2014 resuming agentic activity");
83768
84367
  try {
83769
- const executor = this.runtime.executor;
83770
- executor?.resumeOrphaned?.().catch(
83771
- (err) => runtimeLog.error("Failed to resume orphaned tasks on engine unpause:", err)
84368
+ const runtime = this.runtime;
84369
+ runtime.resumeAfterUnpause?.().catch(
84370
+ (err) => runtimeLog.error("Failed to resume agentic activity on engine unpause:", err)
83772
84371
  );
83773
84372
  } catch (err) {
83774
84373
  runtimeLog.warn(
83775
- `Engine unpause: failed to dispatch resumeOrphaned: ${err instanceof Error ? err.message : String(err)}`
84374
+ `Engine unpause: failed to dispatch resumeAfterUnpause: ${err instanceof Error ? err.message : String(err)}`
83776
84375
  );
83777
84376
  }
83778
84377
  if (s.autoMerge) {
@@ -88894,7 +89493,7 @@ var init_src3 = __esm({
88894
89493
  });
88895
89494
 
88896
89495
  // ../../plugins/fusion-plugin-hermes-runtime/dist/cli-spawn.js
88897
- import { spawn as spawn6, spawnSync } from "node:child_process";
89496
+ import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
88898
89497
  import os2 from "node:os";
88899
89498
  import path, { sep as PATH_SEP } from "node:path";
88900
89499
  function resolveBinaryForSpawn(binary) {
@@ -88907,7 +89506,7 @@ function resolveBinaryForSpawn(binary) {
88907
89506
  if (cached)
88908
89507
  return cached;
88909
89508
  try {
88910
- const result = spawnSync("where", [binary], { encoding: "utf-8" });
89509
+ const result = spawnSync2("where", [binary], { encoding: "utf-8" });
88911
89510
  if (result.status === 0) {
88912
89511
  const first = (result.stdout ?? "").trim().split(/\r?\n/)[0];
88913
89512
  if (first?.length) {
@@ -123889,7 +124488,7 @@ var require_commonjs4 = __commonJS({
123889
124488
  var node_url_1 = __require("node:url");
123890
124489
  var fs_1 = __require("fs");
123891
124490
  var actualFS = __importStar(__require("node:fs"));
123892
- var realpathSync2 = fs_1.realpathSync.native;
124491
+ var realpathSync3 = fs_1.realpathSync.native;
123893
124492
  var promises_1 = __require("node:fs/promises");
123894
124493
  var minipass_1 = require_commonjs3();
123895
124494
  var defaultFS = {
@@ -123897,7 +124496,7 @@ var require_commonjs4 = __commonJS({
123897
124496
  readdir: fs_1.readdir,
123898
124497
  readdirSync: fs_1.readdirSync,
123899
124498
  readlinkSync: fs_1.readlinkSync,
123900
- realpathSync: realpathSync2,
124499
+ realpathSync: realpathSync3,
123901
124500
  promises: {
123902
124501
  lstat: promises_1.lstat,
123903
124502
  readdir: promises_1.readdir,
@@ -136670,7 +137269,7 @@ Rules:
136670
137269
  // ../dashboard/src/routes/register-agent-import-export-generation-routes.ts
136671
137270
  import { createWriteStream } from "node:fs";
136672
137271
  import * as fsPromises2 from "node:fs/promises";
136673
- import { tmpdir as tmpdir3 } from "node:os";
137272
+ import { tmpdir as tmpdir4 } from "node:os";
136674
137273
  import { join as join45, resolve as resolve22 } from "node:path";
136675
137274
  import { Readable } from "node:stream";
136676
137275
  import { pipeline as streamPipeline } from "node:stream/promises";
@@ -136712,7 +137311,7 @@ function registerAgentImportExportRoutes(ctx) {
136712
137311
  } else if (typeof outputDir === "string") {
136713
137312
  throw badRequest("outputDir cannot be empty");
136714
137313
  } else {
136715
- resolvedOutputDir = await mkdtemp(join45(tmpdir3(), "fusion-agent-export-"));
137314
+ resolvedOutputDir = await mkdtemp(join45(tmpdir4(), "fusion-agent-export-"));
136716
137315
  }
136717
137316
  const result = await exportAgentsToDirectory2(agentsToExport, resolvedOutputDir, {
136718
137317
  companyName: typeof companyName === "string" ? companyName : void 0,
@@ -137027,7 +137626,7 @@ ${body}`;
137027
137626
  const archiveUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/main.tar.gz`;
137028
137627
  let tempDir = null;
137029
137628
  try {
137030
- tempDir = await mkdtemp(join45(tmpdir3(), `fn-agent-import-${importCompanySlug}-`));
137629
+ tempDir = await mkdtemp(join45(tmpdir4(), `fn-agent-import-${importCompanySlug}-`));
137031
137630
  const archivePath = join45(tempDir, "archive.tar.gz");
137032
137631
  const downloadController = new AbortController();
137033
137632
  const downloadTimeout = setTimeout(() => downloadController.abort(), 3e4);
@@ -141554,6 +142153,21 @@ ${stderr}`;
141554
142153
  });
141555
142154
  });
141556
142155
  }
142156
+ function buildSkippedStatusPayload(expectedVersion) {
142157
+ return {
142158
+ binary: {
142159
+ installed: false,
142160
+ invocation: FN_INSTALL_NPM
142161
+ },
142162
+ expectedVersion,
142163
+ state: "skipped",
142164
+ install: {
142165
+ npm: FN_INSTALL_NPM,
142166
+ curl: FN_INSTALL_CURL,
142167
+ package: FN_NPM_PACKAGE
142168
+ }
142169
+ };
142170
+ }
141557
142171
  var INSTALL_TIMEOUT_MS, MAX_OUTPUT_BYTES2, registerFnBinaryRoutes;
141558
142172
  var init_register_fn_binary_routes = __esm({
141559
142173
  "../dashboard/src/routes/register-fn-binary-routes.ts"() {
@@ -141564,11 +142178,23 @@ var init_register_fn_binary_routes = __esm({
141564
142178
  INSTALL_TIMEOUT_MS = 18e4;
141565
142179
  MAX_OUTPUT_BYTES2 = 64 * 1024;
141566
142180
  registerFnBinaryRoutes = (ctx) => {
141567
- const { router, rethrowAsApiError: rethrowAsApiError8 } = ctx;
142181
+ const { router, rethrowAsApiError: rethrowAsApiError8, store } = ctx;
142182
+ async function isCheckEnabled() {
142183
+ try {
142184
+ const settings = await store.getSettings();
142185
+ return settings.fnBinaryCheckEnabled !== false;
142186
+ } catch {
142187
+ return true;
142188
+ }
142189
+ }
141568
142190
  router.get("/system/fn-binary/status", async (_req, res) => {
141569
142191
  try {
141570
- const binary = await detectFnBinary();
141571
142192
  const expectedVersion = getCliPackageVersion();
142193
+ if (!await isCheckEnabled()) {
142194
+ res.json(buildSkippedStatusPayload(expectedVersion));
142195
+ return;
142196
+ }
142197
+ const binary = await detectFnBinary();
141572
142198
  res.json(buildStatusPayload(binary, expectedVersion));
141573
142199
  } catch (err) {
141574
142200
  if (err instanceof ApiError) throw err;
@@ -141577,6 +142203,13 @@ var init_register_fn_binary_routes = __esm({
141577
142203
  });
141578
142204
  router.post("/system/fn-binary/install", async (_req, res) => {
141579
142205
  try {
142206
+ if (!await isCheckEnabled()) {
142207
+ throw new ApiError(
142208
+ 409,
142209
+ "fn-binary checks are disabled in global settings (fnBinaryCheckEnabled=false). Re-enable them to install via the dashboard.",
142210
+ { code: "FN_BINARY_CHECK_DISABLED" }
142211
+ );
142212
+ }
141580
142213
  const installResult = await runNpmInstall();
141581
142214
  const binary = await detectFnBinary();
141582
142215
  const expectedVersion = getCliPackageVersion();
@@ -149386,6 +150019,10 @@ Description: ${step.description}`
149386
150019
  const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
149387
150020
  res.json(slots);
149388
150021
  });
150022
+ router.get("/plugins/dashboard-views", async (_req, res) => {
150023
+ const views = options?.pluginLoader?.getPluginDashboardViews() ?? [];
150024
+ res.json(views);
150025
+ });
149389
150026
  router.get("/plugins/runtimes", async (_req, res) => {
149390
150027
  const runtimes2 = options?.pluginLoader?.getPluginRuntimes() ?? [];
149391
150028
  const installed = runtimes2.map(({ pluginId, runtime }) => ({
@@ -155723,6 +156360,43 @@ data: ${JSON.stringify(entry)}
155723
156360
  scopedStore.off("agent:log", onAgentLog);
155724
156361
  });
155725
156362
  });
156363
+ app.get("/api/agents/:id/runs/:runId/logs/stream", async (req, res) => {
156364
+ const agentId = req.params.id;
156365
+ const runId = req.params.runId;
156366
+ const projectId = typeof req.query.projectId === "string" ? req.query.projectId : void 0;
156367
+ res.setHeader("Content-Type", "text/event-stream");
156368
+ res.setHeader("Cache-Control", "no-cache");
156369
+ res.setHeader("Connection", "keep-alive");
156370
+ res.setHeader("X-Accel-Buffering", "no");
156371
+ res.flushHeaders();
156372
+ res.write(": connected\n\n");
156373
+ const engineManager = options?.engineManager;
156374
+ const engine2 = engineManager && projectId ? engineManager.getEngine(projectId) : options?.engine;
156375
+ const agentStore = engine2?.getAgentStore();
156376
+ if (!agentStore) {
156377
+ res.write(`event: error
156378
+ data: ${JSON.stringify({ message: "No active engine for project" })}
156379
+
156380
+ `);
156381
+ res.end();
156382
+ return;
156383
+ }
156384
+ const onRunLog = (eventAgentId, eventRunId, entry) => {
156385
+ if (eventAgentId !== agentId || eventRunId !== runId) return;
156386
+ res.write(`event: agent:log
156387
+ data: ${JSON.stringify(entry)}
156388
+
156389
+ `);
156390
+ };
156391
+ agentStore.on("run:log", onRunLog);
156392
+ const heartbeat = setInterval(() => {
156393
+ res.write(": heartbeat\n\n");
156394
+ }, 3e4);
156395
+ req.on("close", () => {
156396
+ clearInterval(heartbeat);
156397
+ agentStore.off("run:log", onRunLog);
156398
+ });
156399
+ });
155726
156400
  app.get("/api/terminal/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), (req, res) => {
155727
156401
  const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
155728
156402
  res.setHeader("Content-Type", "text/event-stream");
@@ -157556,7 +158230,8 @@ async function clearDefaultProject(globalDir) {
157556
158230
  await globalStore.updateSettings(rest);
157557
158231
  }
157558
158232
  async function detectProjectFromCwd(cwd, central) {
157559
- let currentDir = resolve27(cwd);
158233
+ const startDir = resolve27(cwd);
158234
+ let currentDir = startDir;
157560
158235
  while (true) {
157561
158236
  const kbPath = resolve27(currentDir, ".fusion", "fusion.db");
157562
158237
  if (isValidSqliteDatabaseFile(kbPath)) {
@@ -157564,11 +158239,13 @@ async function detectProjectFromCwd(cwd, central) {
157564
158239
  if (project) {
157565
158240
  return project;
157566
158241
  }
157567
- return {
157568
- id: "",
157569
- name: basename16(currentDir) || "current-project",
157570
- path: currentDir
157571
- };
158242
+ if (currentDir === startDir) {
158243
+ return {
158244
+ id: "",
158245
+ name: basename16(currentDir) || "current-project",
158246
+ path: currentDir
158247
+ };
158248
+ }
157572
158249
  }
157573
158250
  const parentDir = dirname21(currentDir);
157574
158251
  if (parentDir === currentDir) {
@@ -158348,7 +159025,7 @@ var app_exports = {};
158348
159025
  __export(app_exports, {
158349
159026
  DashboardApp: () => DashboardApp
158350
159027
  });
158351
- import { useState as useState2, useSyncExternalStore, useCallback as useCallback2, useEffect as useEffect2 } from "react";
159028
+ import { useState as useState2, useSyncExternalStore, useCallback as useCallback2, useEffect as useEffect2, useRef } from "react";
158352
159029
  import { Box, Text, useInput, useApp, useStdout } from "ink";
158353
159030
  import Spinner from "ink-spinner";
158354
159031
  import TextInput from "ink-text-input";
@@ -159130,7 +159807,8 @@ function formatLogTime(iso) {
159130
159807
  function TaskDetailScreen({
159131
159808
  task,
159132
159809
  projectPath,
159133
- interactiveData
159810
+ interactiveData,
159811
+ controller
159134
159812
  }) {
159135
159813
  const { stdout } = useStdout();
159136
159814
  const cols = stdout?.columns ?? 80;
@@ -159195,6 +159873,27 @@ function TaskDetailScreen({
159195
159873
  useEffect2(() => {
159196
159874
  if (autoFollow) setLogScrollOffset(0);
159197
159875
  }, [autoFollow, logCount]);
159876
+ const WHEEL_STEP = 3;
159877
+ const logCountRef = useRef(logCount);
159878
+ const logPaneRowsRef = useRef(logPaneRows);
159879
+ logCountRef.current = logCount;
159880
+ logPaneRowsRef.current = logPaneRows;
159881
+ useEffect2(() => {
159882
+ return controller.onWheel((dir2) => {
159883
+ const maxOffset = Math.max(0, logCountRef.current - logPaneRowsRef.current);
159884
+ if (maxOffset === 0) return;
159885
+ if (dir2 === "up") {
159886
+ setAutoFollow(false);
159887
+ setLogScrollOffset((o) => Math.min(maxOffset, o + WHEEL_STEP));
159888
+ } else {
159889
+ setLogScrollOffset((o) => {
159890
+ const next = Math.max(0, o - WHEEL_STEP);
159891
+ if (next === 0) setAutoFollow(true);
159892
+ return next;
159893
+ });
159894
+ }
159895
+ });
159896
+ }, [controller]);
159198
159897
  useInput((input, key) => {
159199
159898
  if (detail && detail !== "unavailable" && detail.recentLogs.length > 0) {
159200
159899
  const maxOffset = Math.max(0, detail.recentLogs.length - logPaneRows);
@@ -159565,7 +160264,8 @@ function BoardView({ state, controller }) {
159565
160264
  {
159566
160265
  task: selectedTask,
159567
160266
  projectPath: selectedProject?.path ?? null,
159568
- interactiveData: state.interactiveData
160267
+ interactiveData: state.interactiveData,
160268
+ controller
159569
160269
  }
159570
160270
  ) }) : tasksState.loading ? /* @__PURE__ */ jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, gap: 1, children: [
159571
160271
  /* @__PURE__ */ jsx(Text, { color: "white", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
@@ -160387,7 +161087,7 @@ function PushModal({
160387
161087
  }
160388
161088
  );
160389
161089
  }
160390
- function GitView({ state }) {
161090
+ function GitView({ state, controller }) {
160391
161091
  const { stdout } = useStdout();
160392
161092
  const cols = stdout?.columns ?? 80;
160393
161093
  const data = state.interactiveData;
@@ -160575,6 +161275,23 @@ function GitView({ state }) {
160575
161275
  }
160576
161276
  }
160577
161277
  });
161278
+ const gitWheelRef = useRef({ activePane, commits, branches, worktrees });
161279
+ gitWheelRef.current = { activePane, commits, branches, worktrees };
161280
+ useEffect2(() => {
161281
+ if (state.interactiveView !== "git") return;
161282
+ return controller.onWheel((dir2) => {
161283
+ const { activePane: pane, commits: cs, branches: bs, worktrees: ws } = gitWheelRef.current;
161284
+ const STEP = 3;
161285
+ const delta = dir2 === "up" ? -STEP : STEP;
161286
+ if (pane === "commits") {
161287
+ setCommitIndex((i) => Math.max(0, Math.min(cs.length - 1, i + delta)));
161288
+ } else if (pane === "branches") {
161289
+ setBranchIndex((i) => Math.max(0, Math.min(bs.length - 1, i + delta)));
161290
+ } else if (pane === "worktrees") {
161291
+ setWorktreeIndex((i) => Math.max(0, Math.min(ws.length - 1, i + delta)));
161292
+ }
161293
+ });
161294
+ }, [controller, state.interactiveView]);
160578
161295
  const isNarrow = cols < NARROW_THRESHOLD;
160579
161296
  const leftWidth = Math.max(24, Math.floor(cols * 0.35));
160580
161297
  const rightWidth = cols - leftWidth - 1;
@@ -160916,7 +161633,7 @@ function entriesToNodes(entries, depth) {
160916
161633
  const files = filtered.filter((e) => !e.isDirectory).sort((a, b) => a.name.localeCompare(b.name));
160917
161634
  return [...dirs, ...files].map((e) => ({ entry: e, depth, expanded: false, children: void 0 }));
160918
161635
  }
160919
- function FilesView({ state }) {
161636
+ function FilesView({ state, controller }) {
160920
161637
  const { stdout } = useStdout();
160921
161638
  const cols = stdout?.columns ?? 80;
160922
161639
  const data = state.interactiveData;
@@ -161124,6 +161841,23 @@ function FilesView({ state }) {
161124
161841
  }
161125
161842
  }
161126
161843
  }, { isActive: state.interactiveView === "files" });
161844
+ const filesWheelRef = useRef({ focusedPane, flatNodes, previewResult, previewHeight });
161845
+ filesWheelRef.current = { focusedPane, flatNodes, previewResult, previewHeight };
161846
+ useEffect2(() => {
161847
+ if (state.interactiveView !== "files") return;
161848
+ return controller.onWheel((dir2) => {
161849
+ const { focusedPane: pane, flatNodes: nodes, previewResult: pr, previewHeight: ph } = filesWheelRef.current;
161850
+ const STEP = 3;
161851
+ const delta = dir2 === "up" ? -STEP : STEP;
161852
+ if (pane === "tree") {
161853
+ setSelectedIndex((i) => Math.max(0, Math.min(nodes.length - 1, i + delta)));
161854
+ } else {
161855
+ const lineCount = pr?.lineCount ?? 0;
161856
+ const maxScroll = Math.max(0, lineCount - ph);
161857
+ setPreviewScroll((s) => Math.max(0, Math.min(maxScroll, s + delta)));
161858
+ }
161859
+ });
161860
+ }, [controller, state.interactiveView]);
161127
161861
  const isNarrow = cols < NARROW_THRESHOLD;
161128
161862
  const treeWidth = isNarrow ? Math.max(20, cols - 2) : Math.max(20, Math.floor(cols * 0.38));
161129
161863
  const previewEntry = selectedNode && !selectedNode.entry.isDirectory ? selectedNode.entry : null;
@@ -161287,8 +162021,8 @@ function InteractiveMode({ state, controller }) {
161287
162021
  state.interactiveView === "board" && /* @__PURE__ */ jsx(BoardView, { state, controller }),
161288
162022
  state.interactiveView === "agents" && /* @__PURE__ */ jsx(AgentsView, { state }),
161289
162023
  state.interactiveView === "settings" && /* @__PURE__ */ jsx(SettingsInteractiveView, { state, controller }),
161290
- state.interactiveView === "git" && /* @__PURE__ */ jsx(GitView, { state }),
161291
- state.interactiveView === "files" && /* @__PURE__ */ jsx(FilesView, { state })
162024
+ state.interactiveView === "git" && /* @__PURE__ */ jsx(GitView, { state, controller }),
162025
+ state.interactiveView === "files" && /* @__PURE__ */ jsx(FilesView, { state, controller })
161292
162026
  ] })
161293
162027
  ] });
161294
162028
  }
@@ -161319,6 +162053,23 @@ function DashboardApp({ controller }) {
161319
162053
  useCallback2((cb) => controller.subscribe(cb), [controller]),
161320
162054
  useCallback2(() => controller.getSnapshot(), [controller])
161321
162055
  );
162056
+ const wheelStateRef = useRef(state);
162057
+ wheelStateRef.current = state;
162058
+ useEffect2(() => {
162059
+ return controller.onWheel((dir2) => {
162060
+ const s = wheelStateRef.current;
162061
+ if (s.activeSection !== "logs") return;
162062
+ const filtered = controller.getFilteredLogEntries();
162063
+ if (filtered.length === 0) return;
162064
+ const WHEEL_STEP = 3;
162065
+ const cur = s.selectedLogIndex;
162066
+ if (dir2 === "up") {
162067
+ controller.setSelectedLogIndex(Math.max(0, cur - WHEEL_STEP));
162068
+ } else {
162069
+ controller.setSelectedLogIndex(Math.min(filtered.length - 1, cur + WHEEL_STEP));
162070
+ }
162071
+ });
162072
+ }, [controller]);
161322
162073
  const [qrOverlay, setQrOverlay] = useState2(null);
161323
162074
  useInput((input, key) => {
161324
162075
  if ((input === "q" || input === "Q") && !key.ctrl || key.ctrl && input === "c") {
@@ -161701,6 +162452,15 @@ var init_controller = __esm({
161701
162452
  // no remote API is wired up).
161702
162453
  remoteStatus = null;
161703
162454
  remoteStatusTimer = null;
162455
+ // Mouse-wheel handling. We enable xterm SGR mouse mode in start() so the
162456
+ // terminal sends button reports for wheel up/down (buttons 64/65). A
162457
+ // parallel `data` listener parses those reports and dispatches to wheel
162458
+ // handlers. Ink's own keypress parser ignores SGR mouse sequences so
162459
+ // long as the full sequence (including the leading ESC) arrives in one
162460
+ // chunk — which it does once raw mode is enabled before mouse mode is
162461
+ // requested. (See ink#222 / @zenobius/ink-mouse for prior art.)
162462
+ wheelHandlers = /* @__PURE__ */ new Set();
162463
+ mouseStdinListener = null;
161704
162464
  constructor() {
161705
162465
  this.logBuffer = new LogRingBuffer();
161706
162466
  }
@@ -161709,6 +162469,15 @@ var init_controller = __esm({
161709
162469
  this.subscribers.add(callback);
161710
162470
  return () => this.subscribers.delete(callback);
161711
162471
  }
162472
+ /**
162473
+ * Subscribe to mouse-wheel events. Direction is "up" (scroll back/older
162474
+ * content) or "down" (scroll forward/newer content). Only fires while the
162475
+ * dashboard is running and the terminal supports xterm mouse reporting.
162476
+ */
162477
+ onWheel(handler) {
162478
+ this.wheelHandlers.add(handler);
162479
+ return () => this.wheelHandlers.delete(handler);
162480
+ }
161712
162481
  getSnapshot() {
161713
162482
  if (this.cachedSnapshot) return this.cachedSnapshot;
161714
162483
  this.cachedSnapshot = {
@@ -162093,6 +162862,10 @@ var init_controller = __esm({
162093
162862
  this.inkInstance = render(
162094
162863
  createElement(DashboardApp2, { controller: this })
162095
162864
  );
162865
+ if (process.stdin?.isTTY) {
162866
+ process.stdout.write("\x1B[?1000h\x1B[?1006h");
162867
+ this.installMouseListener();
162868
+ }
162096
162869
  this.resizeListener = () => {
162097
162870
  if (this.resizeDebounceTimer) clearTimeout(this.resizeDebounceTimer);
162098
162871
  this.resizeDebounceTimer = setTimeout(() => {
@@ -162199,10 +162972,48 @@ var init_controller = __esm({
162199
162972
  this.inkInstance = null;
162200
162973
  }
162201
162974
  if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
162975
+ this.uninstallMouseListener();
162976
+ process.stdout.write("\x1B[?1006l\x1B[?1000l");
162202
162977
  process.stdout.write("\x1B[?1049l");
162203
162978
  }
162204
162979
  }
162205
162980
  // ── Private helpers ────────────────────────────────────────────────────────
162981
+ // Attach a parallel `data` listener that decodes xterm SGR mouse
162982
+ // sequences and dispatches wheel events. Ink's own listener is also
162983
+ // attached; SGR sequences arrive as a single chunk that Ink's keypress
162984
+ // parser silently ignores, so we don't need to (and shouldn't) strip
162985
+ // them from the stream.
162986
+ installMouseListener() {
162987
+ if (this.mouseStdinListener) return;
162988
+ const mouseRe = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
162989
+ const listener = (chunk) => {
162990
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
162991
+ if (text.indexOf("\x1B[<") === -1) return;
162992
+ mouseRe.lastIndex = 0;
162993
+ let m;
162994
+ while ((m = mouseRe.exec(text)) !== null) {
162995
+ const btn = Number.parseInt(m[1] ?? "", 10);
162996
+ if (btn === 64) this.dispatchWheel("up");
162997
+ else if (btn === 65) this.dispatchWheel("down");
162998
+ }
162999
+ };
163000
+ this.mouseStdinListener = listener;
163001
+ process.stdin.on("data", listener);
163002
+ }
163003
+ uninstallMouseListener() {
163004
+ if (!this.mouseStdinListener) return;
163005
+ process.stdin.off("data", this.mouseStdinListener);
163006
+ this.mouseStdinListener = null;
163007
+ }
163008
+ dispatchWheel(direction) {
163009
+ for (const handler of this.wheelHandlers) {
163010
+ try {
163011
+ handler(direction);
163012
+ } catch (err) {
163013
+ tuiDebug2("wheel-handler-error", { err: String(err) });
163014
+ }
163015
+ }
163016
+ }
162206
163017
  clampSelectedLogIndex(entries) {
162207
163018
  if (entries.length === 0) {
162208
163019
  this.selectedLogIndex = 0;
@@ -170806,7 +171617,7 @@ __export(native_patch_exports, {
170806
171617
  });
170807
171618
  import { join as join68, basename as basename21, dirname as dirname28 } from "node:path";
170808
171619
  import { existsSync as existsSync51, copyFileSync, mkdirSync as mkdirSync12, symlinkSync as symlinkSync2, rmSync as rmSync5, lstatSync as lstatSync3, readlinkSync as readlinkSync2 } from "node:fs";
170809
- import { tmpdir as tmpdir4 } from "node:os";
171620
+ import { tmpdir as tmpdir5 } from "node:os";
170810
171621
  function findStagedNativeDir2() {
170811
171622
  const platform4 = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "win32" : "unknown";
170812
171623
  const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : "unknown";
@@ -170851,7 +171662,7 @@ function setupNativeResolution() {
170851
171662
  process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir;
170852
171663
  }
170853
171664
  process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir;
170854
- const tmpRoot = join68(tmpdir4(), `fn-bunfs-${process.pid}`);
171665
+ const tmpRoot = join68(tmpdir5(), `fn-bunfs-${process.pid}`);
170855
171666
  const fnDir = join68(tmpRoot, "fn");
170856
171667
  const prebuildsDir = join68(fnDir, "prebuilds");
170857
171668
  const platformDir = join68(prebuildsDir, basename21(nativeDir));
@@ -170939,7 +171750,7 @@ var init_native_patch = __esm({
170939
171750
  import { existsSync as existsSync52, mkdtempSync as mkdtempSync2, readFileSync as readFileSync24, symlinkSync as symlinkSync3, writeFileSync as writeFileSync6 } from "node:fs";
170940
171751
  import { createRequire as createRequire7 } from "node:module";
170941
171752
  import { join as join69, dirname as dirname29, resolve as resolve41 } from "node:path";
170942
- import { tmpdir as tmpdir5 } from "node:os";
171753
+ import { tmpdir as tmpdir6 } from "node:os";
170943
171754
  import { performance as performance3 } from "node:perf_hooks";
170944
171755
  import { fileURLToPath as fileURLToPath11 } from "node:url";
170945
171756
  var isBunBinary3 = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
@@ -170947,7 +171758,7 @@ function configurePiPackage() {
170947
171758
  if (process.env.PI_PACKAGE_DIR) {
170948
171759
  return;
170949
171760
  }
170950
- const tmp = mkdtempSync2(join69(tmpdir5(), "fn-pkg-"));
171761
+ const tmp = mkdtempSync2(join69(tmpdir6(), "fn-pkg-"));
170951
171762
  let packageJson = {
170952
171763
  name: "pi",
170953
171764
  version: "0.1.0",