reflectt-node 0.1.15 → 0.1.17

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 (216) hide show
  1. package/README.md +54 -0
  2. package/defaults/TEAM-ROLES.starter.yaml +87 -0
  3. package/defaults/lane-templates/ops.json +32 -0
  4. package/defaults/lane-templates/workflow.json +32 -0
  5. package/defaults/reviewer-routing.yaml +34 -0
  6. package/dist/activationEvents.d.ts +7 -1
  7. package/dist/activationEvents.d.ts.map +1 -1
  8. package/dist/activationEvents.js +29 -3
  9. package/dist/activationEvents.js.map +1 -1
  10. package/dist/activity-stream-normalizer.d.ts +37 -0
  11. package/dist/activity-stream-normalizer.d.ts.map +1 -0
  12. package/dist/activity-stream-normalizer.js +101 -0
  13. package/dist/activity-stream-normalizer.js.map +1 -0
  14. package/dist/agent-exec-guardrail.d.ts +9 -0
  15. package/dist/agent-exec-guardrail.d.ts.map +1 -0
  16. package/dist/agent-exec-guardrail.js +24 -0
  17. package/dist/agent-exec-guardrail.js.map +1 -0
  18. package/dist/agent-exec-guardrail.test.d.ts +2 -0
  19. package/dist/agent-exec-guardrail.test.d.ts.map +1 -0
  20. package/dist/agent-exec-guardrail.test.js +55 -0
  21. package/dist/agent-exec-guardrail.test.js.map +1 -0
  22. package/dist/agent-interface.d.ts +137 -0
  23. package/dist/agent-interface.d.ts.map +1 -0
  24. package/dist/agent-interface.js +463 -0
  25. package/dist/agent-interface.js.map +1 -0
  26. package/dist/agent-notifications.d.ts +51 -0
  27. package/dist/agent-notifications.d.ts.map +1 -0
  28. package/dist/agent-notifications.js +104 -0
  29. package/dist/agent-notifications.js.map +1 -0
  30. package/dist/agent-runs.d.ts +29 -2
  31. package/dist/agent-runs.d.ts.map +1 -1
  32. package/dist/agent-runs.js +118 -8
  33. package/dist/agent-runs.js.map +1 -1
  34. package/dist/artifact-mirror.d.ts.map +1 -1
  35. package/dist/artifact-mirror.js +4 -1
  36. package/dist/artifact-mirror.js.map +1 -1
  37. package/dist/assignment.d.ts.map +1 -1
  38. package/dist/assignment.js +54 -2
  39. package/dist/assignment.js.map +1 -1
  40. package/dist/boardHealthWorker.d.ts.map +1 -1
  41. package/dist/boardHealthWorker.js +15 -1
  42. package/dist/boardHealthWorker.js.map +1 -1
  43. package/dist/canvas-auto-state.d.ts +58 -0
  44. package/dist/canvas-auto-state.d.ts.map +1 -0
  45. package/dist/canvas-auto-state.js +89 -0
  46. package/dist/canvas-auto-state.js.map +1 -0
  47. package/dist/canvas-routes.d.ts +36 -0
  48. package/dist/canvas-routes.d.ts.map +1 -0
  49. package/dist/canvas-routes.js +47 -0
  50. package/dist/canvas-routes.js.map +1 -0
  51. package/dist/capability-readiness.d.ts +28 -0
  52. package/dist/capability-readiness.d.ts.map +1 -0
  53. package/dist/capability-readiness.js +162 -0
  54. package/dist/capability-readiness.js.map +1 -0
  55. package/dist/channels.d.ts.map +1 -1
  56. package/dist/channels.js +1 -0
  57. package/dist/channels.js.map +1 -1
  58. package/dist/cli.js +179 -4
  59. package/dist/cli.js.map +1 -1
  60. package/dist/cloud.d.ts +5 -0
  61. package/dist/cloud.d.ts.map +1 -1
  62. package/dist/cloud.js +486 -18
  63. package/dist/cloud.js.map +1 -1
  64. package/dist/comms-routing-policy.d.ts +31 -0
  65. package/dist/comms-routing-policy.d.ts.map +1 -0
  66. package/dist/comms-routing-policy.js +128 -0
  67. package/dist/comms-routing-policy.js.map +1 -0
  68. package/dist/config.d.ts.map +1 -1
  69. package/dist/config.js +1 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/continuity-loop.d.ts.map +1 -1
  72. package/dist/continuity-loop.js +26 -0
  73. package/dist/continuity-loop.js.map +1 -1
  74. package/dist/cost-enforcement.d.ts.map +1 -1
  75. package/dist/cost-enforcement.js +22 -0
  76. package/dist/cost-enforcement.js.map +1 -1
  77. package/dist/db.d.ts.map +1 -1
  78. package/dist/db.js +56 -5
  79. package/dist/db.js.map +1 -1
  80. package/dist/doctor.js +2 -2
  81. package/dist/events.d.ts +4 -2
  82. package/dist/events.d.ts.map +1 -1
  83. package/dist/events.js +22 -1
  84. package/dist/events.js.map +1 -1
  85. package/dist/executionSweeper.d.ts.map +1 -1
  86. package/dist/executionSweeper.js +155 -0
  87. package/dist/executionSweeper.js.map +1 -1
  88. package/dist/health.d.ts +21 -1
  89. package/dist/health.d.ts.map +1 -1
  90. package/dist/health.js +164 -19
  91. package/dist/health.js.map +1 -1
  92. package/dist/inbox.d.ts +4 -0
  93. package/dist/inbox.d.ts.map +1 -1
  94. package/dist/inbox.js +38 -1
  95. package/dist/inbox.js.map +1 -1
  96. package/dist/index.js +90 -14
  97. package/dist/index.js.map +1 -1
  98. package/dist/insight-auto-tagger.d.ts +58 -0
  99. package/dist/insight-auto-tagger.d.ts.map +1 -0
  100. package/dist/insight-auto-tagger.js +331 -0
  101. package/dist/insight-auto-tagger.js.map +1 -0
  102. package/dist/insight-task-bridge.d.ts +9 -0
  103. package/dist/insight-task-bridge.d.ts.map +1 -1
  104. package/dist/insight-task-bridge.js +43 -7
  105. package/dist/insight-task-bridge.js.map +1 -1
  106. package/dist/insights.d.ts +6 -0
  107. package/dist/insights.d.ts.map +1 -1
  108. package/dist/insights.js +13 -0
  109. package/dist/insights.js.map +1 -1
  110. package/dist/lane-config.d.ts.map +1 -1
  111. package/dist/lane-config.js +1 -0
  112. package/dist/lane-config.js.map +1 -1
  113. package/dist/lane-template-successor.d.ts +13 -0
  114. package/dist/lane-template-successor.d.ts.map +1 -0
  115. package/dist/lane-template-successor.js +132 -0
  116. package/dist/lane-template-successor.js.map +1 -0
  117. package/dist/local-whisper.d.ts +21 -0
  118. package/dist/local-whisper.d.ts.map +1 -0
  119. package/dist/local-whisper.js +137 -0
  120. package/dist/local-whisper.js.map +1 -0
  121. package/dist/macos-accessibility.d.ts +50 -0
  122. package/dist/macos-accessibility.d.ts.map +1 -0
  123. package/dist/macos-accessibility.js +185 -0
  124. package/dist/macos-accessibility.js.map +1 -0
  125. package/dist/manage.d.ts.map +1 -1
  126. package/dist/manage.js +47 -1
  127. package/dist/manage.js.map +1 -1
  128. package/dist/mcp.d.ts.map +1 -1
  129. package/dist/mcp.js +123 -0
  130. package/dist/mcp.js.map +1 -1
  131. package/dist/notification-worker.d.ts +66 -0
  132. package/dist/notification-worker.d.ts.map +1 -0
  133. package/dist/notification-worker.js +232 -0
  134. package/dist/notification-worker.js.map +1 -0
  135. package/dist/openclaw-usage-sync.d.ts +28 -0
  136. package/dist/openclaw-usage-sync.d.ts.map +1 -0
  137. package/dist/openclaw-usage-sync.js +161 -0
  138. package/dist/openclaw-usage-sync.js.map +1 -0
  139. package/dist/policy.js +1 -1
  140. package/dist/policy.js.map +1 -1
  141. package/dist/pr-link-reconciler.d.ts +61 -0
  142. package/dist/pr-link-reconciler.d.ts.map +1 -0
  143. package/dist/pr-link-reconciler.js +127 -0
  144. package/dist/pr-link-reconciler.js.map +1 -0
  145. package/dist/preflight.d.ts.map +1 -1
  146. package/dist/preflight.js +9 -2
  147. package/dist/preflight.js.map +1 -1
  148. package/dist/presence-narrator.d.ts +52 -0
  149. package/dist/presence-narrator.d.ts.map +1 -0
  150. package/dist/presence-narrator.js +193 -0
  151. package/dist/presence-narrator.js.map +1 -0
  152. package/dist/presence.d.ts +2 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +23 -3
  155. package/dist/presence.js.map +1 -1
  156. package/dist/product-observation-source.d.ts +52 -0
  157. package/dist/product-observation-source.d.ts.map +1 -0
  158. package/dist/product-observation-source.js +326 -0
  159. package/dist/product-observation-source.js.map +1 -0
  160. package/dist/reflection-automation.d.ts +25 -0
  161. package/dist/reflection-automation.d.ts.map +1 -1
  162. package/dist/reflection-automation.js +163 -42
  163. package/dist/reflection-automation.js.map +1 -1
  164. package/dist/review-autoclose.d.ts +62 -0
  165. package/dist/review-autoclose.d.ts.map +1 -0
  166. package/dist/review-autoclose.js +75 -0
  167. package/dist/review-autoclose.js.map +1 -0
  168. package/dist/sentry-webhook.d.ts +69 -0
  169. package/dist/sentry-webhook.d.ts.map +1 -0
  170. package/dist/sentry-webhook.js +88 -0
  171. package/dist/sentry-webhook.js.map +1 -0
  172. package/dist/sentry.d.ts +29 -0
  173. package/dist/sentry.d.ts.map +1 -0
  174. package/dist/sentry.js +86 -0
  175. package/dist/sentry.js.map +1 -0
  176. package/dist/server.d.ts.map +1 -1
  177. package/dist/server.js +5146 -223
  178. package/dist/server.js.map +1 -1
  179. package/dist/stale-candidate-reconciler.d.ts +69 -0
  180. package/dist/stale-candidate-reconciler.d.ts.map +1 -0
  181. package/dist/stale-candidate-reconciler.js +236 -0
  182. package/dist/stale-candidate-reconciler.js.map +1 -0
  183. package/dist/system-loop-state.d.ts +1 -1
  184. package/dist/system-loop-state.d.ts.map +1 -1
  185. package/dist/system-loop-state.js +1 -0
  186. package/dist/system-loop-state.js.map +1 -1
  187. package/dist/taskPrecheck.d.ts.map +1 -1
  188. package/dist/taskPrecheck.js +47 -2
  189. package/dist/taskPrecheck.js.map +1 -1
  190. package/dist/tasks.d.ts.map +1 -1
  191. package/dist/tasks.js +130 -0
  192. package/dist/tasks.js.map +1 -1
  193. package/dist/trust-events.d.ts +61 -0
  194. package/dist/trust-events.d.ts.map +1 -0
  195. package/dist/trust-events.js +148 -0
  196. package/dist/trust-events.js.map +1 -0
  197. package/dist/types.d.ts +1 -0
  198. package/dist/types.d.ts.map +1 -1
  199. package/dist/usage-tracking.d.ts +6 -0
  200. package/dist/usage-tracking.d.ts.map +1 -1
  201. package/dist/usage-tracking.js +14 -0
  202. package/dist/usage-tracking.js.map +1 -1
  203. package/dist/voice-sessions.d.ts +51 -0
  204. package/dist/voice-sessions.d.ts.map +1 -0
  205. package/dist/voice-sessions.js +143 -0
  206. package/dist/voice-sessions.js.map +1 -0
  207. package/dist/workflow-templates.d.ts.map +1 -1
  208. package/dist/workflow-templates.js +16 -1
  209. package/dist/workflow-templates.js.map +1 -1
  210. package/dist/working-contract.d.ts +22 -1
  211. package/dist/working-contract.d.ts.map +1 -1
  212. package/dist/working-contract.js +31 -2
  213. package/dist/working-contract.js.map +1 -1
  214. package/package.json +4 -4
  215. package/public/dashboard.js +12 -4
  216. package/public/docs.md +98 -10
