openclaw-node-harness 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +603 -81
  4. package/bin/mesh-bridge.js +340 -11
  5. package/bin/mesh-deploy-listener.js +119 -97
  6. package/bin/mesh-deploy.js +8 -0
  7. package/bin/mesh-task-daemon.js +1005 -40
  8. package/bin/mesh.js +423 -6
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +300 -8
  29. package/lib/circling-parser.js +119 -0
  30. package/lib/hyperagent-store.mjs +652 -0
  31. package/lib/kanban-io.js +59 -10
  32. package/lib/mcp-knowledge/bench.mjs +118 -0
  33. package/lib/mcp-knowledge/core.mjs +528 -0
  34. package/lib/mcp-knowledge/package.json +25 -0
  35. package/lib/mcp-knowledge/server.mjs +245 -0
  36. package/lib/mcp-knowledge/test.mjs +802 -0
  37. package/lib/memory-budget.mjs +261 -0
  38. package/lib/mesh-collab.js +354 -4
  39. package/lib/mesh-harness.js +427 -0
  40. package/lib/mesh-plans.js +13 -5
  41. package/lib/mesh-registry.js +11 -2
  42. package/lib/mesh-tasks.js +67 -0
  43. package/lib/plan-templates.js +226 -0
  44. package/lib/pre-compression-flush.mjs +320 -0
  45. package/lib/role-loader.js +292 -0
  46. package/lib/rule-loader.js +358 -0
  47. package/lib/session-store.mjs +458 -0
  48. package/lib/transcript-parser.mjs +292 -0
  49. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  50. package/mission-control/drizzle.config.ts +1 -4
  51. package/mission-control/package-lock.json +1571 -83
  52. package/mission-control/package.json +6 -2
  53. package/mission-control/scripts/gen-chronology.js +3 -3
  54. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  55. package/mission-control/scripts/import-pipeline.js +0 -15
  56. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  57. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  58. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  59. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  60. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  61. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  62. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  63. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  64. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  65. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  66. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  67. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  68. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  69. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  70. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  71. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  72. package/mission-control/src/app/api/tasks/route.ts +21 -30
  73. package/mission-control/src/app/cowork/page.tsx +261 -0
  74. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  75. package/mission-control/src/app/graph/page.tsx +26 -0
  76. package/mission-control/src/app/memory/page.tsx +1 -1
  77. package/mission-control/src/app/obsidian/page.tsx +36 -6
  78. package/mission-control/src/app/roadmap/page.tsx +24 -0
  79. package/mission-control/src/app/souls/page.tsx +2 -2
  80. package/mission-control/src/components/board/execution-config.tsx +431 -0
  81. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  82. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  83. package/mission-control/src/components/board/task-card.tsx +55 -2
  84. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  85. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  86. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  87. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  88. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  89. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  90. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  91. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  92. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  93. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  94. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  95. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  96. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  97. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  98. package/mission-control/src/lib/config.ts +58 -0
  99. package/mission-control/src/lib/db/index.ts +69 -0
  100. package/mission-control/src/lib/db/schema.ts +61 -3
  101. package/mission-control/src/lib/hooks.ts +309 -0
  102. package/mission-control/src/lib/memory/entities.ts +3 -2
  103. package/mission-control/src/lib/nats.ts +66 -1
  104. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  105. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  106. package/mission-control/src/lib/scheduler.ts +12 -11
  107. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  108. package/mission-control/src/lib/sync/tasks.ts +23 -1
  109. package/mission-control/src/lib/task-id.ts +32 -0
  110. package/mission-control/src/lib/tts/index.ts +33 -9
  111. package/mission-control/tsconfig.json +2 -1
  112. package/mission-control/vitest.config.ts +14 -0
  113. package/package.json +15 -2
  114. package/services/service-manifest.json +1 -1
  115. package/skills/cc-godmode/references/agents.md +8 -8
  116. package/workspace-bin/memory-daemon.mjs +199 -5
  117. package/workspace-bin/session-search.mjs +204 -0
  118. package/workspace-bin/web-fetch.mjs +65 -0
