ai-whisper 0.2.1 → 0.4.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.
@@ -549,6 +549,100 @@ function getCollab(db, collabId2) {
549
549
  });
550
550
  }
551
551
 
552
+ // ../broker/dist/storage/repositories/workflow-repository.js
553
+ function rowToRecord(row) {
554
+ return {
555
+ workflowId: row.workflow_id,
556
+ collabId: row.collab_id,
557
+ workflowType: row.workflow_type,
558
+ name: row.name,
559
+ specPath: row.spec_path,
560
+ roleBindings: JSON.parse(row.role_bindings),
561
+ status: row.status,
562
+ currentPhaseIndex: row.current_phase_index,
563
+ haltReason: row.halt_reason,
564
+ workflowContext: JSON.parse(row.workflow_context),
565
+ createdAt: row.created_at,
566
+ updatedAt: row.updated_at
567
+ };
568
+ }
569
+ function insertWorkflow(db, input) {
570
+ db.prepare(`INSERT INTO workflows
571
+ (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
572
+ current_phase_index, halt_reason, workflow_context, created_at, updated_at)
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`).run(input.workflowId, input.collabId, input.workflowType, input.name, input.specPath, JSON.stringify(input.roleBindings), input.status, input.currentPhaseIndex, JSON.stringify(input.workflowContext), input.now, input.now);
574
+ }
575
+ function getWorkflowById(db, workflowId) {
576
+ const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
577
+ return row ? rowToRecord(row) : null;
578
+ }
579
+ function listWorkflows(db, filter = {}) {
580
+ const clauses = [];
581
+ const args = [];
582
+ if (filter.collabId) {
583
+ clauses.push("collab_id = ?");
584
+ args.push(filter.collabId);
585
+ }
586
+ if (filter.status) {
587
+ clauses.push("status = ?");
588
+ args.push(filter.status);
589
+ }
590
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
591
+ const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
592
+ return rows.map(rowToRecord);
593
+ }
594
+ function setWorkflowStatus(db, input) {
595
+ db.prepare(`UPDATE workflows
596
+ SET status = ?, halt_reason = ?, updated_at = ?
597
+ WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
598
+ }
599
+ function updateWorkflowContext(db, input) {
600
+ const existing = getWorkflowById(db, input.workflowId);
601
+ if (!existing) {
602
+ throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
603
+ }
604
+ const merged = { ...existing.workflowContext, ...input.patch };
605
+ db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
606
+ }
607
+ function incrementCurrentPhaseIndex(db, input) {
608
+ db.prepare(`UPDATE workflows
609
+ SET current_phase_index = current_phase_index + 1, updated_at = ?
610
+ WHERE workflow_id = ?`).run(input.now, input.workflowId);
611
+ }
612
+ function countActiveWorkflowsForCollab(db, collabId2) {
613
+ const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status IN ('running', 'paused')").get(collabId2);
614
+ return row.n;
615
+ }
616
+
617
+ // ../broker/dist/runtime/workspace-snapshot.js
618
+ import { execFileSync } from "node:child_process";
619
+ function captureWorkspaceSnapshotSync(workspaceRoot) {
620
+ try {
621
+ const ref = execFileSync("git", ["-C", workspaceRoot, "stash", "create"], {
622
+ encoding: "utf8",
623
+ timeout: 1e4
624
+ }).trim();
625
+ if (ref)
626
+ return ref;
627
+ const head = execFileSync("git", ["-C", workspaceRoot, "rev-parse", "HEAD"], {
628
+ encoding: "utf8",
629
+ timeout: 1e4
630
+ }).trim();
631
+ return head || null;
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+ function diffChangedFilesSince(workspaceRoot, sinceRef) {
637
+ try {
638
+ const stdout = execFileSync("git", ["-C", workspaceRoot, "diff", "--name-only", sinceRef], { encoding: "utf8", timeout: 1e4 });
639
+ const files = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".ai-whisper/"));
640
+ return [...new Set(files)].sort();
641
+ } catch {
642
+ return [];
643
+ }
644
+ }
645
+
552
646
  // ../broker/dist/storage/repositories/reply-repository.js
553
647
  function insertReply(db, reply) {
554
648
  db.prepare(`INSERT INTO reply (
@@ -1014,7 +1108,7 @@ function upsertRelayTurnState(db, input) {
1014
1108
  }
1015
1109
 
1016
1110
  // ../broker/dist/storage/repositories/relay-capture-diagnostics-repository.js
1017
- function rowToRecord(row) {
1111
+ function rowToRecord2(row) {
1018
1112
  return {
1019
1113
  captureId: row.capture_id,
1020
1114
  handoffId: row.handoff_id,
@@ -1051,32 +1145,32 @@ function listCaptureDiagnosticsByCollab(db, collabId2, limit, opts) {
1051
1145
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1052
1146
  WHERE collab_id = ?${filterClause}
1053
1147
  ORDER BY created_at DESC`).all(collabId2, ...filterArgs);
1054
- return rows2.map(rowToRecord);
1148
+ return rows2.map(rowToRecord2);
1055
1149
  }
1056
1150
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1057
1151
  WHERE collab_id = ?${filterClause}
1058
1152
  ORDER BY created_at DESC
1059
1153
  LIMIT ?`).all(collabId2, ...filterArgs, limit);
1060
- return rows.map(rowToRecord);
1154
+ return rows.map(rowToRecord2);
1061
1155
  }
1062
1156
  function listCaptureDiagnosticsByCollabAndChain(db, collabId2, chainId, limit) {
1063
1157
  if (limit === null) {
1064
1158
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1065
1159
  WHERE collab_id = ? AND chain_id = ?
1066
1160
  ORDER BY created_at DESC`).all(collabId2, chainId);
1067
- return rows2.map(rowToRecord);
1161
+ return rows2.map(rowToRecord2);
1068
1162
  }
1069
1163
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1070
1164
  WHERE collab_id = ? AND chain_id = ?
1071
1165
  ORDER BY created_at DESC
1072
1166
  LIMIT ?`).all(collabId2, chainId, limit);
1073
- return rows.map(rowToRecord);
1167
+ return rows.map(rowToRecord2);
1074
1168
  }
1075
1169
  function listCaptureDiagnosticsByHandoff(db, handoffId) {
1076
1170
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1077
1171
  WHERE handoff_id = ?
1078
1172
  ORDER BY created_at ASC`).all(handoffId);
1079
- return rows.map(rowToRecord);
1173
+ return rows.map(rowToRecord2);
1080
1174
  }
1081
1175
  function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1082
1176
  const result = db.prepare("DELETE FROM relay_capture_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1084,7 +1178,7 @@ function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1084
1178
  }
1085
1179
 
1086
1180
  // ../broker/dist/storage/repositories/relay-evaluator-diagnostics-repository.js
1087
- function rowToRecord2(row) {
1181
+ function rowToRecord3(row) {
1088
1182
  return {
1089
1183
  evaluatorId: row.evaluator_id,
1090
1184
  handoffId: row.handoff_id,
@@ -1129,32 +1223,32 @@ function listEvaluatorDiagnosticsByCollab(db, collabId2, limit, opts) {
1129
1223
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1130
1224
  WHERE collab_id = ?${filterClause}
1131
1225
  ORDER BY created_at DESC`).all(collabId2, ...filterArgs);
1132
- return rows2.map(rowToRecord2);
1226
+ return rows2.map(rowToRecord3);
1133
1227
  }
1134
1228
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1135
1229
  WHERE collab_id = ?${filterClause}
1136
1230
  ORDER BY created_at DESC
1137
1231
  LIMIT ?`).all(collabId2, ...filterArgs, limit);
1138
- return rows.map(rowToRecord2);
1232
+ return rows.map(rowToRecord3);
1139
1233
  }
1140
1234
  function listEvaluatorDiagnosticsByCollabAndChain(db, collabId2, chainId, limit) {
1141
1235
  if (limit === null) {
1142
1236
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1143
1237
  WHERE collab_id = ? AND chain_id = ?
1144
1238
  ORDER BY created_at DESC`).all(collabId2, chainId);
1145
- return rows2.map(rowToRecord2);
1239
+ return rows2.map(rowToRecord3);
1146
1240
  }
1147
1241
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1148
1242
  WHERE collab_id = ? AND chain_id = ?
1149
1243
  ORDER BY created_at DESC
1150
1244
  LIMIT ?`).all(collabId2, chainId, limit);
1151
- return rows.map(rowToRecord2);
1245
+ return rows.map(rowToRecord3);
1152
1246
  }
1153
1247
  function listEvaluatorDiagnosticsByHandoff(db, handoffId) {
1154
1248
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1155
1249
  WHERE handoff_id = ?
1156
1250
  ORDER BY created_at ASC`).all(handoffId);
1157
- return rows.map(rowToRecord2);
1251
+ return rows.map(rowToRecord3);
1158
1252
  }