package/dist/cloud.js CHANGED
@@ -18,14 +18,15 @@ import { chatManager } from './chat.js';
18
18
  import { remapGitHubMentions } from './github-webhook-attribution.js';
19
19
  import { slotManager } from './canvas-slots.js';
20
20
  import { getDb } from './db.js';
21
- import { getUsageSummary, getUsageByAgent, getUsageByModel, listCaps, checkCaps, getRoutingSuggestions } from './usage-tracking.js';
21
+ import { getUsageSummary, getUsageByAgent, getUsageByModel, listCaps, checkCaps, getRoutingSuggestions, getCostForTaskId } from './usage-tracking.js';
22
22
  import { listReflections } from './reflections.js';
23
23
  import { listInsights } from './insights.js';
24
24
  import { readFileSync, existsSync, watch } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { REFLECTT_HOME } from './config.js';
27
27
  import { getRequestMetrics } from './request-tracker.js';
28
- import { listApprovalQueue, listAgentEvents } from './agent-runs.js';
28
+ import { listApprovalQueue, listAgentEvents, listAgentRuns } from './agent-runs.js';
29
+ import { getUnpushedTrustEvents, markTrustEventsPushed } from './trust-events.js';
29
30
  /**
30
31
  * Docker identity guard: detect when a container has inherited cloud
31
32
  * credentials from a host volume mount. Without explicit opt-in, skip
@@ -68,6 +69,33 @@ let lastActivityAt = Date.now();
68
69
  export function markCloudActivity() {
69
70
  lastActivityAt = Date.now();
70
71
  }
72
+ /** Request immediate canvas sync to cloud (called on canvas_render events) */
73
+ export function requestImmediateCanvasSync() {
74
+ markCloudActivity();
75
+ // syncCanvas is module-scoped; we use a deferred call pattern
76
+ if (immediateSyncFn)
77
+ immediateSyncFn();
78
+ }
79
+ let immediateSyncFn = null;
80
+ export function _registerImmediateSync(fn) {
81
+ immediateSyncFn = fn;
82
+ }
83
+ // ── canvas_push relay buffer ─────────────────────────────────────────────────
84
+ // canvas_push events (utterance, work_released, approval_requested) are emitted
85
+ // on the node event bus but never reached the cloud SSE stream. This buffer
86
+ // collects them and flushes them in the next syncCanvas POST as push_events[].
87
+ // The cloud then broadcasts each as a `canvas_push` SSE event to all subscribers.
88
+ const MAX_PENDING_PUSH_EVENTS = 20;
89
+ const pendingPushEvents = [];
90
+ /** Queue a canvas_push event for relay to cloud in the next sync cycle. */
91
+ export function queueCanvasPushEvent(event) {
92
+ pendingPushEvents.push({ ...event, _queuedAt: Date.now() });
93
+ // Cap buffer to prevent unbounded growth between syncs
94
+ while (pendingPushEvents.length > MAX_PENDING_PUSH_EVENTS)
95
+ pendingPushEvents.shift();
96
+ // Trigger immediate sync so the event reaches browsers quickly
97
+ requestImmediateCanvasSync();
98
+ }
71
99
  /** Check if the system is idle */
