ai-whisper 0.2.0 → 0.3.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.
@@ -599,6 +599,100 @@ function getCollab(db, collabId) {
599
599
  });
600
600
  }
601
601
 
602
+ // ../broker/dist/storage/repositories/workflow-repository.js
603
+ function rowToRecord(row) {
604
+ return {
605
+ workflowId: row.workflow_id,
606
+ collabId: row.collab_id,
607
+ workflowType: row.workflow_type,
608
+ name: row.name,
609
+ specPath: row.spec_path,
610
+ roleBindings: JSON.parse(row.role_bindings),
611
+ status: row.status,
612
+ currentPhaseIndex: row.current_phase_index,
613
+ haltReason: row.halt_reason,
614
+ workflowContext: JSON.parse(row.workflow_context),
615
+ createdAt: row.created_at,
616
+ updatedAt: row.updated_at
617
+ };
618
+ }
619
+ function insertWorkflow(db, input) {
620
+ db.prepare(`INSERT INTO workflows
621
+ (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
622
+ current_phase_index, halt_reason, workflow_context, created_at, updated_at)
623
+ 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);
624
+ }
625
+ function getWorkflowById(db, workflowId) {
626
+ const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
627
+ return row ? rowToRecord(row) : null;
628
+ }
629
+ function listWorkflows(db, filter = {}) {
630
+ const clauses = [];
631
+ const args = [];
632
+ if (filter.collabId) {
633
+ clauses.push("collab_id = ?");
634
+ args.push(filter.collabId);
635
+ }
636
+ if (filter.status) {
637
+ clauses.push("status = ?");
638
+ args.push(filter.status);
639
+ }
640
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
641
+ const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
642
+ return rows.map(rowToRecord);
643
+ }
644
+ function setWorkflowStatus(db, input) {
645
+ db.prepare(`UPDATE workflows
646
+ SET status = ?, halt_reason = ?, updated_at = ?
647
+ WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
648
+ }
649
+ function updateWorkflowContext(db, input) {
650
+ const existing = getWorkflowById(db, input.workflowId);
651
+ if (!existing) {
652
+ throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
653
+ }
654
+ const merged = { ...existing.workflowContext, ...input.patch };
655
+ db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
656
+ }
657
+ function incrementCurrentPhaseIndex(db, input) {
658
+ db.prepare(`UPDATE workflows
659
+ SET current_phase_index = current_phase_index + 1, updated_at = ?
660
+ WHERE workflow_id = ?`).run(input.now, input.workflowId);
661
+ }
662
+ function countActiveWorkflowsForCollab(db, collabId) {
663
+ const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status IN ('running', 'paused')").get(collabId);
664
+ return row.n;
665
+ }
666
+
667
+ // ../broker/dist/runtime/workspace-snapshot.js
668
+ import { execFileSync } from "node:child_process";
669
+ function captureWorkspaceSnapshotSync(workspaceRoot) {
670
+ try {
671
+ const ref = execFileSync("git", ["-C", workspaceRoot, "stash", "create"], {
672
+ encoding: "utf8",
673
+ timeout: 1e4
674
+ }).trim();
675
+ if (ref)
676
+ return ref;
677
+ const head = execFileSync("git", ["-C", workspaceRoot, "rev-parse", "HEAD"], {
678
+ encoding: "utf8",
679
+ timeout: 1e4
680
+ }).trim();
681
+ return head || null;
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+ function diffChangedFilesSince(workspaceRoot, sinceRef) {
687
+ try {
688
+ const stdout = execFileSync("git", ["-C", workspaceRoot, "diff", "--name-only", sinceRef], { encoding: "utf8", timeout: 1e4 });
689
+ const files = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".ai-whisper/"));
690
+ return [...new Set(files)].sort();
691
+ } catch {
692
+ return [];
693
+ }
694
+ }
695
+
602
696
  // ../broker/dist/storage/repositories/reply-repository.js
603
697
  function insertReply(db, reply) {
604
698
  db.prepare(`INSERT INTO reply (
@@ -1088,7 +1182,7 @@ function upsertRelayTurnState(db, input) {
1088
1182
  }
1089
1183
 
1090
1184
  // ../broker/dist/storage/repositories/relay-capture-diagnostics-repository.js
1091
- function rowToRecord(row) {
1185
+ function rowToRecord2(row) {
1092
1186
  return {
1093
1187
  captureId: row.capture_id,
1094
1188
  handoffId: row.handoff_id,
@@ -1105,6 +1199,7 @@ function rowToRecord(row) {
1105
1199
  clipSample: row.clip_sample,
1106
1200
  turnSample: row.turn_sample,
1107
1201
  abortedByRaceGuard: row.aborted_by_race_guard === 1,
1202
+ interferenceDetected: row.interference_detected === 1,
1108
1203
  createdAt: row.created_at
1109
1204
  };
1110
1205
  }
@@ -1112,8 +1207,9 @@ function insertCaptureDiagnostic(db, input) {
1112
1207
  db.prepare(`INSERT INTO relay_capture_diagnostics
1113
1208
  (capture_id, handoff_id, collab_id, chain_id, workflow_id, target_provider,
1114
1209
  capture_status, clip_len, turn_len, turn_confidence, jaccard_score,
1115
- containment_score, clip_sample, turn_sample, aborted_by_race_guard, created_at)
1116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.captureId, input.handoffId, input.collabId, input.chainId, input.workflowId, input.targetProvider, input.captureStatus, input.clipLen, input.turnLen, input.turnConfidence, input.jaccardScore, input.containmentScore, input.clipSample, input.turnSample, input.abortedByRaceGuard ? 1 : 0, input.createdAt);
1210
+ containment_score, clip_sample, turn_sample, aborted_by_race_guard,
1211
+ interference_detected, created_at)
1212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.captureId, input.handoffId, input.collabId, input.chainId, input.workflowId, input.targetProvider, input.captureStatus, input.clipLen, input.turnLen, input.turnConfidence, input.jaccardScore, input.containmentScore, input.clipSample, input.turnSample, input.abortedByRaceGuard ? 1 : 0, input.interferenceDetected ? 1 : 0, input.createdAt);
1117
1213
  }
1118
1214
  function listCaptureDiagnosticsByCollab(db, collabId, limit, opts) {
1119
1215
  const filter = opts?.workflowFilter;
@@ -1123,32 +1219,32 @@ function listCaptureDiagnosticsByCollab(db, collabId, limit, opts) {
1123
1219
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1124
1220
  WHERE collab_id = ?${filterClause}
1125
1221
  ORDER BY created_at DESC`).all(collabId, ...filterArgs);
1126
- return rows2.map(rowToRecord);
1222
+ return rows2.map(rowToRecord2);
1127
1223
  }
1128
1224
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1129
1225
  WHERE collab_id = ?${filterClause}
1130
1226
  ORDER BY created_at DESC
1131
1227
  LIMIT ?`).all(collabId, ...filterArgs, limit);
1132
- return rows.map(rowToRecord);
1228
+ return rows.map(rowToRecord2);
1133
1229
  }
1134
1230
  function listCaptureDiagnosticsByCollabAndChain(db, collabId, chainId, limit) {
1135
1231
  if (limit === null) {
1136
1232
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1137
1233
  WHERE collab_id = ? AND chain_id = ?
1138
1234
  ORDER BY created_at DESC`).all(collabId, chainId);
1139
- return rows2.map(rowToRecord);
1235
+ return rows2.map(rowToRecord2);
1140
1236
  }
1141
1237
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1142
1238
  WHERE collab_id = ? AND chain_id = ?
1143
1239
  ORDER BY created_at DESC
1144
1240
  LIMIT ?`).all(collabId, chainId, limit);
1145
- return rows.map(rowToRecord);
1241
+ return rows.map(rowToRecord2);
1146
1242
  }
1147
1243
  function listCaptureDiagnosticsByHandoff(db, handoffId) {
1148
1244
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1149
1245
  WHERE handoff_id = ?
1150
1246
  ORDER BY created_at ASC`).all(handoffId);
1151
- return rows.map(rowToRecord);
1247
+ return rows.map(rowToRecord2);
1152
1248
  }
1153
1249
  function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1154
1250
  const result = db.prepare("DELETE FROM relay_capture_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1156,7 +1252,7 @@ function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1156
1252
  }
1157
1253
 
1158
1254
  // ../broker/dist/storage/repositories/relay-evaluator-diagnostics-repository.js
1159
- function rowToRecord2(row) {
1255
+ function rowToRecord3(row) {
1160
1256
  return {
1161
1257
  evaluatorId: row.evaluator_id,
1162
1258
  handoffId: row.handoff_id,
@@ -1201,32 +1297,32 @@ function listEvaluatorDiagnosticsByCollab(db, collabId, limit, opts) {
1201
1297
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1202
1298
  WHERE collab_id = ?${filterClause}
1203
1299
  ORDER BY created_at DESC`).all(collabId, ...filterArgs);
1204
- return rows2.map(rowToRecord2);
1300
+ return rows2.map(rowToRecord3);
1205
1301
  }
1206
1302
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1207
1303
  WHERE collab_id = ?${filterClause}
1208
1304
  ORDER BY created_at DESC
1209
1305
  LIMIT ?`).all(collabId, ...filterArgs, limit);
1210
- return rows.map(rowToRecord2);
1306
+ return rows.map(rowToRecord3);
1211
1307
  }
1212
1308
  function listEvaluatorDiagnosticsByCollabAndChain(db, collabId, chainId, limit) {
1213
1309
  if (limit === null) {
1214
1310
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1215
1311
  WHERE collab_id = ? AND chain_id = ?
1216
1312
  ORDER BY created_at DESC`).all(collabId, chainId);
1217
- return rows2.map(rowToRecord2);
1313
+ return rows2.map(rowToRecord3);
1218
1314
  }
1219
1315
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1220
1316
  WHERE collab_id = ? AND chain_id = ?
1221
1317
  ORDER BY created_at DESC
1222
1318
  LIMIT ?`).all(collabId, chainId, limit);
1223
- return rows.map(rowToRecord2);
1319
+ return rows.map(rowToRecord3);
1224
1320
  }
1225
1321
  function listEvaluatorDiagnosticsByHandoff(db, handoffId) {
1226
1322
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1227
1323
  WHERE handoff_id = ?
1228
1324
  ORDER BY created_at ASC`).all(handoffId);
1229
- return rows.map(rowToRecord2);
1325
+ return rows.map(rowToRecord3);
1230
1326
  }
1231
1327
  function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1232
1328
  const result = db.prepare("DELETE FROM relay_evaluator_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1234,7 +1330,7 @@ function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1234
1330
  }
1235
1331
 
1236
1332
  // ../broker/dist/storage/repositories/relay-handoff-repository.js