1159
1253
  function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1160
1254
  const result = db.prepare("DELETE FROM relay_evaluator_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1162,7 +1256,7 @@ function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1162
1256
  }
1163
1257
 
1164
1258
  // ../broker/dist/storage/repositories/relay-handoff-repository.js
1165
- function rowToRecord3(row) {
1259
+ function rowToRecord4(row) {
1166
1260
  return {
1167
1261
  handoffId: row.handoff_id,
1168
1262
  collabId: row.collab_id,
@@ -1199,7 +1293,7 @@ function queryRelayHandoff(db, handoffId) {
1199
1293
  FROM relay_handoff h
1200
1294
  JOIN collab c ON c.collab_id = h.collab_id
1201
1295
  WHERE h.handoff_id = ?`).get(handoffId);
1202
- return row ? rowToRecord3(row) : null;
1296
+ return row ? rowToRecord4(row) : null;
1203
1297
  }
1204
1298
  function createRelayHandoffTxn(db, input) {
1205
1299
  return db.transaction(() => {
@@ -1351,7 +1445,7 @@ function queryLatestHandedBackHandoff(db, collabId2) {
1351
1445
  WHERE h.collab_id = ? AND h.status = 'handed_back'
1352
1446
  ORDER BY h.resolved_at DESC
1353
1447
  LIMIT 1`).get(collabId2);
1354
- return row ? rowToRecord3(row) : null;
1448
+ return row ? rowToRecord4(row) : null;
1355
1449
  }
1356
1450
  function failRelayHandoffOnDisconnectTxn(db, input) {
1357
1451
  db.transaction(() => {
@@ -1375,6 +1469,30 @@ function failRelayHandoffOnDisconnectTxn(db, input) {
1375
1469
  }
1376
1470
  })();
1377
1471
  }
1472
+ function hasInFlightAcceptedHandoffForWorkflow(db, workflowId) {
1473
+ const row = db.prepare("SELECT COUNT(*) AS n FROM relay_handoff WHERE workflow_id = ? AND status = 'accepted'").get(workflowId);
1474
+ return row.n > 0;
1475
+ }
1476
+ function resetStrandedOrchestrationForWorkflow(db, workflowId) {
1477
+ const result = db.prepare(`UPDATE relay_handoff
1478
+ SET orchestrator_status = 'idle', orchestrator_claimed_at = NULL
1479
+ WHERE workflow_id = ?
1480
+ AND status = 'handed_back'
1481
+ AND orchestrator_status = 'pending'
1482
+ AND evaluator_verdict IS NULL`).run(workflowId);
1483
+ return result.changes;
1484
+ }
1485
+ function prependToPendingHandoffRequestText(db, input) {
1486
+ const row = db.prepare(`SELECT handoff_id, request_text FROM relay_handoff
1487
+ WHERE workflow_id = ? AND status = 'pending'
1488
+ ORDER BY created_at DESC LIMIT 1`).get(input.workflowId);
1489
+ if (!row)
1490
+ return false;
1491
+ db.prepare("UPDATE relay_handoff SET request_text = ?, last_activity_at = ? WHERE handoff_id = ?").run(`${input.prefix}
1492
+
1493
+ ${row.request_text}`, input.now, row.handoff_id);
1494
+ return true;
1495
+ }
1378
1496
  function claimRelayHandoffForOrchestrationTxn(db, input) {
1379
1497
  const result = db.prepare(`UPDATE relay_handoff
1380
1498
  SET orchestrator_status = 'pending',
@@ -1393,10 +1511,12 @@ function listRelayHandoffsPendingOrchestration(db, collabId2) {
1393
1511
  c.orchestrator_max_rounds AS max_rounds
1394
1512
  FROM relay_handoff h
1395
1513
  JOIN collab c ON c.collab_id = h.collab_id
1514
+ LEFT JOIN workflows w ON w.workflow_id = h.workflow_id
1396
1515
  WHERE h.collab_id = ?
1397
1516
  AND h.status = 'handed_back'
1398
- AND h.orchestrator_status = 'idle'`).all(collabId2);
1399
- return rows.map(rowToRecord3);
1517
+ AND h.orchestrator_status = 'idle'
1518
+ AND (w.status IS NULL OR w.status <> 'paused')`).all(collabId2);
1519
+ return rows.map(rowToRecord4);
1400
1520
  }
1401
1521
  function createLoopRelayHandoffTxn(db, input) {
1402
1522
  return db.transaction(() => {
@@ -1592,7 +1712,7 @@ function getHandoffWithWorkflowMetaById(db, handoffId) {
1592
1712
  WHERE h.handoff_id = ?`).get(handoffId);
1593
1713
  if (!row)
1594
1714
  return null;
1595
- const base = rowToRecord3(row);
1715
+ const base = rowToRecord4(row);
1596
1716
  return {
1597
1717
  ...base,
1598
1718
  handoffStep: row.handoff_step,
@@ -1694,7 +1814,8 @@ function cleanupOrchestrationOnShutdownTxn(db, input) {
1694
1814
  import { basename } from "node:path";
1695
1815
  function listActiveCollabSummaries(db, input) {
1696
1816
  const nowMs = Date.parse(input.now ?? (/* @__PURE__ */ new Date()).toISOString());
1697
- const cutoff = new Date(Number.isFinite(nowMs) ? nowMs - input.sinceMs : Date.now() - input.sinceMs).toISOString();
1817
+ const base = Number.isFinite(nowMs) ? nowMs : Date.now();
1818
+ const cutoff = new Date(Math.max(0, base - input.sinceMs)).toISOString();
1698
1819
  const eligible = db.prepare(`SELECT c.collab_id AS collabId,
1699
1820
  COALESCE(MAX(h.last_activity_at), '') AS lastAct
1700
1821
  FROM collab c
@@ -1733,7 +1854,8 @@ function buildCollabSummary(db, collabId2) {
1733
1854
  const collab2 = db.prepare(`SELECT display_name AS displayName, workspace_root AS workspaceRoot
1734
1855
  FROM collab WHERE collab_id = ?`).get(e.collabId);
1735
1856
  const wf = db.prepare(`SELECT workflow_id AS workflowId, workflow_type AS workflowType,
1736
- name, status, current_phase_index AS currentPhaseIndex
1857
+ name, status, current_phase_index AS currentPhaseIndex,
1858
+ created_at AS createdAt
1737
1859
  FROM workflows WHERE collab_id = ?
1738
1860
  ORDER BY (status = 'running') DESC, created_at DESC
1739
1861
  LIMIT 1`).get(e.collabId);
@@ -1801,6 +1923,7 @@ function buildCollabSummary(db, collabId2) {
1801
1923
  handoffState: turn?.handoffState ?? "idle"
1802
1924
  },
1803
1925
  sessions,
1926
+ workflowCreatedAt: wf?.createdAt ?? null,
1804
1927
  lastActivityAt: runLastAct
1805
1928
  };
1806
1929
  }
@@ -1842,71 +1965,6 @@ function listRunCostRows(db, input) {
1842
1965
  // ../broker/dist/control/workflow-control.js
1843
1966
  import { randomUUID } from "node:crypto";
1844
1967
 
1845
- // ../broker/dist/storage/repositories/workflow-repository.js
1846
- function rowToRecord4(row) {
1847
- return {
1848
- workflowId: row.workflow_id,
1849
- collabId: row.collab_id,
1850
- workflowType: row.workflow_type,
1851
- name: row.name,
1852
- specPath: row.spec_path,
1853
- roleBindings: JSON.parse(row.role_bindings),
1854
- status: row.status,
1855
- currentPhaseIndex: row.current_phase_index,
1856
- haltReason: row.halt_reason,
1857
- workflowContext: JSON.parse(row.workflow_context),
1858
- createdAt: row.created_at,
1859
- updatedAt: row.updated_at
1860
- };
1861
- }
1862
- function insertWorkflow(db, input) {
1863
- db.prepare(`INSERT INTO workflows
1864
- (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
1865
- current_phase_index, halt_reason, workflow_context, created_at, updated_at)
1866
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`).run(input.workflowId, input.collabId, input.workflowType, input.name, input.specPath, JSON.stringify(input.roleBindings), input.status, input.currentPhaseIndex, JSON.stringify(input.workflowContext), input.now, input.now);
1867
- }
1868
- function getWorkflowById(db, workflowId) {
1869
- const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
1870
- return row ? rowToRecord4(row) : null;
1871
- }
1872
- function listWorkflows(db, filter = {}) {
1873
- const clauses = [];
1874
- const args = [];
1875
- if (filter.collabId) {
1876
- clauses.push("collab_id = ?");
1877
- args.push(filter.collabId);
1878
- }
1879
- if (filter.status) {
1880
- clauses.push("status = ?");
1881
- args.push(filter.status);
1882
- }
1883
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1884
- const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
1885
- return rows.map(rowToRecord4);
1886
- }
1887
- function setWorkflowStatus(db, input) {
1888
- db.prepare(`UPDATE workflows
1889
- SET status = ?, halt_reason = ?, updated_at = ?
1890
- WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
1891
- }
1892
- function updateWorkflowContext(db, input) {
1893
- const existing = getWorkflowById(db, input.workflowId);
1894
- if (!existing) {
1895
- throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
1896
- }
1897
- const merged = { ...existing.workflowContext, ...input.patch };
1898
- db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
1899
- }
1900
- function incrementCurrentPhaseIndex(db, input) {
1901
- db.prepare(`UPDATE workflows
1902
- SET current_phase_index = current_phase_index + 1, updated_at = ?
1903
- WHERE workflow_id = ?`).run(input.now, input.workflowId);
1904
- }
1905
- function countRunningWorkflowsForCollab(db, collabId2) {
1906
- const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status = 'running'").get(collabId2);
1907
- return row.n;
1908
- }
1909
-
1910
1968
  // ../broker/dist/storage/repositories/workflow-phase-repository.js
1911
1969
  function rowToRecord5(row) {
1912
1970
  return {
@@ -2027,6 +2085,14 @@ Findings: (omit this block entirely if none)
2027
2085
  Non-blocking risks:
2028
2086
  - <quality risk that does NOT block this gate, or "None.">
2029
2087
  --- end protocol ---`;
2088
+ var WORKFLOW_OPERATOR_CONTROL = `--- ai-whisper operator control ---
2089
+ If the operator interrupts you and asks to pause the workflow (e.g. "pause the workflow, I need to fix X"):
2090
+ 1. Find the active workflow id: run \`whisper workflow list\`.
2091
+ 2. Run \`whisper workflow pause <id>\`.
2092
+ 3. Acknowledge and STOP working \u2014 do not start the next change.
2093
+ The operator will edit artifacts and later run \`whisper workflow resume <id> [--message "..."]\`; you will then receive a notice of what changed and must re-read those files before continuing.
2094
+ Provider note: the Codex CLI EXITS its session on Ctrl+C at an idle prompt (a mid-task Ctrl+C only interrupts the running task). The operator typically interrupts you while you are BUSY before issuing this instruction; do not assume Ctrl+C is a safe no-op.
2095
+ --- end operator control ---`;
2030
2096
  var WORKFLOW_DIAGNOSIS_PROTOCOL = `--- ai-whisper diagnosis review protocol ---
2031
2097
  You are the gatekeeper for an ai-whisper complex-bug-fixing DIAGNOSIS gate. No human is in the loop. Do NOT trust the implementer's diagnosis \u2014 verify it yourself. The gate stays shut until YOU independently agree on the root cause and that the fix is net-safe.
2032
2098
 
@@ -2265,10 +2331,21 @@ var COMPLEX_BUG_FIXING = {
2265
2331
  }
2266
2332
  ]
2267
2333
  };
2334
+ function withOperatorControl(def) {
2335
+ return {
2336
+ ...def,
2337
+ phases: def.phases.map((p) => ({
2338
+ ...p,
2339
+ kickoffTemplate: `${p.kickoffTemplate}
2340
+
2341
+ ${WORKFLOW_OPERATOR_CONTROL}`
2342
+ }))
2343
+ };
2344
+ }
2268
2345
  var REGISTRY = {
2269
- [SPEC_DRIVEN_DEVELOPMENT.type]: SPEC_DRIVEN_DEVELOPMENT,
2270
- [RALPH_LOOP.type]: RALPH_LOOP,
2271
- [COMPLEX_BUG_FIXING.type]: COMPLEX_BUG_FIXING
2346
+ [SPEC_DRIVEN_DEVELOPMENT.type]: withOperatorControl(SPEC_DRIVEN_DEVELOPMENT),
2347
+ [RALPH_LOOP.type]: withOperatorControl(RALPH_LOOP),
2348
+ [COMPLEX_BUG_FIXING.type]: withOperatorControl(COMPLEX_BUG_FIXING)
2272
2349
  };
2273
2350
  function ralphRunDir(workspaceRoot, workflowId) {
2274
2351
  return join(workspaceRoot, ".ai-whisper", "ralph", workflowId);
@@ -2324,6 +2401,23 @@ function safeDerivePlanPath(specPath, createdAt) {
2324
2401
  return `${dir}${stem}.plan.md`;
2325
2402
  }
2326
2403
  }
2404
+ function composeResumeNotice(input) {
2405
+ const hasFiles = input.changedFiles.length > 0;
2406
+ const hasMessage = !!input.message && input.message.trim().length > 0;
2407
+ if (!hasFiles && !hasMessage)
2408
+ return null;
2409
+ const lines = [];
2410
+ if (hasFiles) {
2411
+ lines.push("While paused, the operator modified these files:");
2412
+ for (const f of input.changedFiles)
2413
+ lines.push(` - ${f}`);
2414
+ lines.push("Re-read them before continuing.");
2415
+ }
2416
+ if (hasMessage)
2417
+ lines.push(`Operator note: ${input.message.trim()}`);
2418
+ lines.push("Re-evaluate whether your current direction still holds; correct course before proceeding.");
2419
+ return lines.join("\n");
2420
+ }
2327
2421
  function createWorkflowControl(deps) {
2328
2422
  const { db, events } = deps;
2329
2423
  function createWorkflow(input) {
@@ -2346,8 +2440,9 @@ function createWorkflowControl(deps) {
2346
2440
  }
2347
2441
  const workflowId = `wf_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2348
2442
  const tx = db.transaction(() => {
2349
- if (countRunningWorkflowsForCollab(db, input.collabId) > 0) {
2350
- throw new Error(`another workflow is already running on this collab (${input.collabId})`);
2443
+ if (countActiveWorkflowsForCollab(db, input.collabId) > 0) {
2444
+ const active = listWorkflows(db, { collabId: input.collabId }).find((w) => w.status === "running" || w.status === "paused");
2445
+ throw new Error(`another workflow is already active on this collab (${input.collabId})` + (active ? `: ${active.workflowId} (${active.status})` : ""));
2351
2446
  }
2352
2447
  insertWorkflow(db, {
2353
2448
  workflowId,
@@ -2515,6 +2610,24 @@ function createWorkflowControl(deps) {
2515
2610
  postmortemPath: bf.postmortemPath
2516
2611
  });
2517
2612
  }
2613
+ function consumeResumeNotice(input) {
2614
+ const wf = getWorkflowById(db, input.workflowId);
2615
+ const notice = wf?.workflowContext?.resumeNotice ?? null;
2616
+ if (notice) {
2617
+ updateWorkflowContext(db, {
2618
+ workflowId: input.workflowId,
2619
+ patch: { resumeNotice: null },
2620
+ now: input.now
2621
+ });
2622
+ }
2623
+ return notice;
2624
+ }
2625
+ function withResumeNotice(workflowId, requestText, now) {
2626
+ const notice = consumeResumeNotice({ workflowId, now });
2627
+ return notice ? `${notice}
2628
+
2629
+ ${requestText}` : requestText;
2630
+ }
2518
2631
  function createContinuationHandoff(input) {
2519
2632
  const handoffId = `ho_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2520
2633
  insertWorkflowOwnedRelayHandoff(db, {
@@ -2522,7 +2635,9 @@ function createWorkflowControl(deps) {
2522
2635
  collabId: input.workflow.collabId,
2523
2636
  senderAgent: input.sender,
2524
2637
  targetAgent: input.target,
2525
- requestText: input.requestText,
2638
+ // Prepend the one-time resume notice (if any) to the FIRST outgoing request
2639
+ // after a resume, then it is cleared — so the agent re-reads changed files.
2640
+ requestText: withResumeNotice(input.workflow.workflowId, input.requestText, input.now),
2526
2641
  chainId: input.chain.chainId,
2527
2642
  roundNumber: input.incrementRound ? input.chain.currentRound + 1 : input.chain.currentRound,
2528
2643
  maxRounds: input.chain.maxRounds,
@@ -2582,7 +2697,9 @@ function createWorkflowControl(deps) {
2582
2697
  collabId: input.workflow.collabId,
2583
2698
  senderAgent: sender,
2584
2699
  targetAgent: target,
2585
- requestText: kickoffText,
2700
+ // Prepend a pending resume notice (consumed once) so a phase-advance kickoff
2701
+ // after a resume also carries the operator's change notice.
2702
+ requestText: withResumeNotice(input.workflow.workflowId, kickoffText, input.now),
2586
2703
  chainId,
2587
2704
  roundNumber: 1,
2588
2705
  maxRounds: phase.maxRounds,
@@ -3043,20 +3160,100 @@ ${findingsText}`;
3043
3160
  reason: input.reason
3044
3161
  });
3045
3162
  }
3163
+ function maybeCaptureQuiesceSnapshot(input) {
3164
+ const wf = getWorkflowById(db, input.workflowId);
3165
+ if (!wf || wf.status !== "paused")
3166
+ return;
3167
+ if (wf.workflowContext.pauseSnapshotRef !== void 0) {
3168
+ return;
3169
+ }
3170
+ if (hasInFlightAcceptedHandoffForWorkflow(db, input.workflowId))
3171
+ return;
3172
+ const ref = deps.captureSnapshotRef ? deps.captureSnapshotRef(input.workflowId) : null;
3173
+ updateWorkflowContext(db, {
3174
+ workflowId: input.workflowId,
3175
+ patch: { pauseSnapshotRef: ref },
3176
+ now: input.now
3177
+ });
3178
+ }
3179
+ function pauseWorkflow(input) {
3180
+ const workflow = getWorkflowById(db, input.workflowId);
3181
+ if (!workflow) {
3182
+ throw new Error(`pauseWorkflow: unknown workflowId ${input.workflowId}`);
3183
+ }
3184
+ if (workflow.status !== "running") {
3185
+ throw new Error(`pauseWorkflow: workflow ${input.workflowId} is ${workflow.status}, only running workflows can be paused`);
3186
+ }
3187
+ const tx = db.transaction(() => {
3188
+ const current = getWorkflowById(db, input.workflowId);
3189
+ if (!current || current.status !== "running")
3190
+ return;
3191
+ setWorkflowStatus(db, {
3192
+ workflowId: input.workflowId,
3193
+ status: "paused",
3194
+ haltReason: null,
3195
+ now: input.now
3196
+ });
3197
+ updateWorkflowContext(db, {
3198
+ workflowId: input.workflowId,
3199
+ patch: { pausedAt: input.now },
3200
+ now: input.now
3201
+ });
3202
+ });
3203
+ tx.immediate();
3204
+ events.emit("workflow.paused", { workflowId: input.workflowId });
3205
+ maybeCaptureQuiesceSnapshot({ workflowId: input.workflowId, now: input.now });
3206
+ }
3207
+ function resumeHaltedWorkflow(workflow, now) {
3208
+ const tx = db.transaction(() => {
3209
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== workflow.workflowId && (w.status === "running" || w.status === "paused"));
3210
+ if (others.length > 0) {
3211
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3212
+ }
3213
+ setWorkflowStatus(db, {
3214
+ workflowId: workflow.workflowId,
3215
+ status: "running",
3216
+ haltReason: null,
3217
+ now
3218
+ });
3219
+ });
3220
+ tx.immediate();
3221
+ events.emit("workflow.resumed", {
3222
+ workflowId: workflow.workflowId,
3223
+ phaseIndex: workflow.currentPhaseIndex
3224
+ });
3225
+ }
3046
3226
  function resumeWorkflow(input) {
3047
3227
  const workflow = getWorkflowById(db, input.workflowId);
3048
3228
  if (!workflow) {
3049
3229
  throw new Error(`resumeWorkflow: unknown workflowId ${input.workflowId}`);
3050
3230
  }
3051
- if (workflow.status === "canceled") {
3052
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is canceled and cannot be resumed`);
3231
+ if (workflow.status === "halted") {
3232
+ resumeHaltedWorkflow(workflow, input.now);
3233
+ return;
3053
3234
  }
3054
- if (workflow.status !== "halted") {
3055
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is not halted (status=${workflow.status})`);
3235
+ if (workflow.status !== "paused") {
3236
+ throw new Error(`resumeWorkflow: workflow ${input.workflowId} is ${workflow.status}, only paused or halted workflows can be resumed`);
3056
3237
  }
3238
+ const ref = workflow.workflowContext.pauseSnapshotRef ?? null;
3239
+ const changedFiles = ref && deps.diffChangedFilesSinceSnapshot ? deps.diffChangedFilesSinceSnapshot(input.workflowId, ref) : [];
3240
+ const notice = composeResumeNotice({
3241
+ changedFiles,
3242
+ message: input.message ?? null
3243
+ });
3057
3244
  const tx = db.transaction(() => {
3058
- if (countRunningWorkflowsForCollab(db, workflow.collabId) > 0) {
3059
- throw new Error(`resumeWorkflow: another workflow is already running on collab ${workflow.collabId}`);
3245
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== input.workflowId && (w.status === "running" || w.status === "paused"));
3246
+ if (others.length > 0) {
3247
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3248
+ }
3249
+ resetStrandedOrchestrationForWorkflow(db, input.workflowId);
3250
+ let bakedIntoPending = false;
3251
+ if (notice) {
3252
+ bakedIntoPending = prependToPendingHandoffRequestText(db, {
3253
+ workflowId: input.workflowId,
3254
+ prefix: notice,
3255
+ now: input.now
3256
+ });
3060
3257
  }
3061
3258
  setWorkflowStatus(db, {
3062
3259
  workflowId: input.workflowId,
@@ -3064,6 +3261,15 @@ ${findingsText}`;
3064
3261
  haltReason: null,
3065
3262
  now: input.now
3066
3263
  });
3264
+ updateWorkflowContext(db, {
3265
+ workflowId: input.workflowId,
3266
+ patch: {
3267
+ resumeNotice: bakedIntoPending ? null : notice,
3268
+ pausedAt: null,
3269
+ pauseSnapshotRef: null
3270
+ },
3271
+ now: input.now
3272
+ });
3067
3273
  });
3068
3274
  tx.immediate();
3069
3275
  events.emit("workflow.resumed", {
@@ -3164,6 +3370,9 @@ ${findingsText}`;
3164
3370
  beginPhaseRun,
3165
3371
  applyOrchestratorVerdict,
3166
3372
  haltWorkflow,
3373
+ pauseWorkflow,
3374
+ maybeCaptureQuiesceSnapshot,
3375
+ consumeResumeNotice,
3167
3376
  resumeWorkflow,
3168
3377
  cancelWorkflow,
3169
3378
  getHandoffWithWorkflowMeta,
@@ -3179,8 +3388,32 @@ function buildEventId(kind, subjectId, timestamp) {
3179
3388
  return `evt_${kind}_${subjectId}_${normalizeTimestampForEventId(timestamp)}`;
3180
3389
  }
3181
3390
  function createControlService(db, events) {
3182
- const workflowControl = createWorkflowControl({ db, events });
3391
+ const resolveWorkspaceRoot = (workflowId) => {
3392
+ const wf = getWorkflowById(db, workflowId);
3393
+ if (!wf)
3394
+ return null;
3395
+ return getCollab(db, wf.collabId)?.workspaceRoot ?? null;
3396
+ };
3397
+ const workflowControl = createWorkflowControl({
3398
+ db,
3399
+ events,
3400
+ captureSnapshotRef: (workflowId) => {
3401
+ const root = resolveWorkspaceRoot(workflowId);
3402
+ return root ? captureWorkspaceSnapshotSync(root) : null;
3403
+ },
3404
+ diffChangedFilesSinceSnapshot: (workflowId, sinceRef) => {
3405
+ const root = resolveWorkspaceRoot(workflowId);
3406
+ return root ? diffChangedFilesSince(root, sinceRef) : [];
3407
+ }
3408
+ });
3409
+ const isWorkflowDeliverySuspended = (handoffId) => {
3410
+ const meta = workflowControl.getHandoffWithWorkflowMeta(handoffId);
3411
+ if (!meta?.workflowId)
3412
+ return false;
3413
+ return workflowControl.getWorkflow(meta.workflowId)?.status === "paused";
3414
+ };
3183
3415
  return Object.assign({
3416
+ isWorkflowDeliverySuspended,
3184
3417
  startCollab(input) {
3185
3418
  const collab2 = collabSchema.parse({
3186
3419
  version: 1,
@@ -3808,6 +4041,8 @@ function createControlService(db, events) {
3808
4041
  return createRelayHandoffTxn(db, input);
3809
4042
  },
3810
4043
  acceptRelayHandoff(input) {
4044
+ if (isWorkflowDeliverySuspended(input.handoffId))
4045
+ return;
3811
4046
  return acceptRelayHandoffTxn(db, input);
3812
4047
  },
3813
4048
  deferRelayHandoff(input) {
@@ -3820,7 +4055,12 @@ function createControlService(db, events) {
3820
4055
  return markRelayHandoffStaleTxn(db, input);
3821
4056
  },
3822
4057
  handoffBackRelay(input) {
3823
- return handoffBackRelayTxn(db, input);
4058
+ const result = handoffBackRelayTxn(db, input);
4059
+ const meta = workflowControl.getHandoffWithWorkflowMeta(input.handoffId);
4060
+ if (meta?.workflowId && workflowControl.getWorkflow(meta.workflowId)?.status === "paused") {
4061
+ workflowControl.maybeCaptureQuiesceSnapshot({ workflowId: meta.workflowId, now: input.now });
4062
+ }
4063
+ return result;
3824
4064
  },
3825
4065
  failRelayHandoffOnDisconnect(input) {
3826
4066
  return failRelayHandoffOnDisconnectTxn(db, input);
@@ -3832,6 +4072,8 @@ function createControlService(db, events) {
3832
4072
  return queryLatestHandedBackHandoff(db, collabId2);
3833
4073
  },
3834
4074
  claimRelayHandoffForOrchestration(input) {
4075
+ if (isWorkflowDeliverySuspended(input.handoffId))
4076
+ return null;
3835
4077
  return claimRelayHandoffForOrchestrationTxn(db, input);
3836
4078
  },
3837
4079
  listRelayHandoffsPendingOrchestration(collabId2) {
@@ -4285,8 +4527,9 @@ CREATE TABLE IF NOT EXISTS workflows (
4285
4527
  updated_at TEXT NOT NULL
4286
4528
  );
4287
4529
 
4530
+ DROP INDEX IF EXISTS workflows_one_running_per_collab;
4288
4531
  CREATE UNIQUE INDEX IF NOT EXISTS workflows_one_running_per_collab
4289
- ON workflows(collab_id) WHERE status = 'running';
4532
+ ON workflows(collab_id) WHERE status IN ('running', 'paused');
4290
4533
 
4291
4534
  CREATE TABLE IF NOT EXISTS workflow_phases (
4292
4535
  phase_run_id TEXT PRIMARY KEY,
@@ -5820,7 +6063,7 @@ ${JSON.stringify(event.payload)}`;
5820
6063
  }
5821
6064
 
5822
6065
  // src/runtime/process-start-time.ts
5823
- import { execFileSync } from "node:child_process";
6066
+ import { execFileSync as execFileSync2 } from "node:child_process";
5824
6067
  import { readFileSync } from "node:fs";
5825
6068
  function readProcessStartTime(pid) {
5826
6069
  if (process.platform === "linux") {
@@ -5834,7 +6077,7 @@ function readProcessStartTime(pid) {
5834
6077
  }
5835
6078
  if (process.platform === "darwin") {
5836
6079
  try {
5837
- const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
6080
+ const out = execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], {
5838
6081
  encoding: "utf8"
5839
6082
  });
5840
6083
  const trimmed = out.trim();