72
100
  function isIdle() {
73
101
  return Date.now() - lastActivityAt > IDLE_THRESHOLD_MS;
@@ -249,7 +277,7 @@ export async function startCloudIntegration() {
249
277
  return;
250
278
  }
251
279
  config = {
252
- cloudUrl: (process.env.REFLECTT_CLOUD_URL || fileConfig?.cloudUrl || 'https://app.reflectt.ai').replace(/\/+$/, ''),
280
+ cloudUrl: (process.env.REFLECTT_CLOUD_URL || fileConfig?.cloudUrl || 'https://api.reflectt.ai').replace(/\/+$/, ''),
253
281
  token: process.env.REFLECTT_HOST_TOKEN || '',
254
282
  hostName: process.env.REFLECTT_HOST_NAME || fileConfig?.hostName || 'unnamed-host',
255
283
  hostType: process.env.REFLECTT_HOST_TYPE || fileConfig?.hostType || 'openclaw',
@@ -349,6 +377,10 @@ export async function startCloudIntegration() {
349
377
  // Canvas sync — adaptive: 5s when active, 60s when idle
350
378
  // Uses a single 5s tick that skips when idle (unless enough time has passed)
351
379
  let lastCanvasSyncAt = 0;
380
+ _registerImmediateSync(() => {
381
+ syncCanvas().catch(() => { });
382
+ lastCanvasSyncAt = Date.now();
383
+ });
352
384
  syncCanvas().catch(() => { });
353
385
  state.canvasSyncTimer = setInterval(() => {
354
386
  const now = Date.now();
@@ -362,12 +394,24 @@ export async function startCloudIntegration() {
362
394
  syncRunApprovals().catch(() => { });
363
395
  state.approvalSyncTimer = setInterval(() => {
364
396
  syncRunApprovals().catch(() => { });
397
+ pollAgentDecisions().catch(() => { }); // poll queued relay decisions (NAT-behind hosts)
398
+ pollCanvasQueryRelay().catch(() => { }); // poll canvas/query relay queue (NAT-behind hosts)
365
399
  }, APPROVAL_SYNC_INTERVAL_MS);
366
400
  // Run event sync — every 5s
367
401
  syncRunEvents().catch(() => { });
368
402
  state.runEventSyncTimer = setInterval(() => {
369
403
  syncRunEvents().catch(() => { });
370
404
  }, RUN_EVENT_SYNC_INTERVAL_MS);
405
+ // Agent runs sync — every 30s (pushes run records to cloud action_runs table)
406
+ syncAgentRuns().catch(() => { });
407
+ setInterval(() => {
408
+ syncAgentRuns().catch(() => { });
409
+ }, 30_000);
410
+ // Trust event sync — every 60s (pushes unpushed trust signals to cloud)
411
+ syncTrustEvents().catch(() => { });
412
+ setInterval(() => {
413
+ syncTrustEvents().catch(() => { });
414
+ }, 60_000);
371
415
  // Usage sync — adaptive: 15s when active, 60s when idle
372
416
  let lastUsageSyncAt = 0;
373
417
  syncUsage().catch(() => { });
@@ -540,6 +584,17 @@ export function stopCloudIntegration() {
540
584
  }
541
585
  logConnectionEvent({ type: 'disconnected', timestamp: Date.now(), reason: 'shutdown' });
542
586
  console.log('☁️ Cloud integration: stopped');
587
+ // Clear canvas state on Fly so subscribers see an empty room, not ghost agents
588
+ if (state.hostId && state.credential && config) {
589
+ const clearUrl = `${config.cloudUrl}/api/hosts/${state.hostId}/canvas/clear`;
590
+ fetch(clearUrl, {
591
+ method: 'POST',
592
+ headers: {
593
+ 'Content-Type': 'application/json',
594
+ Authorization: `Bearer ${state.credential}`,
595
+ },
596
+ }).catch(() => { });
597
+ }
543
598
  }
544
599
  // ---- Data providers ----
545
600
  function getAgents() {
@@ -556,11 +611,16 @@ function getAgents() {
556
611
  name: role.name,
557
612
  status: p
558
613
  ? (p.status === 'working' || p.status === 'reviewing' ? 'active'
559
- : p.status === 'offline' ? 'offline'
560
- : 'idle')
614
+ : p.status === 'waiting' ? 'waiting'
615
+ : p.status === 'offline' ? 'offline'
616
+ : 'idle')
561
617
  : 'offline',
562
618
  currentTask: p?.task,
563
619
  lastSeen: p?.lastUpdate,
620
+ ...(p?.status === 'waiting' && p.waiting ? {
621
+ waitingFor: p.waiting.waitingFor,
622
+ waitingTaskId: p.waiting.taskId,
623
+ } : {}),
564
624
  });
565
625
  }
566
626
  // Also include any presence entries not in TEAM-ROLES (shouldn't happen, but defensive)
@@ -569,10 +629,15 @@ function getAgents() {
569
629
  agents.push({
570
630
  name: p.agent,
571
631
  status: p.status === 'working' || p.status === 'reviewing' ? 'active'
572
- : p.status === 'offline' ? 'offline'
573
- : 'idle',
632
+ : p.status === 'waiting' ? 'waiting'
633
+ : p.status === 'offline' ? 'offline'
634
+ : 'idle',
574
635
  currentTask: p.task,
575
636
  lastSeen: p.lastUpdate,
637
+ ...(p.status === 'waiting' && p.waiting ? {
638
+ waitingFor: p.waiting.waitingFor,
639
+ waitingTaskId: p.waiting.taskId,
640
+ } : {}),
576
641
  });
577
642
  }
578
643
  }
@@ -587,6 +652,7 @@ function getTasks() {
587
652
  assignee: t.assignee,
588
653
  priority: t.priority,
589
654
  updatedAt: t.updatedAt || t.createdAt,
655
+ createdAt: t.createdAt,
590
656
  }));