@@ -21,7 +21,7 @@ const path = require('path');
21
21
  const { readTasks, updateTaskInPlace, isoTimestamp, ACTIVE_TASKS_PATH } = require('../lib/kanban-io');
22
22
 
23
23
  const sc = StringCodec();
24
- const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
24
+ const { NATS_URL } = require('../lib/nats-resolve');
25
25
  const DISPATCH_INTERVAL = parseInt(process.env.BRIDGE_DISPATCH_INTERVAL || '10000'); // 10s
26
26
  const LOG_DIR = path.join(process.env.HOME, '.openclaw', 'workspace', 'memory', 'mesh-logs');
27
27
  const WORKSPACE = path.join(process.env.HOME, '.openclaw', 'workspace');
@@ -186,6 +186,11 @@ async function dispatchTask(task) {
186
186
  success_criteria: task.success_criteria || [],
187
187
  scope: task.scope || [],
188
188
  priority: task.auto_priority || 0,
189
+ llm_provider: task.llm_provider || null,
190
+ llm_model: task.llm_model || null,
191
+ preferred_nodes: task.preferred_nodes || [],
192
+ exclude_nodes: task.exclude_nodes || [],
193
+ collaboration: task.collaboration || undefined,
189
194
  });
190
195
 
191
196
  log(`SUBMITTED: ${meshTask.task_id} to mesh (budget: ${meshTask.budget_minutes}m)`);
@@ -203,6 +208,294 @@ async function dispatchTask(task) {
203
208
  log(`UPDATED: ${task.task_id} → submitted`);
204
209
  }
205
210
 