1237
- function rowToRecord3(row) {
1333
+ function rowToRecord4(row) {
1238
1334
  return {
1239
1335
  handoffId: row.handoff_id,
1240
1336
  collabId: row.collab_id,
@@ -1271,7 +1367,7 @@ function queryRelayHandoff(db, handoffId) {
1271
1367
  FROM relay_handoff h
1272
1368
  JOIN collab c ON c.collab_id = h.collab_id
1273
1369
  WHERE h.handoff_id = ?`).get(handoffId);
1274
- return row ? rowToRecord3(row) : null;
1370
+ return row ? rowToRecord4(row) : null;
1275
1371
  }
1276
1372
  function createRelayHandoffTxn(db, input) {
1277
1373
  return db.transaction(() => {
@@ -1423,7 +1519,7 @@ function queryLatestHandedBackHandoff(db, collabId) {
1423
1519
  WHERE h.collab_id = ? AND h.status = 'handed_back'
1424
1520
  ORDER BY h.resolved_at DESC
1425
1521
  LIMIT 1`).get(collabId);
1426
- return row ? rowToRecord3(row) : null;
1522
+ return row ? rowToRecord4(row) : null;
1427
1523
  }
1428
1524
  function failRelayHandoffOnDisconnectTxn(db, input) {
1429
1525
  db.transaction(() => {
@@ -1447,6 +1543,30 @@ function failRelayHandoffOnDisconnectTxn(db, input) {
1447
1543
  }
1448
1544
  })();
1449
1545
  }
1546
+ function hasInFlightAcceptedHandoffForWorkflow(db, workflowId) {
1547
+ const row = db.prepare("SELECT COUNT(*) AS n FROM relay_handoff WHERE workflow_id = ? AND status = 'accepted'").get(workflowId);
1548
+ return row.n > 0;
1549
+ }
1550
+ function resetStrandedOrchestrationForWorkflow(db, workflowId) {
1551
+ const result = db.prepare(`UPDATE relay_handoff
1552
+ SET orchestrator_status = 'idle', orchestrator_claimed_at = NULL
1553
+ WHERE workflow_id = ?
1554
+ AND status = 'handed_back'
1555
+ AND orchestrator_status = 'pending'
1556
+ AND evaluator_verdict IS NULL`).run(workflowId);
1557
+ return result.changes;
1558
+ }
1559
+ function prependToPendingHandoffRequestText(db, input) {
1560
+ const row = db.prepare(`SELECT handoff_id, request_text FROM relay_handoff
1561
+ WHERE workflow_id = ? AND status = 'pending'
1562
+ ORDER BY created_at DESC LIMIT 1`).get(input.workflowId);
1563
+ if (!row)
1564
+ return false;
1565
+ db.prepare("UPDATE relay_handoff SET request_text = ?, last_activity_at = ? WHERE handoff_id = ?").run(`${input.prefix}
1566
+
1567
+ ${row.request_text}`, input.now, row.handoff_id);
1568
+ return true;
1569
+ }
1450
1570
  function claimRelayHandoffForOrchestrationTxn(db, input) {
1451
1571
  const result = db.prepare(`UPDATE relay_handoff
1452
1572
  SET orchestrator_status = 'pending',
@@ -1465,10 +1585,12 @@ function listRelayHandoffsPendingOrchestration(db, collabId) {
1465
1585
  c.orchestrator_max_rounds AS max_rounds
1466
1586
  FROM relay_handoff h
1467
1587
  JOIN collab c ON c.collab_id = h.collab_id
1588
+ LEFT JOIN workflows w ON w.workflow_id = h.workflow_id
1468
1589
  WHERE h.collab_id = ?
1469
1590
  AND h.status = 'handed_back'
1470
- AND h.orchestrator_status = 'idle'`).all(collabId);
1471
- return rows.map(rowToRecord3);
1591
+ AND h.orchestrator_status = 'idle'
1592
+ AND (w.status IS NULL OR w.status <> 'paused')`).all(collabId);
1593
+ return rows.map(rowToRecord4);
1472
1594
  }
1473
1595
  function createLoopRelayHandoffTxn(db, input) {
1474
1596
  return db.transaction(() => {
@@ -1664,7 +1786,7 @@ function getHandoffWithWorkflowMetaById(db, handoffId) {
1664
1786
  WHERE h.handoff_id = ?`).get(handoffId);
1665
1787
  if (!row)
1666
1788
  return null;
1667
- const base = rowToRecord3(row);
1789
+ const base = rowToRecord4(row);
1668
1790
  return {
1669
1791
  ...base,
1670
1792
  handoffStep: row.handoff_step,
@@ -1914,71 +2036,6 @@ function listRunCostRows(db, input) {
1914
2036
  // ../broker/dist/control/workflow-control.js
1915
2037
  import { randomUUID } from "node:crypto";
1916
2038
 
1917
- // ../broker/dist/storage/repositories/workflow-repository.js
1918
- function rowToRecord4(row) {
1919
- return {
1920
- workflowId: row.workflow_id,
1921
- collabId: row.collab_id,
1922
- workflowType: row.workflow_type,
1923
- name: row.name,
1924
- specPath: row.spec_path,
1925
- roleBindings: JSON.parse(row.role_bindings),
1926
- status: row.status,
1927
- currentPhaseIndex: row.current_phase_index,
1928
- haltReason: row.halt_reason,
1929
- workflowContext: JSON.parse(row.workflow_context),
1930
- createdAt: row.created_at,
1931
- updatedAt: row.updated_at
1932
- };
1933
- }
1934
- function insertWorkflow(db, input) {
1935
- db.prepare(`INSERT INTO workflows
1936
- (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
1937
- current_phase_index, halt_reason, workflow_context, created_at, updated_at)
1938
- 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);
1939
- }
1940
- function getWorkflowById(db, workflowId) {
1941
- const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
1942
- return row ? rowToRecord4(row) : null;
1943
- }
1944
- function listWorkflows(db, filter = {}) {
1945
- const clauses = [];
1946
- const args = [];
1947
- if (filter.collabId) {
1948
- clauses.push("collab_id = ?");
1949
- args.push(filter.collabId);
1950
- }
1951
- if (filter.status) {
1952
- clauses.push("status = ?");
1953
- args.push(filter.status);
1954
- }
1955
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1956
- const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
1957
- return rows.map(rowToRecord4);
1958
- }
1959
- function setWorkflowStatus(db, input) {
1960
- db.prepare(`UPDATE workflows
1961
- SET status = ?, halt_reason = ?, updated_at = ?
1962
- WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
1963
- }
1964
- function updateWorkflowContext(db, input) {
1965
- const existing = getWorkflowById(db, input.workflowId);
1966
- if (!existing) {
1967
- throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
1968
- }
1969
- const merged = { ...existing.workflowContext, ...input.patch };
1970
- db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
1971
- }
1972
- function incrementCurrentPhaseIndex(db, input) {
1973
- db.prepare(`UPDATE workflows
1974
- SET current_phase_index = current_phase_index + 1, updated_at = ?
1975
- WHERE workflow_id = ?`).run(input.now, input.workflowId);
1976
- }
1977
- function countRunningWorkflowsForCollab(db, collabId) {
1978
- const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status = 'running'").get(collabId);
1979
- return row.n;
1980
- }
1981
-
1982
2039
  // ../broker/dist/storage/repositories/workflow-phase-repository.js
1983
2040
  function rowToRecord5(row) {
1984
2041
  return {
@@ -2099,6 +2156,14 @@ Findings: (omit this block entirely if none)
2099
2156
  Non-blocking risks:
2100
2157
  - <quality risk that does NOT block this gate, or "None.">
2101
2158
  --- end protocol ---`;
2159
+ var WORKFLOW_OPERATOR_CONTROL = `--- ai-whisper operator control ---
2160
+ If the operator interrupts you and asks to pause the workflow (e.g. "pause the workflow, I need to fix X"):
2161
+ 1. Find the active workflow id: run \`whisper workflow list\`.
2162
+ 2. Run \`whisper workflow pause <id>\`.
2163
+ 3. Acknowledge and STOP working \u2014 do not start the next change.
2164
+ 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.
2165
+ 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.
2166
+ --- end operator control ---`;
2102
2167
  var WORKFLOW_DIAGNOSIS_PROTOCOL = `--- ai-whisper diagnosis review protocol ---
2103
2168
  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.
2104
2169
 
@@ -2337,10 +2402,21 @@ var COMPLEX_BUG_FIXING = {
2337
2402
  }
2338
2403
  ]
2339
2404
  };
2405
+ function withOperatorControl(def) {
2406
+ return {
2407
+ ...def,
2408
+ phases: def.phases.map((p) => ({
2409
+ ...p,
2410
+ kickoffTemplate: `${p.kickoffTemplate}
2411
+
2412
+ ${WORKFLOW_OPERATOR_CONTROL}`
2413
+ }))
2414
+ };
2415
+ }
2340
2416
  var REGISTRY = {
2341
- [SPEC_DRIVEN_DEVELOPMENT.type]: SPEC_DRIVEN_DEVELOPMENT,
2342
- [RALPH_LOOP.type]: RALPH_LOOP,
2343
- [COMPLEX_BUG_FIXING.type]: COMPLEX_BUG_FIXING
2417
+ [SPEC_DRIVEN_DEVELOPMENT.type]: withOperatorControl(SPEC_DRIVEN_DEVELOPMENT),
2418
+ [RALPH_LOOP.type]: withOperatorControl(RALPH_LOOP),
2419
+ [COMPLEX_BUG_FIXING.type]: withOperatorControl(COMPLEX_BUG_FIXING)
2344
2420
  };
2345
2421
  function ralphRunDir(workspaceRoot, workflowId) {
2346
2422
  return join(workspaceRoot, ".ai-whisper", "ralph", workflowId);
@@ -2396,6 +2472,23 @@ function safeDerivePlanPath(specPath, createdAt) {
2396
2472
  return `${dir}${stem}.plan.md`;
2397
2473
  }
2398
2474
  }
2475
+ function composeResumeNotice(input) {
2476
+ const hasFiles = input.changedFiles.length > 0;
2477
+ const hasMessage = !!input.message && input.message.trim().length > 0;
2478
+ if (!hasFiles && !hasMessage)
2479
+ return null;
2480
+ const lines = [];
2481
+ if (hasFiles) {
2482
+ lines.push("While paused, the operator modified these files:");
2483
+ for (const f of input.changedFiles)
2484
+ lines.push(` - ${f}`);
2485
+ lines.push("Re-read them before continuing.");
2486
+ }
2487
+ if (hasMessage)
2488
+ lines.push(`Operator note: ${input.message.trim()}`);
2489
+ lines.push("Re-evaluate whether your current direction still holds; correct course before proceeding.");
2490
+ return lines.join("\n");
2491
+ }
2399
2492
  function createWorkflowControl(deps) {
2400
2493
  const { db, events } = deps;
2401
2494
  function createWorkflow(input) {
@@ -2418,8 +2511,9 @@ function createWorkflowControl(deps) {
2418
2511
  }
2419
2512
  const workflowId = `wf_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2420
2513
  const tx = db.transaction(() => {
2421
- if (countRunningWorkflowsForCollab(db, input.collabId) > 0) {
2422
- throw new Error(`another workflow is already running on this collab (${input.collabId})`);
2514
+ if (countActiveWorkflowsForCollab(db, input.collabId) > 0) {
2515
+ const active = listWorkflows(db, { collabId: input.collabId }).find((w) => w.status === "running" || w.status === "paused");
2516
+ throw new Error(`another workflow is already active on this collab (${input.collabId})` + (active ? `: ${active.workflowId} (${active.status})` : ""));
2423
2517
  }
2424
2518
  insertWorkflow(db, {
2425
2519
  workflowId,
@@ -2587,6 +2681,24 @@ function createWorkflowControl(deps) {
2587
2681
  postmortemPath: bf.postmortemPath
2588
2682
  });
2589
2683
  }
2684
+ function consumeResumeNotice(input) {
2685
+ const wf = getWorkflowById(db, input.workflowId);
2686
+ const notice = wf?.workflowContext?.resumeNotice ?? null;
2687
+ if (notice) {
2688
+ updateWorkflowContext(db, {
2689
+ workflowId: input.workflowId,
2690
+ patch: { resumeNotice: null },
2691
+ now: input.now
2692
+ });
2693
+ }
2694
+ return notice;
2695
+ }
2696
+ function withResumeNotice(workflowId, requestText, now) {
2697
+ const notice = consumeResumeNotice({ workflowId, now });
2698
+ return notice ? `${notice}
2699
+
2700
+ ${requestText}` : requestText;
2701
+ }
2590
2702
  function createContinuationHandoff(input) {
2591
2703
  const handoffId = `ho_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2592
2704
  insertWorkflowOwnedRelayHandoff(db, {
@@ -2594,7 +2706,9 @@ function createWorkflowControl(deps) {
2594
2706
  collabId: input.workflow.collabId,
2595
2707
  senderAgent: input.sender,
2596
2708
  targetAgent: input.target,
2597
- requestText: input.requestText,
2709
+ // Prepend the one-time resume notice (if any) to the FIRST outgoing request
2710
+ // after a resume, then it is cleared — so the agent re-reads changed files.
2711
+ requestText: withResumeNotice(input.workflow.workflowId, input.requestText, input.now),
2598
2712
  chainId: input.chain.chainId,
2599
2713
  roundNumber: input.incrementRound ? input.chain.currentRound + 1 : input.chain.currentRound,
2600
2714
  maxRounds: input.chain.maxRounds,
@@ -2654,7 +2768,9 @@ function createWorkflowControl(deps) {
2654
2768
  collabId: input.workflow.collabId,
2655
2769
  senderAgent: sender,
2656
2770
  targetAgent: target,
2657
- requestText: kickoffText,
2771
+ // Prepend a pending resume notice (consumed once) so a phase-advance kickoff
2772
+ // after a resume also carries the operator's change notice.
2773
+ requestText: withResumeNotice(input.workflow.workflowId, kickoffText, input.now),
2658
2774
  chainId,
2659
2775
  roundNumber: 1,
2660
2776
  maxRounds: phase.maxRounds,
@@ -3115,20 +3231,100 @@ ${findingsText}`;
3115
3231
  reason: input.reason
3116
3232
  });
3117
3233
  }
3234
+ function maybeCaptureQuiesceSnapshot(input) {
3235
+ const wf = getWorkflowById(db, input.workflowId);
3236
+ if (!wf || wf.status !== "paused")
3237
+ return;
3238
+ if (wf.workflowContext.pauseSnapshotRef !== void 0) {
3239
+ return;
3240
+ }
3241
+ if (hasInFlightAcceptedHandoffForWorkflow(db, input.workflowId))
3242
+ return;
3243
+ const ref = deps.captureSnapshotRef ? deps.captureSnapshotRef(input.workflowId) : null;
3244
+ updateWorkflowContext(db, {
3245
+ workflowId: input.workflowId,
3246
+ patch: { pauseSnapshotRef: ref },
3247
+ now: input.now
3248
+ });
3249
+ }
3250
+ function pauseWorkflow(input) {
3251
+ const workflow = getWorkflowById(db, input.workflowId);
3252
+ if (!workflow) {
3253
+ throw new Error(`pauseWorkflow: unknown workflowId ${input.workflowId}`);
3254
+ }
3255
+ if (workflow.status !== "running") {
3256
+ throw new Error(`pauseWorkflow: workflow ${input.workflowId} is ${workflow.status}, only running workflows can be paused`);
3257
+ }
3258
+ const tx = db.transaction(() => {
3259
+ const current = getWorkflowById(db, input.workflowId);
3260
+ if (!current || current.status !== "running")
3261
+ return;
3262
+ setWorkflowStatus(db, {
3263
+ workflowId: input.workflowId,
3264
+ status: "paused",
3265
+ haltReason: null,
3266
+ now: input.now
3267
+ });
3268
+ updateWorkflowContext(db, {
3269
+ workflowId: input.workflowId,
3270
+ patch: { pausedAt: input.now },
3271
+ now: input.now
3272
+ });
3273
+ });
3274
+ tx.immediate();
3275
+ events.emit("workflow.paused", { workflowId: input.workflowId });
3276
+ maybeCaptureQuiesceSnapshot({ workflowId: input.workflowId, now: input.now });
3277
+ }
3278
+ function resumeHaltedWorkflow(workflow, now) {
3279
+ const tx = db.transaction(() => {
3280
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== workflow.workflowId && (w.status === "running" || w.status === "paused"));
3281
+ if (others.length > 0) {
3282
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3283
+ }
3284
+ setWorkflowStatus(db, {
3285
+ workflowId: workflow.workflowId,
3286
+ status: "running",
3287
+ haltReason: null,
3288
+ now
3289
+ });
3290
+ });
3291
+ tx.immediate();
3292
+ events.emit("workflow.resumed", {
3293
+ workflowId: workflow.workflowId,
3294
+ phaseIndex: workflow.currentPhaseIndex
3295
+ });
3296
+ }
3118
3297
  function resumeWorkflow(input) {
3119
3298
  const workflow = getWorkflowById(db, input.workflowId);
3120
3299
  if (!workflow) {
3121
3300
  throw new Error(`resumeWorkflow: unknown workflowId ${input.workflowId}`);
3122
3301
  }
3123
- if (workflow.status === "canceled") {
3124
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is canceled and cannot be resumed`);
3302
+ if (workflow.status === "halted") {
3303
+ resumeHaltedWorkflow(workflow, input.now);
3304
+ return;
3125
3305
  }
3126
- if (workflow.status !== "halted") {
3127
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is not halted (status=${workflow.status})`);
3306
+ if (workflow.status !== "paused") {
3307
+ throw new Error(`resumeWorkflow: workflow ${input.workflowId} is ${workflow.status}, only paused or halted workflows can be resumed`);
3128
3308
  }
3309
+ const ref = workflow.workflowContext.pauseSnapshotRef ?? null;
3310
+ const changedFiles = ref && deps.diffChangedFilesSinceSnapshot ? deps.diffChangedFilesSinceSnapshot(input.workflowId, ref) : [];
3311
+ const notice = composeResumeNotice({
3312
+ changedFiles,
3313
+ message: input.message ?? null
3314
+ });
3129
3315
  const tx = db.transaction(() => {
3130
- if (countRunningWorkflowsForCollab(db, workflow.collabId) > 0) {
3131
- throw new Error(`resumeWorkflow: another workflow is already running on collab ${workflow.collabId}`);
3316
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== input.workflowId && (w.status === "running" || w.status === "paused"));
3317
+ if (others.length > 0) {
3318
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3319
+ }
3320
+ resetStrandedOrchestrationForWorkflow(db, input.workflowId);
3321
+ let bakedIntoPending = false;
3322
+ if (notice) {
3323
+ bakedIntoPending = prependToPendingHandoffRequestText(db, {
3324
+ workflowId: input.workflowId,
3325
+ prefix: notice,
3326
+ now: input.now
3327
+ });
3132
3328
  }
3133
3329
  setWorkflowStatus(db, {
3134
3330
  workflowId: input.workflowId,
@@ -3136,6 +3332,15 @@ ${findingsText}`;
3136
3332
  haltReason: null,
3137
3333
  now: input.now
3138
3334
  });
3335
+ updateWorkflowContext(db, {
3336
+ workflowId: input.workflowId,
3337
+ patch: {
3338
+ resumeNotice: bakedIntoPending ? null : notice,
3339
+ pausedAt: null,
3340
+ pauseSnapshotRef: null
3341
+ },
3342
+ now: input.now
3343
+ });
3139
3344
  });
3140
3345
  tx.immediate();
3141
3346
  events.emit("workflow.resumed", {
@@ -3236,6 +3441,9 @@ ${findingsText}`;
3236
3441
  beginPhaseRun,
3237
3442
  applyOrchestratorVerdict,
3238
3443
  haltWorkflow,
3444
+ pauseWorkflow,
3445
+ maybeCaptureQuiesceSnapshot,
3446
+ consumeResumeNotice,
3239
3447
  resumeWorkflow,
3240
3448
  cancelWorkflow,
3241
3449
  getHandoffWithWorkflowMeta,
@@ -3251,8 +3459,32 @@ function buildEventId(kind, subjectId, timestamp) {
3251
3459
  return `evt_${kind}_${subjectId}_${normalizeTimestampForEventId(timestamp)}`;
3252
3460
  }
3253
3461
  function createControlService(db, events) {
3254
- const workflowControl = createWorkflowControl({ db, events });
3462
+ const resolveWorkspaceRoot = (workflowId) => {
3463
+ const wf = getWorkflowById(db, workflowId);
3464
+ if (!wf)
3465
+ return null;
3466
+ return getCollab(db, wf.collabId)?.workspaceRoot ?? null;
3467
+ };
3468
+ const workflowControl = createWorkflowControl({
3469
+ db,
3470
+ events,
3471
+ captureSnapshotRef: (workflowId) => {
3472
+ const root = resolveWorkspaceRoot(workflowId);
3473
+ return root ? captureWorkspaceSnapshotSync(root) : null;
3474
+ },
3475
+ diffChangedFilesSinceSnapshot: (workflowId, sinceRef) => {
3476
+ const root = resolveWorkspaceRoot(workflowId);
3477
+ return root ? diffChangedFilesSince(root, sinceRef) : [];
3478
+ }
3479
+ });
3480
+ const isWorkflowDeliverySuspended = (handoffId) => {
3481
+ const meta = workflowControl.getHandoffWithWorkflowMeta(handoffId);
3482
+ if (!meta?.workflowId)
3483
+ return false;
3484
+ return workflowControl.getWorkflow(meta.workflowId)?.status === "paused";
3485
+ };
3255
3486
  return Object.assign({
3487
+ isWorkflowDeliverySuspended,
3256
3488
  startCollab(input) {
3257
3489
  const collab = collabSchema.parse({
3258
3490
  version: 1,
@@ -3880,6 +4112,8 @@ function createControlService(db, events) {
3880
4112
  return createRelayHandoffTxn(db, input);
3881
4113
  },
3882
4114
  acceptRelayHandoff(input) {
4115
+ if (isWorkflowDeliverySuspended(input.handoffId))
4116
+ return;
3883
4117
  return acceptRelayHandoffTxn(db, input);
3884
4118
  },
3885
4119
  deferRelayHandoff(input) {
@@ -3892,7 +4126,12 @@ function createControlService(db, events) {
3892
4126
  return markRelayHandoffStaleTxn(db, input);
3893
4127
  },
3894
4128
  handoffBackRelay(input) {
3895
- return handoffBackRelayTxn(db, input);
4129
+ const result = handoffBackRelayTxn(db, input);
4130
+ const meta = workflowControl.getHandoffWithWorkflowMeta(input.handoffId);
4131
+ if (meta?.workflowId && workflowControl.getWorkflow(meta.workflowId)?.status === "paused") {
4132
+ workflowControl.maybeCaptureQuiesceSnapshot({ workflowId: meta.workflowId, now: input.now });
4133
+ }
4134
+ return result;
3896
4135
  },
3897
4136
  failRelayHandoffOnDisconnect(input) {
3898
4137
  return failRelayHandoffOnDisconnectTxn(db, input);
@@ -3904,6 +4143,8 @@ function createControlService(db, events) {
3904
4143
  return queryLatestHandedBackHandoff(db, collabId);
3905
4144
  },
3906
4145
  claimRelayHandoffForOrchestration(input) {
4146
+ if (isWorkflowDeliverySuspended(input.handoffId))
4147
+ return null;
3907
4148
  return claimRelayHandoffForOrchestrationTxn(db, input);
3908
4149
  },
3909
4150
  listRelayHandoffsPendingOrchestration(collabId) {
@@ -3939,6 +4180,7 @@ function createControlService(db, events) {
3939
4180
  clipSample: input.clipSample,
3940
4181
  turnSample: input.turnSample,
3941
4182
  abortedByRaceGuard: input.abortedByRaceGuard,
4183
+ interferenceDetected: input.interferenceDetected ?? false,
3942
4184
  createdAt: input.now
3943
4185
  });
3944
4186
  return { captureId };
@@ -4015,10 +4257,155 @@ function createBrokerApp(input) {
4015
4257
  return app;
4016
4258
  }
4017
4259
 
4018
- // ../broker/dist/storage/apply-migrations.js
4019
- var CURRENT_SCHEMA_VERSION = 4;
4020
- var initMigrationSql = `
4021
- CREATE TABLE IF NOT EXISTS broker_state (
4260
+ // ../broker/dist/storage/enforce-one-active-collab.js
4261
+ function defaultIsPidAlive(pid) {
4262
+ try {
4263
+ process.kill(pid, 0);
4264
+ return true;
4265
+ } catch (err) {
4266
+ return err.code === "EPERM";
4267
+ }
4268
+ }
4269
+ function dedupeActiveCollabs(db, opts) {
4270
+ const rows = db.prepare(`SELECT c.collab_id, c.workspace_id, c.created_at, d.pid AS pid,
4271
+ (SELECT COUNT(*) FROM workflows w
4272
+ WHERE w.collab_id = c.collab_id AND w.status = 'running') AS running_workflows
4273
+ FROM collab c
4274
+ LEFT JOIN broker_daemon d ON d.collab_id = c.collab_id
4275
+ WHERE c.status = 'active' AND c.workspace_id IS NOT NULL`).all();
4276
+ const byWorkspace = /* @__PURE__ */ new Map();
4277
+ for (const row of rows) {
4278
+ const key = row.workspace_id;
4279
+ const list = byWorkspace.get(key);
4280
+ if (list)
4281
+ list.push(row);
4282
+ else
4283
+ byWorkspace.set(key, [row]);
4284
+ }
4285
+ const conflicted = [];
4286
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4287
+ const stop = db.prepare("UPDATE collab SET status = 'stopped', stopped_at = ?, updated_at = ? WHERE collab_id = ?");
4288
+ for (const [workspaceId, group] of byWorkspace) {
4289
+ if (group.length < 2)
4290
+ continue;
4291
+ const workflowOwners = group.filter((r) => r.running_workflows > 0);
4292
+ if (workflowOwners.length >= 2) {
4293
+ conflicted.push(workspaceId);
4294
+ opts.warn(`Workspace ${workspaceId} has ${workflowOwners.length} active collabs that each own a running workflow: ${workflowOwners.map((r) => r.collab_id).join(", ")}. Refusing to auto-stop any (that would orphan a live run). Stop the extra collab(s) manually with \`whisper collab stop --collab <id>\`. The one-active-collab-per-workspace index will be created automatically once only one remains.`);
4295
+ continue;
4296
+ }
4297
+ let survivor;
4298
+ if (workflowOwners.length === 1) {
4299
+ survivor = workflowOwners[0];
4300
+ } else {
4301
+ const live = group.filter((r) => r.pid !== null && opts.isPidAlive(r.pid));
4302
+ if (live.length >= 1) {
4303
+ survivor = live.reduce((a, b) => a.created_at >= b.created_at ? a : b);
4304
+ } else {
4305
+ survivor = group.reduce((a, b) => a.created_at >= b.created_at ? a : b);
4306
+ }
4307
+ }
4308
+ for (const row of group) {
4309
+ if (row.collab_id === survivor.collab_id)
4310
+ continue;
4311
+ stop.run(now, now, row.collab_id);
4312
+ }
4313
+ }
4314
+ return conflicted;
4315
+ }
4316
+ var CREATE_INDEX_SQL = "CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_one_active_per_workspace ON collab(workspace_id) WHERE status = 'active'";
4317
+ function residualDuplicateCount(db) {
4318
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM (
4319
+ SELECT workspace_id FROM collab
4320
+ WHERE status = 'active' AND workspace_id IS NOT NULL
4321
+ GROUP BY workspace_id HAVING COUNT(*) > 1
4322
+ )`).get();
4323
+ return row.n;
4324
+ }
4325
+ function enforceOneActiveCollabPerWorkspace(db, options = {}) {
4326
+ const opts = {
4327
+ isPidAlive: options.isPidAlive ?? defaultIsPidAlive,
4328
+ warn: options.warn ?? ((m) => console.error(m))
4329
+ };
4330
+ const tx = db.transaction(() => {
4331
+ dedupeActiveCollabs(db, opts);
4332
+ if (residualDuplicateCount(db) === 0) {
4333
+ db.exec(CREATE_INDEX_SQL);
4334
+ } else {
4335
+ opts.warn("Skipping idx_collab_one_active_per_workspace: an irreducible duplicate active collab remains. The index will be created on a later startup once only one active collab per workspace exists.");
4336
+ }
4337
+ });
4338
+ tx();
4339
+ }
4340
+
4341
+ // ../broker/dist/storage/clipboard-capture-lease.js
4342
+ var LEASE_ID = 1;
4343
+ var DEFAULT_LEASE_TTL_MS = 5e3;
4344
+ function defaultIsPidAlive2(pid) {
4345
+ try {
4346
+ process.kill(pid, 0);
4347
+ return true;
4348
+ } catch (err) {
4349
+ return err.code === "EPERM";
4350
+ }
4351
+ }
4352
+ function resolveOptions(options) {
4353
+ return {
4354
+ isPidAlive: options.isPidAlive ?? defaultIsPidAlive2,
4355
+ ttlMs: options.ttlMs ?? DEFAULT_LEASE_TTL_MS,
4356
+ now: options.now ?? Date.now
4357
+ };
4358
+ }
4359
+ function isStale(row, opts) {
4360
+ if (row.holder_collab_id === null)
4361
+ return true;
4362
+ if (row.holder_pid === null || !opts.isPidAlive(row.holder_pid))
4363
+ return true;
4364
+ if (row.acquired_at === null)
4365
+ return true;
4366
+ const age = opts.now() - Date.parse(row.acquired_at);
4367
+ return age > opts.ttlMs;
4368
+ }
4369
+ function acquireCaptureLease(db, collabId, pid, options = {}) {
4370
+ const opts = resolveOptions(options);
4371
+ const tx = db.transaction(() => {
4372
+ const row = db.prepare("SELECT holder_collab_id, holder_pid, acquired_at FROM clipboard_capture_lease WHERE id = ?").get(LEASE_ID);
4373
+ if (row && !isStale(row, opts))
4374
+ return false;
4375
+ const acquiredAt = new Date(opts.now()).toISOString();
4376
+ db.prepare(`INSERT INTO clipboard_capture_lease (id, holder_collab_id, holder_pid, acquired_at)
4377
+ VALUES (?, ?, ?, ?)
4378
+ ON CONFLICT(id) DO UPDATE SET
4379
+ holder_collab_id = excluded.holder_collab_id,
4380
+ holder_pid = excluded.holder_pid,
4381
+ acquired_at = excluded.acquired_at`).run(LEASE_ID, collabId, pid, acquiredAt);
4382
+ return true;
4383
+ });
4384
+ return tx();
4385
+ }
4386
+ function releaseCaptureLease(db, collabId) {
4387
+ const tx = db.transaction(() => {
4388
+ db.prepare("UPDATE clipboard_capture_lease SET holder_collab_id = NULL, holder_pid = NULL, acquired_at = NULL WHERE id = ? AND holder_collab_id = ?").run(LEASE_ID, collabId);
4389
+ });
4390
+ tx();
4391
+ }
4392
+ function sweepStaleCaptureLease(db, options = {}) {
4393
+ const opts = resolveOptions(options);
4394
+ const tx = db.transaction(() => {
4395
+ const row = db.prepare("SELECT holder_collab_id, holder_pid, acquired_at FROM clipboard_capture_lease WHERE id = ?").get(LEASE_ID);
4396
+ if (!row || row.holder_collab_id === null)
4397
+ return;
4398
+ if (isStale(row, opts)) {
4399
+ db.prepare("UPDATE clipboard_capture_lease SET holder_collab_id = NULL, holder_pid = NULL, acquired_at = NULL WHERE id = ?").run(LEASE_ID);
4400
+ }
4401
+ });
4402
+ tx();
4403
+ }
4404
+
4405
+ // ../broker/dist/storage/apply-migrations.js
4406
+ var CURRENT_SCHEMA_VERSION = 5;
4407
+ var initMigrationSql = `
4408
+ CREATE TABLE IF NOT EXISTS broker_state (
4022
4409
  id INTEGER PRIMARY KEY CHECK (id = 1),
4023
4410
  schema_version INTEGER NOT NULL,
4024
4411
  migrated INTEGER NOT NULL
@@ -4234,8 +4621,9 @@ CREATE TABLE IF NOT EXISTS workflows (
4234
4621
  updated_at TEXT NOT NULL
4235
4622
  );
4236
4623
 
4624
+ DROP INDEX IF EXISTS workflows_one_running_per_collab;
4237
4625
  CREATE UNIQUE INDEX IF NOT EXISTS workflows_one_running_per_collab
4238
- ON workflows(collab_id) WHERE status = 'running';
4626
+ ON workflows(collab_id) WHERE status IN ('running', 'paused');
4239
4627
 
4240
4628
  CREATE TABLE IF NOT EXISTS workflow_phases (
4241
4629
  phase_run_id TEXT PRIMARY KEY,
@@ -4303,6 +4691,13 @@ CREATE TABLE IF NOT EXISTS recovery_state (
4303
4691
  recovered_at TEXT
4304
4692
  );
4305
4693
 
4694
+ CREATE TABLE IF NOT EXISTS clipboard_capture_lease (
4695
+ id INTEGER PRIMARY KEY CHECK (id = 1),
4696
+ holder_collab_id TEXT,
4697
+ holder_pid INTEGER,
4698
+ acquired_at TEXT
4699
+ );
4700
+
4306
4701
  `;
4307
4702
  function ensureBrokerStateRow(db) {
4308
4703
  db.prepare(`INSERT INTO broker_state (id, schema_version, migrated)
@@ -4313,20 +4708,20 @@ function ensureBrokerStateRow(db) {
4313
4708
  }
4314
4709
  function applyMigrations(db) {
4315
4710
  const current = db.pragma("user_version", { simple: true });
4316
- if (current >= CURRENT_SCHEMA_VERSION) {
4317
- ensureBrokerStateRow(db);
4318
- return;
4319
- }
4320
- db.exec("BEGIN EXCLUSIVE");
4321
- try {
4322
- runMigrationBody(db);
4323
- db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
4324
- ensureBrokerStateRow(db);
4325
- db.exec("COMMIT");
4326
- } catch (err) {
4327
- db.exec("ROLLBACK");
4328
- throw err;
4711
+ if (current < CURRENT_SCHEMA_VERSION) {
4712
+ db.exec("BEGIN EXCLUSIVE");
4713
+ try {
4714
+ runMigrationBody(db);
4715
+ db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
4716
+ db.exec("COMMIT");
4717
+ } catch (err) {
4718
+ db.exec("ROLLBACK");
4719
+ throw err;
4720
+ }
4329
4721
  }
4722
+ ensureBrokerStateRow(db);
4723
+ enforceOneActiveCollabPerWorkspace(db);
4724
+ sweepStaleCaptureLease(db);
4330
4725
  }
4331
4726
  function runMigrationBody(db) {
4332
4727
  db.exec(initMigrationSql);
@@ -4463,6 +4858,7 @@ function runMigrationBody(db) {
4463
4858
  clip_sample TEXT,
4464
4859
  turn_sample TEXT,
4465
4860
  aborted_by_race_guard INTEGER NOT NULL DEFAULT 0,
4861
+ interference_detected INTEGER NOT NULL DEFAULT 0,
4466
4862
  created_at TEXT NOT NULL
4467
4863
  );
4468
4864
  CREATE INDEX IF NOT EXISTS idx_relay_capture_diagnostics_collab_created
@@ -4474,6 +4870,10 @@ function runMigrationBody(db) {
4474
4870
  CREATE INDEX IF NOT EXISTS idx_relay_capture_diagnostics_status
4475
4871
  ON relay_capture_diagnostics (capture_status);
4476
4872
  `);
4873
+ const captureDiagColumns = db.prepare("PRAGMA table_info(relay_capture_diagnostics)").all();
4874
+ if (!captureDiagColumns.some((column) => column.name === "interference_detected")) {
4875
+ db.exec("ALTER TABLE relay_capture_diagnostics ADD COLUMN interference_detected INTEGER NOT NULL DEFAULT 0");
4876
+ }
4477
4877
  db.exec(`
4478
4878
  CREATE TABLE IF NOT EXISTS relay_evaluator_diagnostics (
4479
4879
  evaluator_id TEXT PRIMARY KEY,
@@ -5254,11 +5654,11 @@ function lookupByCwd(db, cwd) {
5254
5654
  }
5255
5655
 
5256
5656
  // src/runtime/current-tty.ts
5257
- import { execFileSync } from "node:child_process";
5657
+ import { execFileSync as execFileSync2 } from "node:child_process";
5258
5658
  import { existsSync as existsSync5 } from "node:fs";
5259
5659
  function resolveCurrentTty() {
5260
5660
  const stdin = process.stdin;
5261
- const ttyPath = stdin.isTTY && typeof stdin.path === "string" ? stdin.path : execFileSync("tty", [], {
5661
+ const ttyPath = stdin.isTTY && typeof stdin.path === "string" ? stdin.path : execFileSync2("tty", [], {
5262
5662
  encoding: "utf8",
5263
5663
  stdio: ["inherit", "pipe", "pipe"]
5264
5664
  }).trim();
@@ -7098,6 +7498,9 @@ function createMountedTurnOwnedRelay(input) {
7098
7498
  if (!handoff) return null;
7099
7499
  if (handoff.status !== "pending" && handoff.status !== "deferred") return null;
7100
7500
  if (handoff.targetAgent !== input.currentAgent) return null;
7501
+ if (input.broker.control.isWorkflowDeliverySuspended?.(handoff.handoffId)) {
7502
+ return null;
7503
+ }
7101
7504
  return handoff;
7102
7505
  }
7103
7506
  function getAcceptedHandoff() {
@@ -7279,7 +7682,8 @@ ${hint}`,
7279
7682
  let composed = null;
7280
7683
  let initialValue = "";
7281
7684
  if (input.captureHandbackText) {
7282
- const captured = await input.captureHandbackText() ?? "";
7685
+ const outcome = await input.captureHandbackText("") ?? "";
7686
+ const captured = typeof outcome === "string" ? outcome : outcome.text ?? "";
7283
7687
  if (captured.trim().length > 0 && input.confirmHandbackCapture) {
7284
7688
  const accepted = await input.confirmHandbackCapture({
7285
7689
  target,
@@ -7341,14 +7745,29 @@ ${hint}`,
7341
7745
  input.turnCapture?.finishAssistantTurn();
7342
7746
  const turnResult = input.turnCapture?.extractLatestAssistantTurn() ?? { confidence: "low", text: null };
7343
7747
  let clipboardText = null;
7748
+ let leaseDegraded = false;
7749
+ let interferenceDetected = false;
7344
7750
  try {
7345
- clipboardText = await input.captureHandbackText?.() ?? null;
7751
+ const captureResult = await input.captureHandbackText?.(turnResult.text ?? "") ?? null;
7752
+ if (typeof captureResult === "string") {
7753
+ clipboardText = captureResult;
7754
+ } else if (captureResult !== null) {
7755
+ interferenceDetected = captureResult.interferenceDetected;
7756
+ if (captureResult.status === "captured") {
7757
+ clipboardText = captureResult.text;
7758
+ } else {
7759
+ leaseDegraded = true;
7760
+ }
7761
+ }
7346
7762
  } catch {
7347
7763
  clipboardText = null;
7348
7764
  }
7349
7765
  const classification = classifyCapture(turnResult, clipboardText);
7350
7766
  const captureStatus = classification.status;
7351
- const requestText = captureStatus === "ok" ? clipboardText ?? "" : "";
7767
+ let requestText = captureStatus === "ok" ? clipboardText ?? "" : "";
7768
+ if (leaseDegraded && (turnResult.text ?? "").trim().length > 0) {
7769
+ requestText = turnResult.text;
7770
+ }
7352
7771
  const currentAcceptedId = getAcceptedHandoff()?.handoffId;
7353
7772
  const abortedByRaceGuard = currentAcceptedId !== accepted.handoffId;
7354
7773
  const handoffMeta = input.broker.control.getHandoffWithWorkflowMeta?.(accepted.handoffId) ?? null;
@@ -7375,6 +7794,7 @@ ${hint}`,
7375
7794
  clipSample: sampleOf(clipboardText),
7376
7795
  turnSample: sampleOf(turnResult.text),
7377
7796
  abortedByRaceGuard,
7797
+ interferenceDetected,
7378
7798
  now
7379
7799
  });
7380
7800
  } catch (err) {
@@ -7679,6 +8099,107 @@ async function captureClipboardHandback(input) {
7679
8099
  return null;
7680
8100
  }
7681
8101
 
8102
+ // src/runtime/capture-handback-text.ts
8103
+ var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
8104
+ function contentMatches(turnText, clip) {
8105
+ const t = turnText.trim();
8106
+ const c = clip.trim();
8107
+ if (c.length === 0) return false;
8108
+ if (t === c) return true;
8109
+ const jaccard = computeOrderedJaccard(t, c);
8110
+ const containment = computeContainment(c, t);
8111
+ return jaccard >= 0.6 || containment >= 0.8;
8112
+ }
8113
+ async function captureHandbackText(input) {
8114
+ const sleep3 = input.sleep ?? defaultSleep;
8115
+ const acquireMaxWaitMs = input.acquireMaxWaitMs ?? 4e3;
8116
+ const acquireBackoffMs = input.acquireBackoffMs ?? 50;
8117
+ const recaptureAttempts = input.recaptureAttempts ?? 2;
8118
+ const recaptureBackoffMs = input.recaptureBackoffMs ?? 50;
8119
+ let acquired = false;
8120
+ const deadline = Date.now() + acquireMaxWaitMs;
8121
+ for (; ; ) {
8122
+ acquired = acquireCaptureLease(
8123
+ input.db,
8124
+ input.collabId,
8125
+ input.pid,
8126
+ input.leaseOptions
8127
+ );
8128
+ if (acquired) break;
8129
+ if (Date.now() >= deadline) break;
8130
+ await sleep3(acquireBackoffMs);
8131
+ }
8132
+ if (!acquired) {
8133
+ return { status: "degraded_pty_only", text: null, interferenceDetected: false };
8134
+ }
8135
+ try {
8136
+ let interferenceDetected = false;
8137
+ for (let attempt = 0; attempt <= recaptureAttempts; attempt += 1) {
8138
+ if (attempt > 0) await sleep3(recaptureBackoffMs);
8139
+ const c0 = await input.readChangeCount();
8140
+ const clip = await input.runCapture();
8141
+ const cn = await input.readChangeCount();
8142
+ const checkAvailable = c0 !== null && cn !== null;
8143
+ const interfered = checkAvailable && cn - c0 > 1;
8144
+ if (clip === null || clip.trim().length === 0) {
8145
+ if (interfered) {
8146
+ interferenceDetected = true;
8147
+ continue;
8148
+ }
8149
+ return { status: "captured", text: null, interferenceDetected };
8150
+ }
8151
+ const clean = checkAvailable ? cn - c0 === 1 : true;
8152
+ if (clean) {
8153
+ return { status: "captured", text: clip, interferenceDetected };
8154
+ }
8155
+ interferenceDetected = true;
8156
+ if (contentMatches(input.turnText, clip)) {
8157
+ return { status: "captured", text: clip, interferenceDetected: true };
8158
+ }
8159
+ }
8160
+ return { status: "degraded_pty_only", text: null, interferenceDetected: true };
8161
+ } finally {
8162
+ releaseCaptureLease(input.db, input.collabId);
8163
+ }
8164
+ }
8165
+
8166
+ // src/runtime/clipboard-change-count.ts
8167
+ import { execFile as execFile3 } from "node:child_process";
8168
+ import { existsSync as existsSync7 } from "node:fs";
8169
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
8170
+ import { dirname as dirname3, join as join7 } from "node:path";
8171
+ var HELPER_BIN_NAME = "clipboard-change-count";
8172
+ function execFileText2(command, args = []) {
8173
+ return new Promise((resolve5, reject) => {
8174
+ execFile3(command, args, { encoding: "utf8" }, (error, stdout) => {
8175
+ if (error) {
8176
+ reject(error instanceof Error ? error : new Error("helper failed"));
8177
+ return;
8178
+ }
8179
+ resolve5(stdout);
8180
+ });
8181
+ });
8182
+ }
8183
+ function makeChangeCountReader(deps) {
8184
+ const platform = deps?.platform ?? process.platform;
8185
+ const runHelper = deps?.runHelper ?? (async () => {
8186
+ const here = dirname3(fileURLToPath2(import.meta.url));
8187
+ const bin = join7(here, "..", "native", HELPER_BIN_NAME);
8188
+ if (!existsSync7(bin)) throw new Error("changeCount helper not built");
8189
+ return execFileText2(bin);
8190
+ });
8191
+ return async () => {
8192
+ if (platform !== "darwin") return null;
8193
+ try {
8194
+ const out = (await runHelper()).trim();
8195
+ const n = Number.parseInt(out, 10);
8196
+ return Number.isFinite(n) && String(n) === out ? n : null;
8197
+ } catch {
8198
+ return null;
8199
+ }
8200
+ };
8201
+ }
8202
+
7682
8203
  // src/runtime/provider-submit-strategy.ts
7683
8204
  async function submitInjectedProviderInput(input) {
7684
8205
  const sleep3 = input.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
@@ -7723,6 +8244,7 @@ function createRuntimeDebugLogger(input) {
7723
8244
  }
7724
8245
 
7725
8246
  // src/runtime/mount-session-main.ts
8247
+ var readChangeCount = makeChangeCountReader();
7726
8248
  function safeReapSessions(input) {
7727
8249
  const reap = input.reap ?? ((collabId, agentType, keepSessionId) => {
7728
8250
  const db = openDatabase(getSharedSqlitePath());
@@ -7807,6 +8329,7 @@ function createMountSessionRuntime(input) {
7807
8329
  agentType: input.target,
7808
8330
  attachmentKind: "mounted"
7809
8331
  });
8332
+ releaseCaptureLease(db, resolvedClaim.collabId);
7810
8333
  } finally {
7811
8334
  db.close();
7812
8335
  }
@@ -7966,17 +8489,30 @@ function createMountSessionRuntime(input) {
7966
8489
  }
7967
8490
  return runComposer();
7968
8491
  },
7969
- captureHandbackText: async () => {
7970
- return captureClipboardHandback({
7971
- triggerCopy: () => submitInjectedInput2("/copy"),
7972
- // Some provider versions show a picker after /copy (e.g. Claude Code's
7973
- // content-type picker). Send Enter to confirm the default selection
7974
- // if the clipboard has not changed yet after the trigger delay.
7975
- confirmPicker: () => {
7976
- writeInjectedInput("mounted-submit-picker", "\r");
7977
- },
7978
- triggerDelayMs: 300
7979
- });
8492
+ captureHandbackText: async (turnText) => {
8493
+ if (!resolvedClaim) return null;
8494
+ const db = openDatabase(getSharedSqlitePath());
8495
+ try {
8496
+ return await captureHandbackText({
8497
+ db,
8498
+ collabId: resolvedClaim.collabId,
8499
+ pid: process.pid,
8500
+ turnText,
8501
+ readChangeCount,
8502
+ runCapture: () => captureClipboardHandback({
8503
+ triggerCopy: () => submitInjectedInput2("/copy"),
8504
+ // Some provider versions show a picker after /copy (e.g. Claude
8505
+ // Code's content-type picker). Send Enter to confirm the default
8506
+ // selection if the clipboard has not changed yet after the delay.
8507
+ confirmPicker: () => {
8508
+ writeInjectedInput("mounted-submit-picker", "\r");
8509
+ },
8510
+ triggerDelayMs: 300
8511
+ })
8512
+ });
8513
+ } finally {
8514
+ db.close();
8515
+ }
7980
8516
  },
7981
8517
  confirmHandbackCapture: async () => {
7982
8518
  const runConfirm = async () => {
@@ -8088,30 +8624,53 @@ async function isPortFree(port, host = "127.0.0.1") {
8088
8624
  });
8089
8625
  }
8090
8626
 
8091
- // src/commands/collab/start.ts
8092
- import { mkdirSync as mkdirSync3 } from "node:fs";
8093
-
8094
8627
  // src/runtime/port-allocator.ts
8095
8628
  var DEFAULT_PORT_RANGE = [4500, 4999];
8096
8629
 
8097
- // src/commands/collab/start.ts
8630
+ // src/commands/collab/recover.ts
8098
8631
  var READY_TIMEOUT_DEFAULT = 3e4;
8099
- async function runCollabStart(opts) {
8100
- const root = getStateRoot();
8101
- mkdirSync3(root, { recursive: true });
8632
+ var STALE_THRESHOLD_DEFAULT = 9e4;
8633
+ function defaultIsAliveImpl(pid) {
8634
+ try {
8635
+ process.kill(pid, 0);
8636
+ return Promise.resolve({ alive: true, startTime: null });
8637
+ } catch (err) {
8638
+ const code = err.code;
8639
+ if (code === "EPERM") return Promise.resolve({ alive: true, startTime: null });
8640
+ return Promise.resolve({ alive: false, startTime: null });
8641
+ }
8642
+ }
8643
+ async function runCollabRecover(opts) {
8102
8644
  const sqlitePath = getSharedSqlitePath();
8103
8645
  const db = openDatabase(sqlitePath);
8104
8646
  applyMigrations(db);
8105
- const workspaceRoot = canonicalWorkspaceRoot(opts.cwd);
8106
- const workspaceId = workspaceIdFromPath(opts.cwd);
8107
- const now = opts.now();
8108
- const collabId = createCliCollabId(now);
8647
+ const isAlive = opts.isAlive ?? defaultIsAliveImpl;
8648
+ const staleThresholdMs = opts.staleThresholdMs ?? STALE_THRESHOLD_DEFAULT;
8649
+ const resolved = resolveCollab({
8650
+ db,
8651
+ cwd: opts.cwd,
8652
+ ...opts.collabIdOverride ? { collabIdOverride: opts.collabIdOverride } : {},
8653
+ requireActive: true
8654
+ });
8655
+ const collabId = resolved.collabId;
8109
8656
  const host = "127.0.0.1";
8110
- const orchestratorEnabled = process.env.AI_WHISPER_RELAY_ORCHESTRATOR_ENABLED !== "0";
8111
- const orchestratorMaxRounds = Math.max(
8112
- 1,
8113
- Number(process.env.AI_WHISPER_RELAY_ORCHESTRATOR_MAX_ROUNDS ?? "3") || 3
8114
- );
8657
+ const preCheckRow = getBrokerDaemonByCollab(db, collabId);
8658
+ let preCheckPid = null;
8659
+ let preCheckPidStartTime = null;
8660
+ let preCheckHeartbeat = null;
8661
+ if (preCheckRow && preCheckRow.pid !== null) {
8662
+ preCheckPid = preCheckRow.pid;
8663
+ preCheckPidStartTime = preCheckRow.pidStartTime;
8664
+ preCheckHeartbeat = preCheckRow.lastHeartbeatAt;
8665
+ const liveness = await isAlive(preCheckRow.pid);
8666
+ const startTimeMatches = preCheckRow.pidStartTime === null || liveness.startTime === null || liveness.startTime === preCheckRow.pidStartTime;
8667
+ if (liveness.alive && startTimeMatches) {
8668
+ db.close();
8669
+ throw new Error(
8670
+ `daemon already running for collab ${collabId} (pid ${preCheckRow.pid})`
8671
+ );
8672
+ }
8673
+ }
8115
8674
  const range = opts.portRange ?? DEFAULT_PORT_RANGE;
8116
8675
  const osFreeCandidates = [];
8117
8676
  if (opts.explicitPort !== void 0) {
@@ -8133,16 +8692,25 @@ async function runCollabStart(opts) {
8133
8692
  );
8134
8693
  }
8135
8694
  }
8695
+ const now = opts.now();
8136
8696
  let allocatedPort = 0;
8137
8697
  const tx = db.transaction(() => {
8138
- upsertWorkspace(db, { id: workspaceId, workspaceRoot, now });
8139
- const active = db.prepare(
8140
- "SELECT collab_id FROM collab WHERE workspace_id = ? AND status = 'active' LIMIT 1"
8141
- ).get(workspaceId);
8142
- if (active) {
8143
- throw new Error(
8144
- `active collab ${active.collab_id} already exists for workspace ${workspaceRoot}`
8145
- );
8698
+ const existing = getBrokerDaemonByCollab(db, collabId);
8699
+ if (existing) {
8700
+ if (existing.pid === null) {
8701
+ const heartbeatMs = Date.parse(existing.lastHeartbeatAt);
8702
+ const nowMs = Date.parse(now);
8703
+ const ageMs = Number.isFinite(heartbeatMs) && Number.isFinite(nowMs) ? nowMs - heartbeatMs : Number.POSITIVE_INFINITY;
8704
+ if (ageMs < staleThresholdMs) {
8705
+ throw new Error("RECOVERY_ALREADY_IN_PROGRESS");
8706
+ }
8707
+ deleteBrokerDaemonByCollab(db, collabId);
8708
+ } else {
8709
+ if (preCheckPid === null || existing.pid !== preCheckPid || existing.pidStartTime !== preCheckPidStartTime || existing.lastHeartbeatAt !== preCheckHeartbeat) {
8710
+ throw new Error("DAEMON_NOW_RUNNING");
8711
+ }
8712
+ deleteBrokerDaemonByCollab(db, collabId);
8713
+ }
8146
8714
  }
8147
8715
  const takenPorts = new Set(
8148
8716
  db.prepare("SELECT port FROM broker_daemon").all().map((r) => r.port)
@@ -8155,20 +8723,6 @@ async function runCollabStart(opts) {
8155
8723
  );
8156
8724
  }
8157
8725
  allocatedPort = picked;
8158
- db.prepare(
8159
- "INSERT INTO collab (collab_id, workspace_root, display_name, status, workspace_id, launch_mode, tmux_session, created_at, updated_at, orchestrator_enabled, orchestrator_max_rounds) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)"
8160
- ).run(
8161
- collabId,
8162
- workspaceRoot,
8163
- opts.displayName,
8164
- workspaceId,
8165
- opts.launchMode,
8166
- opts.tmuxSession ?? null,
8167
- now,
8168
- now,
8169
- orchestratorEnabled ? 1 : 0,
8170
- orchestratorMaxRounds
8171
- );
8172
8726
  insertBrokerDaemon(db, {
8173
8727
  collabId,
8174
8728
  host,
@@ -8176,23 +8730,27 @@ async function runCollabStart(opts) {
8176
8730
  startedAt: now,
8177
8731
  lastHeartbeatAt: now
8178
8732
  });
8179
- upsertRecoveryState(db, {
8180
- collabId,
8181
- state: "normal",
8182
- idleAfterRecovery: false,
8183
- recoveredAt: null
8184
- });
8185
8733
  });
8186
8734
  try {
8187
8735
  tx.immediate();
8188
8736
  } catch (err) {
8189
- if (err.message === "ALL_CANDIDATES_TAKEN") {
8190
- db.close();
8737
+ const msg = err.message;
8738
+ db.close();
8739
+ if (msg === "RECOVERY_ALREADY_IN_PROGRESS") {
8740
+ throw new Error(
8741
+ `recovery already in progress for collab ${collabId}`
8742
+ );
8743
+ }
8744
+ if (msg === "DAEMON_NOW_RUNNING") {
8745
+ throw new Error(
8746
+ `daemon already running for collab ${collabId}`
8747
+ );
8748
+ }
8749
+ if (msg === "ALL_CANDIDATES_TAKEN") {
8191
8750
  throw new Error(
8192
8751
  `every OS-free port in [${range[0]}, ${range[1]}] is reserved in the registry`
8193
8752
  );
8194
8753
  }
8195
- db.close();
8196
8754
  throw err;
8197
8755
  }
8198
8756
  const childPid = opts.spawnBroker({
@@ -8204,9 +8762,6 @@ async function runCollabStart(opts) {
8204
8762
  const cleanupOnFailure = (msg) => {
8205
8763
  const cleanup = db.transaction(() => {
8206
8764
  deleteBrokerDaemonByCollab(db, collabId);
8207
- db.prepare(
8208
- "UPDATE collab SET status='stopped', stopped_at=?, updated_at=? WHERE collab_id = ?"
8209
- ).run(opts.now(), opts.now(), collabId);
8210
8765
  });
8211
8766
  cleanup.immediate();
8212
8767
  try {
@@ -8234,16 +8789,190 @@ async function runCollabStart(opts) {
8234
8789
  );
8235
8790
  }
8236
8791
  db.close();
8237
- return {
8238
- collabId,
8239
- port: allocatedPort,
8240
- host,
8241
- // biome-ignore lint/style/noNonNullAssertion: guarded by cleanupOnFailure
8242
- pid: finalRow.pid
8243
- };
8244
- }
8245
- function recordLaunchedSessions(input) {
8246
- const db = openDatabase(getSharedSqlitePath());
8792
+ const recoveryBroker = createBrokerRuntime({
8793
+ sqlitePath,
8794
+ runWorkflowDriver: false,
8795
+ runDiagnosticsSweep: false,
8796
+ runDaemonHeartbeat: false,
8797
+ runBrokerDaemonSweep: false
8798
+ });
8799
+ try {
8800
+ const prepared = recoveryBroker.control.prepareCollabRecovery({
8801
+ collabId,
8802
+ now
8803
+ });
8804
+ const hasRememberedBindings = prepared.bindings.some(
8805
+ (b) => b.activeSessionId !== null
8806
+ );
8807
+ upsertRecoveryState(recoveryBroker.db, {
8808
+ collabId,
8809
+ state: hasRememberedBindings ? "recovered" : "normal",
8810
+ idleAfterRecovery: hasRememberedBindings,
8811
+ recoveredAt: hasRememberedBindings ? now : null
8812
+ });
8813
+ } finally {
8814
+ await recoveryBroker.stop();
8815
+ }
8816
+ return {
8817
+ collabId,
8818
+ host,
8819
+ port: allocatedPort,
8820
+ // biome-ignore lint/style/noNonNullAssertion: guarded by cleanupOnFailure
8821
+ pid: finalRow.pid
8822
+ };
8823
+ }
8824
+
8825
+ // src/commands/collab/start.ts
8826
+ import { mkdirSync as mkdirSync3 } from "node:fs";
8827
+ var READY_TIMEOUT_DEFAULT2 = 3e4;
8828
+ async function runCollabStart(opts) {
8829
+ const root = getStateRoot();
8830
+ mkdirSync3(root, { recursive: true });
8831
+ const sqlitePath = getSharedSqlitePath();
8832
+ const db = openDatabase(sqlitePath);
8833
+ applyMigrations(db);
8834
+ const workspaceRoot = canonicalWorkspaceRoot(opts.cwd);
8835
+ const workspaceId = workspaceIdFromPath(opts.cwd);
8836
+ const now = opts.now();
8837
+ const collabId = createCliCollabId(now);
8838
+ const host = "127.0.0.1";
8839
+ const orchestratorEnabled = process.env.AI_WHISPER_RELAY_ORCHESTRATOR_ENABLED !== "0";
8840
+ const orchestratorMaxRounds = Math.max(
8841
+ 1,
8842
+ Number(process.env.AI_WHISPER_RELAY_ORCHESTRATOR_MAX_ROUNDS ?? "3") || 3
8843
+ );
8844
+ const range = opts.portRange ?? DEFAULT_PORT_RANGE;
8845
+ const osFreeCandidates = [];
8846
+ if (opts.explicitPort !== void 0) {
8847
+ if (!await opts.isPortFreeOs(opts.explicitPort)) {
8848
+ db.close();
8849
+ throw new Error(
8850
+ `port ${opts.explicitPort} is in use by another process`
8851
+ );
8852
+ }
8853
+ osFreeCandidates.push(opts.explicitPort);
8854
+ } else {
8855
+ for (let p = range[0]; p <= range[1]; p++) {
8856
+ if (await opts.isPortFreeOs(p)) osFreeCandidates.push(p);
8857
+ }
8858
+ if (osFreeCandidates.length === 0) {
8859
+ db.close();
8860
+ throw new Error(
8861
+ `No OS-free port in range [${range[0]}, ${range[1]}]`
8862
+ );
8863
+ }
8864
+ }
8865
+ let allocatedPort = 0;
8866
+ const tx = db.transaction(() => {
8867
+ upsertWorkspace(db, { id: workspaceId, workspaceRoot, now });
8868
+ const active = db.prepare(
8869
+ "SELECT collab_id FROM collab WHERE workspace_id = ? AND status = 'active' LIMIT 1"
8870
+ ).get(workspaceId);
8871
+ if (active) {
8872
+ throw new Error(
8873
+ `active collab ${active.collab_id} already exists for workspace ${workspaceRoot}`
8874
+ );
8875
+ }
8876
+ const takenPorts = new Set(
8877
+ db.prepare("SELECT port FROM broker_daemon").all().map((r) => r.port)
8878
+ );
8879
+ const picked = osFreeCandidates.find((p) => !takenPorts.has(p));
8880
+ if (picked === void 0) throw new Error("ALL_CANDIDATES_TAKEN");
8881
+ if (opts.explicitPort !== void 0 && picked !== opts.explicitPort) {
8882
+ throw new Error(
8883
+ `port ${opts.explicitPort} already in use by another daemon`
8884
+ );
8885
+ }
8886
+ allocatedPort = picked;
8887
+ db.prepare(
8888
+ "INSERT INTO collab (collab_id, workspace_root, display_name, status, workspace_id, launch_mode, tmux_session, created_at, updated_at, orchestrator_enabled, orchestrator_max_rounds) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)"
8889
+ ).run(
8890
+ collabId,
8891
+ workspaceRoot,
8892
+ opts.displayName,
8893
+ workspaceId,
8894
+ opts.launchMode,
8895
+ opts.tmuxSession ?? null,
8896
+ now,
8897
+ now,
8898
+ orchestratorEnabled ? 1 : 0,
8899
+ orchestratorMaxRounds
8900
+ );
8901
+ insertBrokerDaemon(db, {
8902
+ collabId,
8903
+ host,
8904
+ port: allocatedPort,
8905
+ startedAt: now,
8906
+ lastHeartbeatAt: now
8907
+ });
8908
+ upsertRecoveryState(db, {
8909
+ collabId,
8910
+ state: "normal",
8911
+ idleAfterRecovery: false,
8912
+ recoveredAt: null
8913
+ });
8914
+ });
8915
+ try {
8916
+ tx.immediate();
8917
+ } catch (err) {
8918
+ if (err.message === "ALL_CANDIDATES_TAKEN") {
8919
+ db.close();
8920
+ throw new Error(
8921
+ `every OS-free port in [${range[0]}, ${range[1]}] is reserved in the registry`
8922
+ );
8923
+ }
8924
+ db.close();
8925
+ throw err;
8926
+ }
8927
+ const childPid = opts.spawnBroker({
8928
+ collabId,
8929
+ host,
8930
+ port: allocatedPort,
8931
+ sqlitePath
8932
+ });
8933
+ const cleanupOnFailure = (msg) => {
8934
+ const cleanup = db.transaction(() => {
8935
+ deleteBrokerDaemonByCollab(db, collabId);
8936
+ db.prepare(
8937
+ "UPDATE collab SET status='stopped', stopped_at=?, updated_at=? WHERE collab_id = ?"
8938
+ ).run(opts.now(), opts.now(), collabId);
8939
+ });
8940
+ cleanup.immediate();
8941
+ try {
8942
+ opts.signalProcess(childPid, "SIGTERM");
8943
+ } catch {
8944
+ }
8945
+ db.close();
8946
+ throw new Error(msg);
8947
+ };
8948
+ const ready = await opts.waitForReady({
8949
+ host,
8950
+ port: allocatedPort,
8951
+ collabId,
8952
+ timeoutMs: opts.readyTimeoutMs ?? READY_TIMEOUT_DEFAULT2
8953
+ });
8954
+ if (!ready) {
8955
+ cleanupOnFailure(
8956
+ `broker readiness check timed out for collab ${collabId}`
8957
+ );
8958
+ }
8959
+ const finalRow = getBrokerDaemonByCollab(db, collabId);
8960
+ if (!finalRow || finalRow.pid === null) {
8961
+ cleanupOnFailure(
8962
+ `daemon did not write its PID for collab ${collabId}`
8963
+ );
8964
+ }
8965
+ db.close();
8966
+ return {
8967
+ collabId,
8968
+ port: allocatedPort,
8969
+ host,
8970
+ // biome-ignore lint/style/noNonNullAssertion: guarded by cleanupOnFailure
8971
+ pid: finalRow.pid
8972
+ };
8973
+ }
8974
+ function recordLaunchedSessions(input) {
8975
+ const db = openDatabase(getSharedSqlitePath());
8247
8976
  try {
8248
8977
  if (input.launch.tmuxSession) {
8249
8978
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -8302,7 +9031,7 @@ function recordLaunchedSessions(input) {
8302
9031
  }
8303
9032
 
8304
9033
  // src/commands/collab/mount.ts
8305
- function defaultIsPidAlive(pid) {
9034
+ function defaultIsPidAlive3(pid) {
8306
9035
  try {
8307
9036
  process.kill(pid, 0);
8308
9037
  return true;
@@ -8358,10 +9087,46 @@ async function runCollabMount(input) {
8358
9087
  db.close();
8359
9088
  }
8360
9089
  };
9090
+ const isDaemonAlive = input.isDaemonPidAlive ?? defaultIsPidAlive3;
9091
+ const reviveViaRecover = async () => {
9092
+ await (input.runRecoverFn ?? runCollabRecover)({
9093
+ cwd: input.workspaceRoot,
9094
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
9095
+ isPortFreeOs: (port) => isPortFree(port),
9096
+ spawnBroker: ({ collabId, host, port, sqlitePath }) => spawnBrokerDaemon(sqlitePath, host, port, collabId),
9097
+ waitForReady: ({ host, port, collabId, timeoutMs }) => waitForBrokerReady({ host, port, collabId, timeoutMs }),
9098
+ signalProcess: (pid, signal) => {
9099
+ try {
9100
+ process.kill(pid, signal);
9101
+ } catch {
9102
+ }
9103
+ }
9104
+ });
9105
+ const db = openDatabase(getSharedSqlitePath());
9106
+ try {
9107
+ const r = resolveCollab({ db, cwd: input.workspaceRoot });
9108
+ upsertRecoveryState(db, {
9109
+ collabId: r.collabId,
9110
+ state: "normal",
9111
+ idleAfterRecovery: false,
9112
+ recoveredAt: null
9113
+ });
9114
+ } finally {
9115
+ db.close();
9116
+ }
9117
+ return tryResolve();
9118
+ };
8361
9119
  const resolveOrCreate = async () => {
8362
9120
  try {
8363
- return tryResolve();
9121
+ const resolved2 = tryResolve();
9122
+ if (input.collabIdOverride === void 0 && resolved2.daemon !== null && !isDaemonAlive(resolved2.daemon.pid)) {
9123
+ return await reviveViaRecover();
9124
+ }
9125
+ return resolved2;
8364
9126
  } catch (err) {
9127
+ if (err instanceof CollabResolverError && err.kind === "NoLiveDaemonForCollab" && input.collabIdOverride === void 0) {
9128
+ return await reviveViaRecover();
9129
+ }
8365
9130
  if (!(err instanceof CollabResolverError && (err.kind === "NoCollabFoundForCwd" || err.kind === "CollabAlreadyStopped"))) {
8366
9131
  throw err;
8367
9132
  }
@@ -8429,7 +9194,7 @@ async function runCollabMount(input) {
8429
9194
  try {
8430
9195
  const current = broker.control.listSessionBindings(resolved.collabId).find((binding) => binding.agentType === input.target);
8431
9196
  if (current?.bindingState === "bound") {
8432
- const isAlive = input.isPidAlive ?? defaultIsPidAlive;
9197
+ const isAlive = input.isPidAlive ?? defaultIsPidAlive3;
8433
9198
  const liveOwner = broker.control.listSessionAttachments(resolved.collabId).some(
8434
9199
  (a) => a.agentType === input.target && a.pid !== null && isAlive(a.pid)
8435
9200
  );
@@ -8873,201 +9638,6 @@ async function runCollabInspect(input) {
8873
9638
  }
8874
9639
  }
8875
9640
 
8876
- // src/commands/collab/recover.ts
8877
- var READY_TIMEOUT_DEFAULT2 = 3e4;
8878
- var STALE_THRESHOLD_DEFAULT = 9e4;
8879
- function defaultIsAliveImpl(pid) {
8880
- try {
8881
- process.kill(pid, 0);
8882
- return Promise.resolve({ alive: true, startTime: null });
8883
- } catch (err) {
8884
- const code = err.code;
8885
- if (code === "EPERM") return Promise.resolve({ alive: true, startTime: null });
8886
- return Promise.resolve({ alive: false, startTime: null });
8887
- }
8888
- }
8889
- async function runCollabRecover(opts) {
8890
- const sqlitePath = getSharedSqlitePath();
8891
- const db = openDatabase(sqlitePath);
8892
- applyMigrations(db);
8893
- const isAlive = opts.isAlive ?? defaultIsAliveImpl;
8894
- const staleThresholdMs = opts.staleThresholdMs ?? STALE_THRESHOLD_DEFAULT;
8895
- const resolved = resolveCollab({
8896
- db,
8897
- cwd: opts.cwd,
8898
- ...opts.collabIdOverride ? { collabIdOverride: opts.collabIdOverride } : {},
8899
- requireActive: true
8900
- });
8901
- const collabId = resolved.collabId;
8902
- const host = "127.0.0.1";
8903
- const preCheckRow = getBrokerDaemonByCollab(db, collabId);
8904
- let preCheckPid = null;
8905
- let preCheckPidStartTime = null;
8906
- let preCheckHeartbeat = null;
8907
- if (preCheckRow && preCheckRow.pid !== null) {
8908
- preCheckPid = preCheckRow.pid;
8909
- preCheckPidStartTime = preCheckRow.pidStartTime;
8910
- preCheckHeartbeat = preCheckRow.lastHeartbeatAt;
8911
- const liveness = await isAlive(preCheckRow.pid);
8912
- const startTimeMatches = preCheckRow.pidStartTime === null || liveness.startTime === null || liveness.startTime === preCheckRow.pidStartTime;
8913
- if (liveness.alive && startTimeMatches) {
8914
- db.close();
8915
- throw new Error(
8916
- `daemon already running for collab ${collabId} (pid ${preCheckRow.pid})`
8917
- );
8918
- }
8919
- }
8920
- const range = opts.portRange ?? DEFAULT_PORT_RANGE;
8921
- const osFreeCandidates = [];
8922
- if (opts.explicitPort !== void 0) {
8923
- if (!await opts.isPortFreeOs(opts.explicitPort)) {
8924
- db.close();
8925
- throw new Error(
8926
- `port ${opts.explicitPort} is in use by another process`
8927
- );
8928
- }
8929
- osFreeCandidates.push(opts.explicitPort);
8930
- } else {
8931
- for (let p = range[0]; p <= range[1]; p++) {
8932
- if (await opts.isPortFreeOs(p)) osFreeCandidates.push(p);
8933
- }
8934
- if (osFreeCandidates.length === 0) {
8935
- db.close();
8936
- throw new Error(
8937
- `No OS-free port in range [${range[0]}, ${range[1]}]`
8938
- );
8939
- }
8940
- }
8941
- const now = opts.now();
8942
- let allocatedPort = 0;
8943
- const tx = db.transaction(() => {
8944
- const existing = getBrokerDaemonByCollab(db, collabId);
8945
- if (existing) {
8946
- if (existing.pid === null) {
8947
- const heartbeatMs = Date.parse(existing.lastHeartbeatAt);
8948
- const nowMs = Date.parse(now);
8949
- const ageMs = Number.isFinite(heartbeatMs) && Number.isFinite(nowMs) ? nowMs - heartbeatMs : Number.POSITIVE_INFINITY;
8950
- if (ageMs < staleThresholdMs) {
8951
- throw new Error("RECOVERY_ALREADY_IN_PROGRESS");
8952
- }
8953
- deleteBrokerDaemonByCollab(db, collabId);
8954
- } else {
8955
- if (preCheckPid === null || existing.pid !== preCheckPid || existing.pidStartTime !== preCheckPidStartTime || existing.lastHeartbeatAt !== preCheckHeartbeat) {
8956
- throw new Error("DAEMON_NOW_RUNNING");
8957
- }
8958
- deleteBrokerDaemonByCollab(db, collabId);
8959
- }
8960
- }
8961
- const takenPorts = new Set(
8962
- db.prepare("SELECT port FROM broker_daemon").all().map((r) => r.port)
8963
- );
8964
- const picked = osFreeCandidates.find((p) => !takenPorts.has(p));
8965
- if (picked === void 0) throw new Error("ALL_CANDIDATES_TAKEN");
8966
- if (opts.explicitPort !== void 0 && picked !== opts.explicitPort) {
8967
- throw new Error(
8968
- `port ${opts.explicitPort} already in use by another daemon`
8969
- );
8970
- }
8971
- allocatedPort = picked;
8972
- insertBrokerDaemon(db, {
8973
- collabId,
8974
- host,
8975
- port: allocatedPort,
8976
- startedAt: now,
8977
- lastHeartbeatAt: now
8978
- });
8979
- });
8980
- try {
8981
- tx.immediate();
8982
- } catch (err) {
8983
- const msg = err.message;
8984
- db.close();
8985
- if (msg === "RECOVERY_ALREADY_IN_PROGRESS") {
8986
- throw new Error(
8987
- `recovery already in progress for collab ${collabId}`
8988
- );
8989
- }
8990
- if (msg === "DAEMON_NOW_RUNNING") {
8991
- throw new Error(
8992
- `daemon already running for collab ${collabId}`
8993
- );
8994
- }
8995
- if (msg === "ALL_CANDIDATES_TAKEN") {
8996
- throw new Error(
8997
- `every OS-free port in [${range[0]}, ${range[1]}] is reserved in the registry`
8998
- );
8999
- }
9000
- throw err;
9001
- }
9002
- const childPid = opts.spawnBroker({
9003
- collabId,
9004
- host,
9005
- port: allocatedPort,
9006
- sqlitePath
9007
- });
9008
- const cleanupOnFailure = (msg) => {
9009
- const cleanup = db.transaction(() => {
9010
- deleteBrokerDaemonByCollab(db, collabId);
9011
- });
9012
- cleanup.immediate();
9013
- try {
9014
- opts.signalProcess(childPid, "SIGTERM");
9015
- } catch {
9016
- }
9017
- db.close();
9018
- throw new Error(msg);
9019
- };
9020
- const ready = await opts.waitForReady({
9021
- host,
9022
- port: allocatedPort,
9023
- collabId,
9024
- timeoutMs: opts.readyTimeoutMs ?? READY_TIMEOUT_DEFAULT2
9025
- });
9026
- if (!ready) {
9027
- cleanupOnFailure(
9028
- `broker readiness check timed out for collab ${collabId}`
9029
- );
9030
- }
9031
- const finalRow = getBrokerDaemonByCollab(db, collabId);
9032
- if (!finalRow || finalRow.pid === null) {
9033
- cleanupOnFailure(
9034
- `daemon did not write its PID for collab ${collabId}`
9035
- );
9036
- }
9037
- db.close();
9038
- const recoveryBroker = createBrokerRuntime({
9039
- sqlitePath,
9040
- runWorkflowDriver: false,
9041
- runDiagnosticsSweep: false,
9042
- runDaemonHeartbeat: false,
9043
- runBrokerDaemonSweep: false
9044
- });
9045
- try {
9046
- const prepared = recoveryBroker.control.prepareCollabRecovery({
9047
- collabId,
9048
- now
9049
- });
9050
- const hasRememberedBindings = prepared.bindings.some(
9051
- (b) => b.activeSessionId !== null
9052
- );
9053
- upsertRecoveryState(recoveryBroker.db, {
9054
- collabId,
9055
- state: hasRememberedBindings ? "recovered" : "normal",
9056
- idleAfterRecovery: hasRememberedBindings,
9057
- recoveredAt: hasRememberedBindings ? now : null
9058
- });
9059
- } finally {
9060
- await recoveryBroker.stop();
9061
- }
9062
- return {
9063
- collabId,
9064
- host,
9065
- port: allocatedPort,
9066
- // biome-ignore lint/style/noNonNullAssertion: guarded by cleanupOnFailure
9067
- pid: finalRow.pid
9068
- };
9069
- }
9070
-
9071
9641
  // src/commands/collab/reconnect.ts
9072
9642
  async function runCollabReconnect(input) {
9073
9643
  const db = openDatabase(getSharedSqlitePath());
@@ -10517,11 +11087,11 @@ async function runCollabDashboard(input) {
10517
11087
 
10518
11088
  // src/commands/collab/status.ts
10519
11089
  import Database2 from "better-sqlite3";
10520
- import { existsSync as existsSync7 } from "node:fs";
11090
+ import { existsSync as existsSync8 } from "node:fs";
10521
11091
 
10522
11092
  // src/runtime/evaluator-config.ts
10523
11093
  import { readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
10524
- import { join as join7 } from "node:path";
11094
+ import { join as join8 } from "node:path";
10525
11095
  function isEvaluatorReady(status) {
10526
11096
  return status === "ready" || status === "unknown";
10527
11097
  }
@@ -10532,7 +11102,7 @@ function isEvaluatorPreflightBlocked(status) {
10532
11102
  // src/commands/collab/status.ts
10533
11103
  function runCollabStatus(input) {
10534
11104
  const sqlitePath = getSharedSqlitePath();
10535
- if (!existsSync7(sqlitePath)) {
11105
+ if (!existsSync8(sqlitePath)) {
10536
11106
  return input.json ? JSON.stringify({ error: "no_collab_for_cwd", cwd: input.cwd }) : `no active collab for ${input.cwd}`;
10537
11107
  }
10538
11108
  const db = new Database2(sqlitePath, { readonly: true });
@@ -10867,9 +11437,9 @@ async function runCollabTell(input) {
10867
11437
 
10868
11438
  // src/runtime/launcher.ts
10869
11439
  import { execSync as execSync3, spawn as nodeSpawn } from "node:child_process";
10870
- import { dirname as dirname3, resolve as resolve4 } from "node:path";
10871
- import { fileURLToPath as fileURLToPath2 } from "node:url";
10872
- var __dirname = dirname3(fileURLToPath2(import.meta.url));
11440
+ import { dirname as dirname4, resolve as resolve4 } from "node:path";
11441
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
11442
+ var __dirname = dirname4(fileURLToPath3(import.meta.url));
10873
11443
  var whisperBinPath = resolve4(__dirname, "../bin/whisper.js");
10874
11444
  var relayMonitorBinPath = resolve4(__dirname, "../bin/relay-monitor.js");
10875
11445
  function shellQuote(value) {
@@ -11095,7 +11665,16 @@ async function runWorkflowInspect(deps) {
11095
11665
 
11096
11666
  // src/commands/workflow/resume.ts
11097
11667
  async function runWorkflowResume(deps) {
11098
- deps.broker.control.resumeWorkflow({ workflowId: deps.workflowId, now: deps.now });
11668
+ deps.broker.control.resumeWorkflow({
11669
+ workflowId: deps.workflowId,
11670
+ now: deps.now,
11671
+ ...deps.message !== void 0 ? { message: deps.message } : {}
11672
+ });
11673
+ }
11674
+
11675
+ // src/commands/workflow/pause.ts
11676
+ async function runWorkflowPause(deps) {
11677
+ deps.broker.control.pauseWorkflow({ workflowId: deps.workflowId, now: deps.now });
11099
11678
  }
11100
11679
 
11101
11680
  // src/commands/workflow/cancel.ts
@@ -11111,9 +11690,9 @@ async function runWorkflowTypes() {
11111
11690
  // src/commands/skill/install.ts
11112
11691
  import { cp, mkdir, readdir, stat } from "node:fs/promises";
11113
11692
  import path3 from "node:path";
11114
- import { fileURLToPath as fileURLToPath3 } from "node:url";
11693
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
11115
11694
  function defaultBundledSkillsDir() {
11116
- const here = path3.dirname(fileURLToPath3(import.meta.url));
11695
+ const here = path3.dirname(fileURLToPath4(import.meta.url));
11117
11696
  return path3.resolve(here, "..", "..", "skills");
11118
11697
  }
11119
11698
  function homeForTarget(target, fakeHome) {
@@ -11483,15 +12062,31 @@ Not attached (stdin/stdout is not a TTY). To view the tmux session, run:
11483
12062
  await broker.stop();
11484
12063
  }
11485
12064
  });
11486
- workflow.command("resume").description("Resume a halted workflow").argument("<workflowId>", "Workflow ID").option("--workspace <path>", "Workspace root", process.cwd()).action(async (workflowId, opts) => {
12065
+ workflow.command("pause").description("Pause a running workflow").argument("<workflowId>", "Workflow ID").option("--workspace <path>", "Workspace root", process.cwd()).action(async (workflowId, opts) => {
11487
12066
  const { broker } = await connectToWorkspaceBroker({ cwd: opts.workspace });
11488
12067
  try {
11489
- await runWorkflowResume({ broker, workflowId, now: (/* @__PURE__ */ new Date()).toISOString() });
11490
- console.log(`Workflow resumed: ${workflowId}`);
12068
+ await runWorkflowPause({ broker, workflowId, now: (/* @__PURE__ */ new Date()).toISOString() });
12069
+ console.log(`Workflow paused: ${workflowId}`);
11491
12070
  } finally {
11492
12071
  await broker.stop();
11493
12072
  }
11494
12073
  });
12074
+ workflow.command("resume").description("Resume a paused or halted workflow").argument("<workflowId>", "Workflow ID").option("--workspace <path>", "Workspace root", process.cwd()).option("--message <note>", "Operator note delivered to the agents on resume").action(
12075
+ async (workflowId, opts) => {
12076
+ const { broker } = await connectToWorkspaceBroker({ cwd: opts.workspace });
12077
+ try {
12078
+ await runWorkflowResume({
12079
+ broker,
12080
+ workflowId,
12081
+ now: (/* @__PURE__ */ new Date()).toISOString(),
12082
+ ...opts.message !== void 0 ? { message: opts.message } : {}
12083
+ });
12084
+ console.log(`Workflow resumed: ${workflowId}`);
12085
+ } finally {
12086
+ await broker.stop();
12087
+ }
12088
+ }
12089
+ );
11495
12090
  workflow.command("cancel").description("Cancel a running or halted workflow").argument("<workflowId>", "Workflow ID").option("--workspace <path>", "Workspace root", process.cwd()).action(async (workflowId, opts) => {
11496
12091
  const { broker } = await connectToWorkspaceBroker({ cwd: opts.workspace });
11497
12092
  try {