591
657
  }
592
658
  // ---- Cloud communication ----
@@ -596,6 +662,29 @@ async function sendHeartbeat() {
596
662
  const agents = getAgents();
597
663
  const tasks = getTasks();
598
664
  const doingTasks = tasks.filter(t => t.status === 'doing');
665
+ // ── Slow task detection ───────────────────────────────────────────────
666
+ // Include tasks that have been doing >4h with no activity (slow-flagged).
667
+ // These are NOT explicitly blocked — they're just stale.
668
+ const SLOW_HEARTBEAT_MS = 4 * 60 * 60 * 1000;
669
+ const nowTs = Date.now();
670
+ const slowTasks = doingTasks.reduce((acc, t) => {
671
+ const comments = taskManager.getTaskComments(t.id);
672
+ const lastComment = comments.length > 0 ? comments[comments.length - 1] : null;
673
+ const lastActivityAt = lastComment?.timestamp ?? t.updatedAt ?? t.createdAt ?? nowTs;
674
+ const age = nowTs - lastActivityAt;
675
+ if (age > SLOW_HEARTBEAT_MS) {
676
+ acc.push({
677
+ id: t.id,
678
+ title: t.title,
679
+ assignee: t.assignee || undefined,
680
+ priority: t.priority || undefined,
681
+ slowSinceMs: age,
682
+ slowSinceHours: Math.round(age / 36_000) / 100,
683
+ lastActivityAt: lastActivityAt,
684
+ });
685
+ }
686
+ return acc;
687
+ }, []);
599
688
  // Cloud API: POST /api/hosts/:hostId/heartbeat
600
689
  // Expects: { status, agents?, activeTasks? }
601
690
  // Host is "online" if the server is running and responding.
@@ -606,13 +695,24 @@ async function sendHeartbeat() {
606
695
  contractVersion: 'host-heartbeat.v1',
607
696
  status: hostStatus,
608
697
  timestamp: Date.now(),
609
- agents: agents.map(a => ({
610
- id: a.name,
611
- name: a.name,
612
- status: a.status,
613
- currentTaskId: a.currentTask || undefined,
614
- lastSeenAt: a.lastSeen || Date.now(),
615
- })),
698
+ agents: agents.map(a => {
699
+ const agentAliases = [a.name];
700
+ const todoCount = tasks.filter(t => t.status === 'todo' && agentAliases.includes(t.assignee || '')).length;
701
+ const doingCount = tasks.filter(t => t.status === 'doing' && agentAliases.includes(t.assignee || '')).length;
702
+ const blockedCount = tasks.filter(t => t.status === 'blocked' && agentAliases.includes(t.assignee || '')).length;
703
+ return {
704
+ id: a.name,
705
+ name: a.name,
706
+ status: a.status,
707
+ currentTaskId: a.currentTask || undefined,
708
+ lastSeenAt: a.lastSeen || Date.now(),
709
+ taskCounts: { todo: todoCount, doing: doingCount, blocked: blockedCount },
710
+ ...(a.status === 'waiting' ? {
711
+ waitingFor: a.waitingFor,
712
+ waitingTaskId: a.waitingTaskId,
713
+ } : {}),
714
+ };
715
+ }),
616
716
  activeTasks: doingTasks.map(t => ({
617
717
  id: t.id,
618
718
  title: t.title,
@@ -621,6 +721,7 @@ async function sendHeartbeat() {
621
721
  priority: t.priority || undefined,
622
722
  updatedAt: t.updatedAt || Date.now(),
623
723
  })),
724
+ slowTasks: slowTasks.length > 0 ? slowTasks : undefined,
624
725
  metrics: (() => {
625
726
  const m = getRequestMetrics();
626
727
  return {
@@ -888,7 +989,7 @@ async function syncChat() {
888
989
  // CRITICAL: use oldestFirst=true so the cursor walks forward through ALL
889
990
  // messages without skipping. Default getMessages returns newest-N (DESC
890
991
  // then reversed), which drops older messages in high-traffic windows.
891
- // This was the root cause of cloud chat sync gaps Ryan reported.
992
+ // This was the root cause of cloud chat sync gaps reported.
892
993
  const recentMessages = chatManager.getMessages({
893
994
  after: chatSyncCursor,
894
995
  limit: 100,
@@ -969,6 +1070,53 @@ async function syncChat() {
969
1070
  }
970
1071
  // ---- Canvas sync ----
971
1072
  let canvasSyncErrors = 0;
1073
+ // ── Needs-attention call hook ─────────────────────────────────────────────
1074
+ // Track previous agent states to detect needs-attention transitions.
1075
+ // When an agent newly enters needs-attention/urgent, fire POST /call on the
1076
+ // Fly API for org members who have call_on_needs_attention=true.
1077
+ // The Fly API resolves phone numbers from team_members.notification_phone.
1078
+ const prevAgentStates = new Map(); // agentId → state
1079
+ function checkNeedsAttentionTransitions(agents, hostId, cloudUrl, credential) {
1080
+ for (const [agentId, agentData] of Object.entries(agents)) {
1081
+ const agentState = agentData?.state;
1082
+ if (!agentState)
1083
+ continue;
1084
+ const prev = prevAgentStates.get(agentId);
1085
+ const isAlert = agentState === 'needs-attention' || agentState === 'urgent';
1086
+ const wasAlert = prev === 'needs-attention' || prev === 'urgent';
1087
+ prevAgentStates.set(agentId, agentState);
1088
+ // Only fire on NEW transitions into alert state
1089
+ if (!isAlert || wasAlert)
1090
+ continue;
1091
+ const taskData = agentData?.payload;
1092
+ const taskTitle = taskData?.task || taskData?.title || undefined;
1093
+ console.log(`☁️ [Canvas] needs-attention: @${agentId} → auto-call hook`);
1094
+ // POST /call to Fly — Fly resolves phones for members with call_on_needs_attention=true
1095
+ // If no members have that preference set, the call is a no-op (400 with no phone).
1096
+ const callUrl = `${cloudUrl}/api/hosts/${hostId}/call`;
1097
+ fetch(callUrl, {
1098
+ method: 'POST',
1099
+ headers: {
1100
+ 'Content-Type': 'application/json',
1101
+ Authorization: `Bearer ${credential}`,
1102
+ },
1103
+ body: JSON.stringify({
1104
+ agentId,
1105
+ agentName: agentId,
1106
+ taskTitle,
1107
+ // No `to` — Fly resolves phone from team_members.notification_phone
1108
+ // for members with call_on_needs_attention=true
1109
+ }),
1110
+ }).then(r => {
1111
+ if (!r.ok && r.status !== 400) {
1112
+ console.warn(`☁️ [Canvas] auto-call failed: ${r.status}`);
1113
+ }
1114
+ }).catch(err => {
1115
+ // Non-fatal: call is best-effort
1116
+ console.warn(`☁️ [Canvas] auto-call error: ${err instanceof Error ? err.message : err}`);
1117
+ });
1118
+ }
1119
+ }
972
1120
  async function syncCanvas() {
973
1121
  if (!state.hostId || !config)
974
1122
  return;
@@ -984,8 +1132,138 @@ async function syncCanvas() {
984
1132
  }
985
1133
  }
986
1134
  catch { /* local API not ready */ }
987
- // Push to cloud include both slots and agent states
988
- const result = await cloudPost(`/api/hosts/${state.hostId}/canvas`, { slots: activeSlots, agents });
1135
+ // ── Task-derived agent presence ─────────────────────────────────────────
1136
+ // Agents that have open tasks are present even if they haven't pushed native
1137
+ // canvas state. Any agent with a doing/validating task → "working".
1138
+ // Any agent with a todo task (but no doing) → "working" (queued work).
1139
+ // Native canvas state takes precedence when present — only fill gaps.
1140
+ try {
1141
+ const ACTIVE_STATUSES = ['doing', 'validating', 'todo'];
1142
+ const byAgent = {};
1143
+ for (const status of ACTIVE_STATUSES) {
1144
+ const res = await fetch(`http://127.0.0.1:4445/tasks?status=${status}&limit=100`);
1145
+ if (!res.ok)
1146
+ continue;
1147
+ const data = await res.json();
1148
+ const tasks = data.tasks ?? [];
1149
+ for (const task of tasks) {
1150
+ const assignee = task.assignee;
1151
+ if (!assignee || assignee === 'unassigned')
1152
+ continue;
1153
+ // Higher-priority status wins: doing > validating > todo
1154
+ const existing = byAgent[assignee];
1155
+ const priority = { doing: 0, validating: 1, todo: 2 };
1156
+ const newPriority = priority[status] ?? 99;
1157
+ const existingPriority = existing ? (priority[existing.bestStatus] ?? 99) : 99;
1158
+ if (!existing || newPriority < existingPriority) {
1159
+ byAgent[assignee] = { bestStatus: status, taskTitle: task.title };
1160
+ }
1161
+ }
1162
+ }
1163
+ // Merge derived states into agents — native canvas state takes precedence
1164
+ const now = Date.now();
1165
+ for (const [agentId, info] of Object.entries(byAgent)) {
1166
+ if (agents[agentId])
1167
+ continue; // native state present — don't override
1168
+ const derivedState = info.bestStatus === 'doing' ? 'working'
1169
+ : info.bestStatus === 'validating' ? 'working'
1170
+ : 'working'; // todo → working (has queued work)
1171
+ agents[agentId] = {
1172
+ state: derivedState,
1173
+ currentTask: info.taskTitle,
1174
+ updatedAt: now,
1175
+ source: 'task-derived',
1176
+ };
1177
+ }
1178
+ // ── Waiting state overlay ───────────────────────────────────────────
1179
+ // Agents in waiting status get state='needs-attention' (amber pulse) on canvas.
1180
+ // This runs AFTER task-derived but BEFORE thinking inference — waiting overrides working.
1181
+ // Native canvas state still wins if explicitly set.
1182
+ const allAgentInfos = getAgents();
1183
+ for (const agent of allAgentInfos) {
1184
+ if (agent.status !== 'waiting')
1185
+ continue;
1186
+ if (agents[agent.name])
1187
+ continue; // native state — don't override
1188
+ agents[agent.name] = {
1189
+ state: 'waiting', // soft amber drift — distinct from needs-attention (bright pulse)
1190
+ updatedAt: now,
1191
+ source: 'waiting-derived',
1192
+ waitingFor: agent.waitingFor ?? null,
1193
+ waitingTaskId: agent.waitingTaskId ?? null,
1194
+ };
1195
+ }
1196
+ }
1197
+ catch { /* task API not ready — not fatal */ }
1198
+ // ── Thinking state inference ────────────────────────────────────────────
1199
+ // Agent has an active (running, non-completed) run AND hasn't sent a message
1200
+ // in >2min → auto-derive state = 'thinking'. Explicit native canvas state always
1201
+ // wins; this only fills gaps left after task-derived and native state passes.
1202
+ // @swift @kotlin: once this ships, local heuristics for thinking can be removed.
1203
+ try {
1204
+ const THINKING_SILENCE_MS = 2 * 60 * 1000; // 2 minutes
1205
+ const now2 = Date.now();
1206
+ const presences = presenceManager.getAllPresence();
1207
+ const presenceByAgent = new Map(presences.map(p => [p.agent, p]));
1208
+ const allAgents = getAgents();
1209
+ for (const agent of allAgents) {
1210
+ // Skip if already has an explicit state (native or task-derived)
1211
+ if (agents[agent.name])
1212
+ continue;
1213
+ // Check for an active (incomplete) run
1214
+ const runs = listAgentRuns(agent.name, 'default', { limit: 5 });
1215
+ const hasActiveRun = runs.some(r => r.status === 'working' && r.completedAt === null);
1216
+ if (!hasActiveRun)
1217
+ continue;
1218
+ // Check message silence window
1219
+ const presence = presenceByAgent.get(agent.name);
1220
+ const lastMsgTs = presence?.lastUpdate ?? 0;
1221
+ if (now2 - lastMsgTs > THINKING_SILENCE_MS) {
1222
+ agents[agent.name] = {
1223
+ state: 'thinking',
1224
+ updatedAt: now2,
1225
+ source: 'thinking-inferred',
1226
+ };
1227
+ }
1228
+ }
1229
+ }
1230
+ catch { /* non-fatal */ }
1231
+ // ── Needs-attention call hook ───────────────────────────────────────────
1232
+ // Check for new needs-attention transitions BEFORE pushing to cloud.
1233
+ // The Fly canvas handler also triggers auto-calls, but this node-side hook
1234
+ // fires immediately on state detection — no waiting for Fly SSE round-trip.
1235
+ if (Object.keys(agents).length > 0 && state.hostId && state.credential && config.cloudUrl) {
1236
+ checkNeedsAttentionTransitions(agents, state.hostId, config.cloudUrl, state.credential);
1237
+ }
1238
+ // Inject agent avatars into sync payload — browsers on app.reflectt.ai read avatar
1239
+ // from agent state (canvasStore), not from a separate API call. We merge avatar
1240
+ // into each agent entry here so cloud browsers render custom orbs instead of circles.
1241
+ // Agents with avatars who haven't posted a canvas state get a floor stub so their
1242
+ // custom orb always reaches the cloud (not just when canvas/state is called).
1243
+ // task-1773690756100
1244
+ try {
1245
+ const db = getDb();
1246
+ const avatarRows = db.prepare("SELECT agent_id, settings FROM agent_config WHERE settings LIKE '%avatar%'").all();
1247
+ for (const row of avatarRows) {
1248
+ try {
1249
+ const s = JSON.parse(row.settings);
1250
+ if (s.avatar?.content) {
1251
+ if (agents[row.agent_id]) {
1252
+ // Agent already has a canvas state — just inject the avatar string
1253
+ agents[row.agent_id].avatar = s.avatar.content;
1254
+ }
1255
+ // No floor stub for agents without canvas state — this was causing extra
1256
+ // agents to appear in the canvas constellation and fighting SSE presence updates.
1257
+ // Avatars only render when the agent has an active canvas state.
1258
+ }
1259
+ }
1260
+ catch { /* skip */ }
1261
+ }
1262
+ }
1263
+ catch { /* non-blocking */ }
1264
+ // Push to cloud — include slots, agent states, and any buffered canvas_push events
1265
+ const pushEventsToSend = pendingPushEvents.splice(0, pendingPushEvents.length);
1266
+ const result = await cloudPost(`/api/hosts/${state.hostId}/canvas`, { slots: activeSlots, agents, push_events: pushEventsToSend.length > 0 ? pushEventsToSend : undefined });
989
1267
  if (result.success && result.data) {
990
1268
  state.lastCanvasSync = Date.now();
991
1269
  if (canvasSyncErrors > 0) {
@@ -998,6 +1276,10 @@ async function syncCanvas() {
998
1276
  if (canvasSyncErrors <= 3 || canvasSyncErrors % 20 === 0) {
999
1277
  console.warn(`☁️ [Canvas] Sync failed (${canvasSyncErrors}): ${result.error}`);
1000
1278
  }
1279
+ // Re-queue events that failed to send (up to cap)
1280
+ if (pushEventsToSend.length > 0) {
1281
+ pendingPushEvents.unshift(...pushEventsToSend.slice(-MAX_PENDING_PUSH_EVENTS));
1282
+ }
1001
1283
  }
1002
1284
  }
1003
1285
  // ---- Run Approval Sync ----
@@ -1012,7 +1294,19 @@ async function syncRunApprovals() {
1012
1294
  return;
1013
1295
  lastApprovalSyncAt = now;
1014
1296
  try {
1015
- const items = listApprovalQueue({ category: 'review', limit: 20 });
1297
+ const KNOWN_AGENTS_SYNC = new Set([
1298
+ 'link', 'kai', 'pixel', 'sage', 'scout', 'echo',
1299
+ 'rhythm', 'spark', 'swift', 'kotlin', 'harmony',
1300
+ 'artdirector', 'uipolish', 'coo', 'cos', 'pm', 'qa',
1301
+ 'shield', 'kindling', 'quill', 'funnel', 'attribution',
1302
+ 'bookkeeper', 'legal-counsel', 'evi-scout',
1303
+ ]);
1304
+ const rawItems = listApprovalQueue({ category: 'review', limit: 20 });
1305
+ // Filter out agent-to-agent reviews — only sync human-required approvals to cloud
1306
+ const items = rawItems.filter(item => {
1307
+ const reviewer = (item.agentId ?? '').toLowerCase().trim();
1308
+ return !reviewer || !KNOWN_AGENTS_SYNC.has(reviewer);
1309
+ });
1016
1310
  if (items.length === 0 && approvalSyncErrors === 0)
1017
1311
  return; // Skip push when empty and no prior errors
1018
1312
  const payload = items.map(item => ({
@@ -1045,6 +1339,180 @@ async function syncRunApprovals() {
1045
1339
  }
1046
1340
  }
1047
1341
  }
1342
+ // ---- Agent Decision Relay Poll ----
1343
+ // When decisions are made via the cloud canvas while the node is behind NAT,
1344
+ // they are queued at GET /api/hosts/:id/agent-interface/decisions.
1345
+ // This function polls that queue and processes each decision locally.
1346
+ let lastDecisionPollAt = 0;
1347
+ const DECISION_POLL_INTERVAL_MS = 10_000; // 10s — same cadence as approval sync
1348
+ async function pollAgentDecisions() {
1349
+ if (!state.hostId || !config)
1350
+ return;
1351
+ const now = Date.now();
1352
+ if (now - lastDecisionPollAt < DECISION_POLL_INTERVAL_MS)
1353
+ return;
1354
+ lastDecisionPollAt = now;
1355
+ try {
1356
+ const result = await cloudGet(`/api/hosts/${state.hostId}/agent-interface/decisions`);
1357
+ if (!result.success)
1358
+ return;
1359
+ const decisions = Array.isArray(result.data?.decisions) ? result.data.decisions : [];
1360
+ if (decisions.length === 0)
1361
+ return;
1362
+ const acked = [];
1363
+ for (const d of decisions) {
1364
+ try {
1365
+ const endpoint = `/agent-interface/runs/${d.eventId}/${d.decision === 'approve' ? 'approve' : 'reject'}`;
1366
+ const res = await fetch(`http://127.0.0.1:4445${endpoint}`, {
1367
+ method: 'POST',
1368
+ headers: { 'Content-Type': 'application/json' },
1369
+ signal: AbortSignal.timeout(3000),
1370
+ });
1371
+ // ACK on success OR 404/409 (run already decided / not found — still remove from queue)
1372
+ if (res.ok || res.status === 404 || res.status === 409) {
1373
+ acked.push(d.eventId);
1374
+ }
1375
+ }
1376
+ catch {
1377
+ // Individual failure — leave in queue, retry next cycle
1378
+ }
1379
+ }
1380
+ if (acked.length > 0) {
1381
+ await cloudPost(`/api/hosts/${state.hostId}/agent-interface/decisions/ack`, { eventIds: acked });
1382
+ console.log(`☁️ [DecisionRelay] Processed ${acked.length}/${decisions.length} queued decisions`);
1383
+ }
1384
+ }
1385
+ catch {
1386
+ // Non-critical — decisions will be retried next cycle
1387
+ }
1388
+ }
1389
+ // ── Canvas query relay polling ────────────────────────────────────────────────
1390
+ // When canvas/query is called from a NAT-behind node, the cloud queues the query
1391
+ // at GET /api/hosts/:id/canvas/query/pending. We poll here, POST each query to
1392
+ // the local node, and ACK. The node emits canvas_message via eventBus → canvas_push
1393
+ // relay → cloud → browser pulse subscribers.
1394
+ let lastCanvasQueryPollAt = 0;
1395
+ const CANVAS_QUERY_POLL_INTERVAL_MS = 8_000; // 8s — faster than decisions (user-facing)
1396
+ async function pollCanvasQueryRelay() {
1397
+ if (!state.hostId || !config)
1398
+ return;
1399
+ const now = Date.now();
1400
+ if (now - lastCanvasQueryPollAt < CANVAS_QUERY_POLL_INTERVAL_MS)
1401
+ return;
1402
+ lastCanvasQueryPollAt = now;
1403
+ try {
1404
+ const result = await cloudGet(`/api/hosts/${state.hostId}/canvas/query/pending`);
1405
+ if (!result.success)
1406
+ return;
1407
+ const queries = Array.isArray(result.data?.queries) ? result.data.queries : [];
1408
+ if (queries.length === 0)
1409
+ return;
1410
+ const acked = [];
1411
+ for (const q of queries) {
1412
+ try {
1413
+ // POST to local node — canvas/query processes it and emits canvas_message via eventBus.
1414
+ // Process query locally and capture card for relay back to the browser.
1415
+ // The card is included in the ACK payload → cloud broadcasts canvas_message to pulse SSE.
1416
+ const res = await fetch('http://127.0.0.1:4445/canvas/query', {
1417
+ method: 'POST',
1418
+ headers: { 'Content-Type': 'application/json' },
1419
+ body: JSON.stringify({ query: q.query, sessionId: q.sessionId ?? undefined }),
1420
+ signal: AbortSignal.timeout(12000), // LLM calls can take ~10s
1421
+ });
1422
+ if (res.ok) {
1423
+ try {
1424
+ const data = await res.json();
1425
+ if (data.card) {
1426
+ // Include card in acked payload so cloud can broadcast it to browser subscribers
1427
+ ;
1428
+ q._card = data.card;
1429
+ }
1430
+ }
1431
+ catch { /* card extraction optional — still ACK */ }
1432
+ acked.push(q.queryId);
1433
+ }
1434
+ else if (res.status === 400) {
1435
+ // Invalid query — still ACK to remove from queue
1436
+ acked.push(q.queryId);
1437
+ }
1438
+ }
1439
+ catch {
1440
+ // Individual failure — leave in queue, retry next cycle
1441
+ }
1442
+ }
1443
+ if (acked.length > 0) {
1444
+ // Collect response cards for broadcast: cloud will emit canvas_message to pulse SSE
1445
+ const cards = queries
1446
+ .filter(q => acked.includes(q.queryId) && q._card)
1447
+ .map(q => q._card);
1448
+ await cloudPost(`/api/hosts/${state.hostId}/canvas/query/ack`, { queryIds: acked, cards });
1449
+ console.log(`☁️ [CanvasQueryRelay] Processed ${acked.length}/${queries.length} relay queries, ${cards.length} cards broadcast`);
1450
+ }
1451
+ }
1452
+ catch {
1453
+ // Non-critical — queries will be retried next cycle
1454
+ }
1455
+ }
1456
+ // ---- Agent Runs Sync ----
1457
+ // Push agent run records to cloud action_runs table (used by cloud Runs screen)
1458
+ let agentRunSyncErrors = 0;
1459
+ async function syncAgentRuns() {
1460
+ if (!state.hostId || !config)
1461
+ return;
1462
+ try {
1463
+ const agents = getAgents();
1464
+ if (agents.length === 0)
1465
+ return;
1466
+ const allRuns = [];
1467
+ for (const agent of agents) {
1468
+ const runs = listAgentRuns(agent.name, 'default', { limit: 20 });
1469
+ allRuns.push(...runs);
1470
+ }
1471
+ if (allRuns.length === 0)
1472
+ return;
1473
+ // Enrich runs with cost attribution from local model_usage table.
1474
+ // task_id lives in contextSnapshot.taskId — only attributed when present.
1475
+ // No time-window fallback (too error-prone for financial metrics).
1476
+ const enrichedRuns = allRuns.map(run => {
1477
+ const taskId = typeof run.contextSnapshot?.taskId === 'string' ? run.contextSnapshot.taskId : null;
1478
+ return {
1479
+ ...run,
1480
+ taskId,
1481
+ costUsd: taskId ? getCostForTaskId(taskId) : null,
1482
+ };
1483
+ });
1484
+ const result = await cloudPost(`/api/hosts/${state.hostId}/runs/sync`, { runs: enrichedRuns });
1485
+ if (result.success || result.data) {
1486
+ if (agentRunSyncErrors > 0) {
1487
+ console.log('☁️ [RunSync] Recovered after errors');
1488
+ agentRunSyncErrors = 0;
1489
+ }
1490
+ }
1491
+ }
1492
+ catch (err) {
1493
+ agentRunSyncErrors++;
1494
+ if (agentRunSyncErrors <= 3) {
1495
+ console.warn(`☁️ [RunSync] Error: ${err?.message}`);
1496
+ }
1497
+ }
1498
+ }
1499
+ // ---- Trust Event Sync ----
1500
+ // Push unpushed trust-collapse signals to cloud agent_trust_events table.
1501
+ async function syncTrustEvents() {
1502
+ const { hostId } = state;
1503
+ if (!hostId)
1504
+ return;
1505
+ const events = getUnpushedTrustEvents(50);
1506
+ if (events.length === 0)
1507
+ return;
1508
+ try {
1509
+ const result = await cloudPost(`/api/hosts/${hostId}/trust-events/sync`, { events: events.map(e => ({ id: e.id, agentId: e.agentId, type: e.eventType, severity: e.severity, taskId: e.taskId ?? null, summary: e.summary, metadata: e.context, emittedAt: e.occurredAt })) });
1510
+ if (result.success || result.data?.ok) {
1511
+ markTrustEventsPushed(events.map(e => e.id));
1512
+ }
1513
+ }
1514
+ catch { /* non-fatal */ }
1515
+ }
1048
1516
  // ---- Run Event Sync ----
1049
1517
  // Push recent run events to cloud so Presence SSE relay has data
1050
1518
  let lastRunEventSyncAt = 0;