211
+ // ── Collab Events → Kanban ───────────────────────────
212
+
213
+ /**
214
+ * Handle collab-specific events. Updates kanban with collaboration progress.
215
+ */
216
+ function handleCollabEvent(eventType, taskId, data) {
217
+ // Only process events for tasks we dispatched
218
+ if (!dispatched.has(taskId)) return;
219
+
220
+ const session = data; // collab events include the session object
221
+
222
+ switch (eventType) {
223
+ case 'collab.created':
224
+ log(`COLLAB CREATED: session ${session.session_id} for task ${taskId} (mode: ${session.mode})`);
225
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
226
+ next_action: `Collab session created (mode: ${session.mode}). Recruiting ${session.min_nodes}+ nodes...`,
227
+ updated_at: isoTimestamp(),
228
+ });
229
+ break;
230
+
231
+ case 'collab.joined':
232
+ log(`COLLAB JOINED: ${session.nodes?.length || '?'} nodes in session for ${taskId}`);
233
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
234
+ next_action: `${session.nodes?.length || '?'} nodes joined. ${session.status === 'recruiting' ? 'Recruiting...' : 'Working...'}`,
235
+ updated_at: isoTimestamp(),
236
+ });
237
+ break;
238
+
239
+ case 'collab.round_started':
240
+ log(`COLLAB ROUND ${session.current_round}: started for ${taskId}`);
241
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
242
+ next_action: `Round ${session.current_round}/${session.max_rounds} in progress (${session.nodes?.length || '?'} nodes)`,
243
+ updated_at: isoTimestamp(),
244
+ });
245
+ break;
246
+
247
+ case 'collab.reflection_received': {
248
+ const totalReflections = session.rounds?.[session.rounds.length - 1]?.reflections?.length || 0;
249
+ const totalNodes = session.nodes?.length || '?';
250
+ log(`COLLAB REFLECT: ${totalReflections}/${totalNodes} for R${session.current_round} of ${taskId}`);
251
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
252
+ next_action: `R${session.current_round}: ${totalReflections}/${totalNodes} reflections received`,
253
+ updated_at: isoTimestamp(),
254
+ });
255
+ break;
256
+ }
257
+
258
+ case 'collab.converged':
259
+ log(`COLLAB CONVERGED: ${taskId} after ${session.current_round} rounds`);
260
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
261
+ next_action: `Converged after ${session.current_round} rounds. Collecting artifacts...`,
262
+ updated_at: isoTimestamp(),
263
+ });
264
+ break;
265
+
266
+ case 'collab.completed': {
267
+ const result = session.result || {};
268
+ const nodeNames = Object.keys(result.node_contributions || {});
269
+ const artifactCount = (result.artifacts || []).length;
270
+ log(`COLLAB COMPLETED: ${taskId} — ${result.rounds_taken} rounds, ${nodeNames.length} nodes, ${artifactCount} artifacts`);
271
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
272
+ next_action: `Collab completed: ${result.rounds_taken || '?'} rounds, ${nodeNames.length} nodes. ${artifactCount} artifact(s). ${result.summary || ''}`.trim(),
273
+ collab_result: {
274
+ rounds_taken: result.rounds_taken,
275
+ node_contributions: result.node_contributions,
276
+ artifacts: result.artifacts,
277
+ summary: result.summary,
278
+ },
279
+ updated_at: isoTimestamp(),
280
+ });
281
+ break;
282
+ }
283
+
284
+ case 'collab.aborted':
285
+ log(`COLLAB ABORTED: ${taskId} — ${session.result?.summary || 'unknown reason'}`);
286
+ break;
287
+
288
+ // Circling Strategy events
289
+ case 'collab.circling_step_started': {
290
+ if (!dispatched.has(taskId)) {
291
+ // Auto-track CLI-submitted circling tasks
292
+ try {
293
+ const tasks = readTasks(ACTIVE_TASKS_PATH);
294
+ if (tasks.find(t => t.task_id === taskId && t.execution === 'mesh')) {
295
+ dispatched.add(taskId);
296
+ lastHeartbeat.set(taskId, Date.now());
297
+ log(`CIRCLING AUTO-TRACK: ${taskId} (CLI-submitted)`);
298
+ } else { break; }
299
+ } catch { break; }
300
+ }
301
+ const c = session.circling || {};
302
+ const stepName = c.phase === 'init' ? 'Init'
303
+ : c.phase === 'finalization' ? 'Finalization'
304
+ : `SR${c.current_subround}/${c.max_subrounds} Step${c.current_step}`;
305
+ log(`CIRCLING ${stepName}: started for ${taskId}`);
306
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
307
+ circling_phase: c.phase || null,
308
+ circling_subround: c.current_subround || 0,
309
+ circling_step: c.current_step || 0,
310
+ next_action: `${stepName} in progress (${session.nodes?.length || '?'} nodes)`,
311
+ updated_at: isoTimestamp(),
312
+ });
313
+ break;
314
+ }
315
+
316
+ case 'collab.circling_gate': {
317
+ const cg = session.circling || {};
318
+ // Extract blocked reviewer summaries from the last round (if any)
319
+ const lastRound = session.rounds?.[session.rounds.length - 1];
320
+ const blockedVotes = lastRound?.reflections?.filter(r => r.vote === 'blocked') || [];
321
+ let gateMsg;
322
+ if (blockedVotes.length > 0) {
323
+ const reason = blockedVotes.map(r => r.summary).filter(Boolean).join('; ').slice(0, 150);
324
+ gateMsg = `[GATE] SR${cg.current_subround} blocked — ${reason || 'reviewer flagged concern'}`;
325
+ } else {
326
+ gateMsg = `[GATE] SR${cg.current_subround} complete — review reconciliationDoc and approve/reject`;
327
+ }
328
+ log(`CIRCLING GATE: ${taskId} — SR${cg.current_subround} waiting for approval`);
329
+ updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
330
+ status: 'waiting-user',
331
+ next_action: gateMsg,
332
+ updated_at: isoTimestamp(),
333
+ });
334
+ break;
335
+ }
336
+
337
+ default:
338
+ log(`COLLAB EVENT: ${eventType} for ${taskId}`);
339
+ }
340
+ }
341
+
342
+ // ── Plan Events → Kanban ────────────────────────────
343
+
344
+ /**
345
+ * Handle plan-specific events. Materializes subtasks in kanban and tracks progress.
346
+ */
347
+ function handlePlanEvent(eventType, data) {
348
+ const plan = data;
349
+ const parentTaskId = plan?.parent_task_id;
350
+
351
+ switch (eventType) {
352
+ case 'plan.created':
353
+ log(`PLAN CREATED: ${plan.plan_id} for task ${parentTaskId} (${plan.subtasks?.length || 0} subtasks, ${plan.estimated_waves || 0} waves)`);
354
+ if (parentTaskId) {
355
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
356
+ next_action: `Plan created: ${plan.subtasks?.length || 0} subtasks in ${plan.estimated_waves || 0} waves. ${plan.requires_approval ? 'Awaiting approval.' : 'Auto-executing.'}`,
357
+ updated_at: isoTimestamp(),
358
+ });
359
+ }
360
+ break;
361
+
362
+ case 'plan.approved':
363
+ log(`PLAN APPROVED: ${plan.plan_id}`);
364
+ if (parentTaskId) {
365
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
366
+ status: 'running',
367
+ next_action: `Plan approved. Dispatching wave 0...`,
368
+ updated_at: isoTimestamp(),
369
+ });
370
+ }
371
+ break;
372
+
373
+ case 'plan.wave_started': {
374
+ // Materialize new subtasks into kanban as child tasks
375
+ const readySubtasks = (plan.subtasks || []).filter(st => st.status === 'queued' || st.status === 'blocked');
376
+ log(`PLAN WAVE: ${plan.plan_id} — materializing ${readySubtasks.length} subtasks in kanban`);
377
+
378
+ for (const st of readySubtasks) {
379
+ // Only materialize local/soul/human subtasks — mesh subtasks are tracked via mesh events
380
+ if (st.delegation?.mode === 'local' || st.delegation?.mode === 'soul' || st.delegation?.mode === 'human') {
381
+ materializeSubtask(plan, st);
382
+ }
383
+ }
384
+
385
+ if (parentTaskId) {
386
+ const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
387
+ const total = (plan.subtasks || []).length;
388
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
389
+ next_action: `Plan executing: ${completed}/${total} subtasks done`,
390
+ updated_at: isoTimestamp(),
391
+ });
392
+ }
393
+ break;
394
+ }
395
+
396
+ case 'plan.subtask_completed': {
397
+ const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
398
+ const total = (plan.subtasks || []).length;
399
+ log(`PLAN PROGRESS: ${plan.plan_id} — ${completed}/${total} subtasks done`);
400
+ if (parentTaskId) {
401
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
402
+ next_action: `Plan: ${completed}/${total} subtasks complete`,
403
+ updated_at: isoTimestamp(),
404
+ });
405
+ }
406
+ break;
407
+ }
408
+
409
+ case 'plan.completed':
410
+ log(`PLAN COMPLETED: ${plan.plan_id}`);
411
+ // Parent task completion handled by the daemon (marks waiting-user)
412
+ break;
413
+
414
+ case 'plan.aborted':
415
+ log(`PLAN ABORTED: ${plan.plan_id}`);
416
+ if (parentTaskId) {
417
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
418
+ status: 'blocked',
419
+ next_action: `Plan aborted — needs triage`,
420
+ updated_at: isoTimestamp(),
421
+ });
422
+ }
423
+ break;
424
+
425
+ default:
426
+ log(`PLAN EVENT: ${eventType} for plan ${plan?.plan_id || 'unknown'}`);
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Materialize a plan subtask as a child task in active-tasks.md.
432
+ * For local/soul/human delegation modes only — mesh subtasks use the existing mesh dispatch.
433
+ */
434
+ function materializeSubtask(plan, subtask) {
435
+ try {
436
+ const tasks = readTasks(ACTIVE_TASKS_PATH);
437
+
438
+ // Check if already materialized
439
+ if (tasks.find(t => t.task_id === subtask.subtask_id)) {
440
+ log(` SKIP: ${subtask.subtask_id} already in kanban`);
441
+ return;
442
+ }
443
+
444
+ const mode = subtask.delegation?.mode || 'local';
445
+ const soulId = subtask.delegation?.soul_id || null;
446
+
447
+ const taskEntry = {
448
+ task_id: subtask.subtask_id,
449
+ title: subtask.title,
450
+ status: mode === 'human' ? 'blocked' : 'queued',
451
+ owner: mode === 'soul' ? 'Daedalus' : null,
452
+ soul_id: soulId,
453
+ success_criteria: subtask.success_criteria || [],
454
+ artifacts: [],
455
+ next_action: mode === 'human'
456
+ ? 'Needs Gui input'
457
+ : `Plan subtask (${mode}${soulId ? `: ${soulId}` : ''})`,
458
+ parent_id: plan.parent_task_id,
459
+ project: null, // inherited from parent via MC
460
+ description: `${subtask.description || ''}\n\n_Delegation: ${mode}${soulId ? ` → ${soulId}` : ''} | Plan: ${plan.plan_id} | Budget: ${subtask.budget_minutes}m_`,
461
+ updated_at: isoTimestamp(),
462
+ };
463
+
464
+ // Append to active-tasks.md
465
+ const content = fs.readFileSync(ACTIVE_TASKS_PATH, 'utf-8');
466
+ const yamlEntry = formatTaskYaml(taskEntry);
467
+ fs.writeFileSync(ACTIVE_TASKS_PATH, content + '\n' + yamlEntry);
468
+
469
+ log(` MATERIALIZED: ${subtask.subtask_id} → kanban (${mode})`);
470
+ } catch (err) {
471
+ log(` ERROR materializing ${subtask.subtask_id}: ${err.message}`);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Format a task entry as YAML for active-tasks.md.
477
+ */
478
+ function formatTaskYaml(task) {
479
+ const lines = [];
480
+ lines.push(`- task_id: ${task.task_id}`);
481
+ lines.push(` title: ${task.title}`);
482
+ lines.push(` status: ${task.status}`);
483
+ if (task.owner) lines.push(` owner: ${task.owner}`);
484
+ if (task.soul_id) lines.push(` soul_id: ${task.soul_id}`);
485
+ if (task.success_criteria?.length > 0) {
486
+ lines.push(' success_criteria:');
487
+ for (const sc of task.success_criteria) {
488
+ lines.push(` - ${sc}`);
489
+ }
490
+ }
491
+ lines.push(` artifacts:`);
492
+ if (task.next_action) lines.push(` next_action: "${task.next_action.replace(/"/g, '\\"')}"`);
493
+ if (task.parent_id) lines.push(` parent_id: ${task.parent_id}`);
494
+ if (task.description) lines.push(` description: "${task.description.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
495
+ lines.push(` updated_at: ${task.updated_at}`);
496
+ return lines.join('\n');
497
+ }
498
+
206
499
  // ── Results: Mesh → Kanban (event-driven) ───────────
207
500
 
208
501
  /**
@@ -247,6 +540,11 @@ function handleEvent(eventType, taskId, meshTask) {
247
540
  lastHeartbeat.set(taskId, Date.now());
248
541
  return;
249
542
  default:
543
+ // Handle collab events
544
+ if (eventType.startsWith('collab.')) {
545
+ handleCollabEvent(eventType, taskId, meshTask);
546
+ return;
547
+ }
250
548
  // submitted, started — informational only
251
549
  log(`EVENT: ${eventType} ${taskId}`);
252
550
  return;
@@ -428,19 +726,30 @@ async function main() {
428
726
  log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
429
727
  log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
430
728
 
431
- nc = await connect(natsConnectOpts({ timeout: 5000, reconnect: true, maxReconnectAttempts: -1, reconnectTimeWait: 5000 }));
729
+ nc = await connect({
730
+ servers: NATS_URL,
731
+ timeout: 5000,
732
+ reconnect: true,
733
+ maxReconnectAttempts: 10,
734
+ reconnectTimeWait: 2000,
735
+ });
432
736
  log('Connected to NATS');
433
737
 
434
738
  // Re-reconcile on reconnect — catches events missed during NATS blip (#2)
435
- // NATS.js uses async status() iterator, not EventEmitter .on()
739
+ // Exit on permanent disconnect so launchd restarts us
436
740
  (async () => {
437
741
  for await (const s of nc.status()) {
742
+ log(`NATS status: ${s.type}`);
438
743
  if (s.type === 'reconnect') {
439
744
  log('NATS reconnected — running reconciliation');
440
745
  reconcile().catch(err => log(`RECONCILE on reconnect failed: ${err.message}`));
441
746
  }
442
747
  }
443
748
  })();
749
+ nc.closed().then(() => {
750
+ log('NATS connection permanently closed — exiting for launchd restart');
751
+ process.exit(1);
752
+ });
444
753
 
445
754
  // Reconcile any orphaned tasks from a previous crash (#2, #10)
446
755
  await reconcile();
@@ -454,7 +763,12 @@ async function main() {
454
763
  for await (const msg of sub) {
455
764
  try {
456
765
  const payload = JSON.parse(sc.decode(msg.data));
457
- handleEvent(payload.event, payload.task_id, payload.task);
766
+ // Route plan events separately (they use plan_id, not task_id)
767
+ if (payload.event && payload.event.startsWith('plan.')) {
768
+ handlePlanEvent(payload.event, payload.plan || payload);
769
+ } else {
770
+ handleEvent(payload.event, payload.task_id, payload.task);
771
+ }
458
772
  } catch (err) {
459
773
  log(`ERROR parsing event: ${err.message}`);
460
774
  }
@@ -465,15 +779,23 @@ async function main() {
465
779
  const stalenessTimer = setInterval(checkStaleness, HEARTBEAT_CHECK_INTERVAL);
466
780
  log(`Heartbeat staleness check: every ${HEARTBEAT_CHECK_INTERVAL / 1000}s (warn at ${STALE_WARNING_MS / 60000}m)`);
467
781
 
782
+ // Wake signal — MC publishes mesh.bridge.wake after creating a mesh task
783
+ // so the bridge picks it up in ~1s instead of waiting for the next poll cycle
784
+ let wakeResolve = null;
785
+ const wakeSub = nc.subscribe('mesh.bridge.wake', {
786
+ callback: () => {
787
+ log('WAKE: received wake signal, triggering immediate poll');
788
+ if (wakeResolve) { wakeResolve(); wakeResolve = null; }
789
+ },
790
+ });
791
+
468
792
  // Dispatch loop (polls active-tasks.md)
469
793
  while (running) {
470
- let lastAttemptedTask = null;
471
794
  try {
472
795
  // Only dispatch if no active tasks in the mesh from this bridge
473
796
  if (dispatched.size === 0) {
474
797
  const task = findDispatchable();
475
798
  if (task) {
476
- lastAttemptedTask = task;
477
799
  await dispatchTask(task);
478
800
  consecutiveSubmitFailures = 0; // reset on success
479
801
  }
@@ -498,11 +820,12 @@ async function main() {
498
820
  log(`DISPATCH ERROR (${consecutiveSubmitFailures}/${MAX_SUBMIT_FAILURES}): ${err.message}`);
499
821
 
500
822
  if (consecutiveSubmitFailures >= MAX_SUBMIT_FAILURES) {
501
- // Use the captured task reference don't re-query which could return a different task
502
- if (lastAttemptedTask) {
503
- log(`BLOCKING: ${lastAttemptedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
823
+ // Find the task we were trying to dispatch and mark it blocked
824
+ const failedTask = findDispatchable();
825
+ if (failedTask) {
826
+ log(`BLOCKING: ${failedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
504
827
  try {
505
- updateTaskInPlace(ACTIVE_TASKS_PATH, lastAttemptedTask.task_id, {
828
+ updateTaskInPlace(ACTIVE_TASKS_PATH, failedTask.task_id, {
506
829
  status: 'blocked',
507
830
  next_action: `Mesh submit failed ${MAX_SUBMIT_FAILURES}x — check NATS connectivity`,
508
831
  updated_at: isoTimestamp(),
@@ -515,10 +838,16 @@ async function main() {
515
838
  }
516
839
  }
517
840
 
518
- await new Promise(r => setTimeout(r, DISPATCH_INTERVAL));
841
+ // Sleep until next poll OR wake signal, whichever comes first
842
+ await Promise.race([
843
+ new Promise(r => setTimeout(r, DISPATCH_INTERVAL)),
844
+ new Promise(r => { wakeResolve = r; }),
845
+ ]);
846
+ wakeResolve = null;
519
847
  }
520
848
 
521
849
  clearInterval(stalenessTimer);
850
+ wakeSub.unsubscribe();
522
851
  sub.unsubscribe();
523
852
  await nc.drain();
524
853
  log('Bridge stopped.');