reflectt-node 0.1.8 → 0.1.12

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 (167) hide show
  1. package/defaults/TEAM-ROLES.yaml +317 -5
  2. package/dist/agent-config.d.ts +51 -0
  3. package/dist/agent-config.d.ts.map +1 -0
  4. package/dist/agent-config.js +129 -0
  5. package/dist/agent-config.js.map +1 -0
  6. package/dist/agent-config.test.d.ts +2 -0
  7. package/dist/agent-config.test.d.ts.map +1 -0
  8. package/dist/agent-config.test.js +91 -0
  9. package/dist/agent-config.test.js.map +1 -0
  10. package/dist/agent-memories.d.ts +58 -0
  11. package/dist/agent-memories.d.ts.map +1 -0
  12. package/dist/agent-memories.js +168 -0
  13. package/dist/agent-memories.js.map +1 -0
  14. package/dist/agent-memories.test.d.ts +2 -0
  15. package/dist/agent-memories.test.d.ts.map +1 -0
  16. package/dist/agent-memories.test.js +327 -0
  17. package/dist/agent-memories.test.js.map +1 -0
  18. package/dist/agent-messaging.d.ts +50 -0
  19. package/dist/agent-messaging.d.ts.map +1 -0
  20. package/dist/agent-messaging.js +103 -0
  21. package/dist/agent-messaging.js.map +1 -0
  22. package/dist/agent-messaging.test.d.ts +2 -0
  23. package/dist/agent-messaging.test.d.ts.map +1 -0
  24. package/dist/agent-messaging.test.js +105 -0
  25. package/dist/agent-messaging.test.js.map +1 -0
  26. package/dist/agent-runs.d.ts +158 -0
  27. package/dist/agent-runs.d.ts.map +1 -0
  28. package/dist/agent-runs.js +514 -0
  29. package/dist/agent-runs.js.map +1 -0
  30. package/dist/agent-runs.test.d.ts +2 -0
  31. package/dist/agent-runs.test.d.ts.map +1 -0
  32. package/dist/agent-runs.test.js +386 -0
  33. package/dist/agent-runs.test.js.map +1 -0
  34. package/dist/approval-queue.test.d.ts +2 -0
  35. package/dist/approval-queue.test.d.ts.map +1 -0
  36. package/dist/approval-queue.test.js +118 -0
  37. package/dist/approval-queue.test.js.map +1 -0
  38. package/dist/artifact-store.d.ts +55 -0
  39. package/dist/artifact-store.d.ts.map +1 -0
  40. package/dist/artifact-store.js +128 -0
  41. package/dist/artifact-store.js.map +1 -0
  42. package/dist/artifact-store.test.d.ts +2 -0
  43. package/dist/artifact-store.test.d.ts.map +1 -0
  44. package/dist/artifact-store.test.js +119 -0
  45. package/dist/artifact-store.test.js.map +1 -0
  46. package/dist/boardHealthWorker.d.ts +28 -0
  47. package/dist/boardHealthWorker.d.ts.map +1 -1
  48. package/dist/boardHealthWorker.js +33 -1
  49. package/dist/boardHealthWorker.js.map +1 -1
  50. package/dist/canvas-input.test.d.ts +2 -0
  51. package/dist/canvas-input.test.d.ts.map +1 -0
  52. package/dist/canvas-input.test.js +96 -0
  53. package/dist/canvas-input.test.js.map +1 -0
  54. package/dist/canvas-render.test.d.ts +2 -0
  55. package/dist/canvas-render.test.d.ts.map +1 -0
  56. package/dist/canvas-render.test.js +95 -0
  57. package/dist/canvas-render.test.js.map +1 -0
  58. package/dist/capabilities/browser.d.ts +75 -0
  59. package/dist/capabilities/browser.d.ts.map +1 -0
  60. package/dist/capabilities/browser.js +172 -0
  61. package/dist/capabilities/browser.js.map +1 -0
  62. package/dist/channels.d.ts +1 -1
  63. package/dist/cli.js +4 -2
  64. package/dist/cli.js.map +1 -1
  65. package/dist/cloud.d.ts +2 -0
  66. package/dist/cloud.d.ts.map +1 -1
  67. package/dist/cloud.js +135 -3
  68. package/dist/cloud.js.map +1 -1
  69. package/dist/cost-enforcement.d.ts +38 -0
  70. package/dist/cost-enforcement.d.ts.map +1 -0
  71. package/dist/cost-enforcement.js +84 -0
  72. package/dist/cost-enforcement.js.map +1 -0
  73. package/dist/dashboard.d.ts.map +1 -1
  74. package/dist/dashboard.js +8 -0
  75. package/dist/dashboard.js.map +1 -1
  76. package/dist/db.d.ts.map +1 -1
  77. package/dist/db.js +131 -0
  78. package/dist/db.js.map +1 -1
  79. package/dist/e2e-loop-proof.test.d.ts +2 -0
  80. package/dist/e2e-loop-proof.test.d.ts.map +1 -0
  81. package/dist/e2e-loop-proof.test.js +104 -0
  82. package/dist/e2e-loop-proof.test.js.map +1 -0
  83. package/dist/email-sms-send.test.d.ts +2 -0
  84. package/dist/email-sms-send.test.d.ts.map +1 -0
  85. package/dist/email-sms-send.test.js +96 -0
  86. package/dist/email-sms-send.test.js.map +1 -0
  87. package/dist/events.d.ts +1 -1
  88. package/dist/events.d.ts.map +1 -1
  89. package/dist/events.js +2 -0
  90. package/dist/events.js.map +1 -1
  91. package/dist/fingerprint.d.ts.map +1 -1
  92. package/dist/fingerprint.js +5 -10
  93. package/dist/fingerprint.js.map +1 -1
  94. package/dist/github-webhook-chat.d.ts +75 -0
  95. package/dist/github-webhook-chat.d.ts.map +1 -0
  96. package/dist/github-webhook-chat.js +108 -0
  97. package/dist/github-webhook-chat.js.map +1 -0
  98. package/dist/handoff-state.test.d.ts +2 -0
  99. package/dist/handoff-state.test.d.ts.map +1 -0
  100. package/dist/handoff-state.test.js +102 -0
  101. package/dist/handoff-state.test.js.map +1 -0
  102. package/dist/health.d.ts +9 -0
  103. package/dist/health.d.ts.map +1 -1
  104. package/dist/health.js +18 -0
  105. package/dist/health.js.map +1 -1
  106. package/dist/host-error-correlation.d.ts +65 -0
  107. package/dist/host-error-correlation.d.ts.map +1 -0
  108. package/dist/host-error-correlation.js +123 -0
  109. package/dist/host-error-correlation.js.map +1 -0
  110. package/dist/index.js +39 -10
  111. package/dist/index.js.map +1 -1
  112. package/dist/notificationDedupeGuard.d.ts +4 -0
  113. package/dist/notificationDedupeGuard.d.ts.map +1 -1
  114. package/dist/notificationDedupeGuard.js +8 -4
  115. package/dist/notificationDedupeGuard.js.map +1 -1
  116. package/dist/presence.d.ts +37 -5
  117. package/dist/presence.d.ts.map +1 -1
  118. package/dist/presence.js +127 -16
  119. package/dist/presence.js.map +1 -1
  120. package/dist/review-sla.d.ts +9 -0
  121. package/dist/review-sla.d.ts.map +1 -0
  122. package/dist/review-sla.js +51 -0
  123. package/dist/review-sla.js.map +1 -0
  124. package/dist/routing-enforcement.test.d.ts +2 -0
  125. package/dist/routing-enforcement.test.d.ts.map +1 -0
  126. package/dist/routing-enforcement.test.js +86 -0
  127. package/dist/routing-enforcement.test.js.map +1 -0
  128. package/dist/run-retention.test.d.ts +2 -0
  129. package/dist/run-retention.test.d.ts.map +1 -0
  130. package/dist/run-retention.test.js +57 -0
  131. package/dist/run-retention.test.js.map +1 -0
  132. package/dist/run-stream.test.d.ts +2 -0
  133. package/dist/run-stream.test.d.ts.map +1 -0
  134. package/dist/run-stream.test.js +70 -0
  135. package/dist/run-stream.test.js.map +1 -0
  136. package/dist/server.d.ts.map +1 -1
  137. package/dist/server.js +1301 -75
  138. package/dist/server.js.map +1 -1
  139. package/dist/tasks.d.ts.map +1 -1
  140. package/dist/tasks.js +45 -0
  141. package/dist/tasks.js.map +1 -1
  142. package/dist/todoHoardingGuard.d.ts +17 -0
  143. package/dist/todoHoardingGuard.d.ts.map +1 -1
  144. package/dist/todoHoardingGuard.js +25 -2
  145. package/dist/todoHoardingGuard.js.map +1 -1
  146. package/dist/webhook-storage.d.ts +50 -0
  147. package/dist/webhook-storage.d.ts.map +1 -0
  148. package/dist/webhook-storage.js +102 -0
  149. package/dist/webhook-storage.js.map +1 -0
  150. package/dist/webhook-storage.test.d.ts +2 -0
  151. package/dist/webhook-storage.test.d.ts.map +1 -0
  152. package/dist/webhook-storage.test.js +86 -0
  153. package/dist/webhook-storage.test.js.map +1 -0
  154. package/dist/workflow-templates.d.ts +44 -0
  155. package/dist/workflow-templates.d.ts.map +1 -0
  156. package/dist/workflow-templates.js +154 -0
  157. package/dist/workflow-templates.js.map +1 -0
  158. package/dist/workflow-templates.test.d.ts +2 -0
  159. package/dist/workflow-templates.test.d.ts.map +1 -0
  160. package/dist/workflow-templates.test.js +76 -0
  161. package/dist/workflow-templates.test.js.map +1 -0
  162. package/package.json +3 -1
  163. package/public/dashboard.js +76 -1
  164. package/public/design-tokens-platform.md +118 -0
  165. package/public/design-tokens.css +195 -0
  166. package/public/docs.md +131 -2
  167. package/public/presence-loop-demo.html +473 -0
package/dist/server.js CHANGED
@@ -69,9 +69,9 @@ import { researchManager } from './research.js';
69
69
  import { wsHeartbeat } from './ws-heartbeat.js';
70
70
  import { getBuildInfo } from './buildInfo.js';
71
71
  import { appendStoredLog, readStoredLogs, getStoredLogPath } from './logStore.js';
72
- import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, scoreAssignment, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
72
+ import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
73
73
  import { initTelemetry, trackRequest as trackTelemetryRequest, trackError as trackTelemetryError, trackTaskEvent, getSnapshot as getTelemetrySnapshot, getTelemetryConfig } from './telemetry.js';
74
- import { recordUsage, recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, getDailySpendByModel, getAvgCostByLane, getAvgCostByAgent, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
74
+ import { recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, getDailySpendByModel, getAvgCostByLane, getAvgCostByAgent, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
75
75
  import { getTeamConfigHealth } from './team-config.js';
76
76
  import { SecretVault } from './secrets.js';
77
77
  import { initGitHubActorAuth, resolveGitHubTokenForActor } from './github-actor-auth.js';
@@ -81,6 +81,7 @@ import { createGitHubIdentityProvider } from './github-identity.js';
81
81
  import { getProvisioningManager } from './provisioning.js';
82
82
  import { getWebhookDeliveryManager } from './webhooks.js';
83
83
  import { enrichWebhookPayload } from './github-webhook-attribution.js';
84
+ import { formatGitHubEvent } from './github-webhook-chat.js';
84
85
  import { exportBundle, importBundle } from './portability.js';
85
86
  import { getNotificationManager } from './notifications.js';
86
87
  import { getConnectivityManager } from './connectivity.js';
@@ -275,6 +276,13 @@ function normalizeConfiguredModel(value) {
275
276
  error: `Unknown model identifier "${raw}". Allowed aliases: ${Object.keys(MODEL_ALIASES).join(', ')} or provider/model format.`,
276
277
  };
277
278
  }
279
+ // ── Handoff state schema (max 3 columns per COO rule) ─────────────
280
+ const VALID_HANDOFF_DECISIONS = ['approved', 'rejected', 'needs_changes', 'escalated'];
281
+ const HandoffStateSchema = z.object({
282
+ reviewed_by: z.string().min(1),
283
+ decision: z.enum(VALID_HANDOFF_DECISIONS),
284
+ next_owner: z.string().min(1).optional(),
285
+ }).strict();
278
286
  const UpdateTaskSchema = z.object({
279
287
  title: z.string().min(1).optional(),
280
288
  description: z.string().optional(),
@@ -3000,6 +3008,22 @@ export async function createServer() {
3000
3008
  reply.code(500).send({ error: 'Failed to load UI kit page' });
3001
3009
  }
3002
3010
  });
3011
+ // Presence loop demo — live end-to-end ambient→run→approve→result→collapse
3012
+ app.get('/presence-loop', async (_request, reply) => {
3013
+ try {
3014
+ const { promises: fs } = await import('fs');
3015
+ const { join } = await import('path');
3016
+ const { fileURLToPath } = await import('url');
3017
+ const { dirname } = await import('path');
3018
+ const __filename = fileURLToPath(import.meta.url);
3019
+ const __dirname = dirname(__filename);
3020
+ const html = await fs.readFile(join(__dirname, '..', 'public', 'presence-loop-demo.html'), 'utf-8');
3021
+ reply.type('text/html; charset=utf-8').send(html);
3022
+ }
3023
+ catch (err) {
3024
+ reply.code(500).send({ error: 'Failed to load presence loop demo' });
3025
+ }
3026
+ });
3003
3027
  app.get('/docs', async (_request, reply) => {
3004
3028
  try {
3005
3029
  const { promises: fs } = await import('fs');
@@ -4506,6 +4530,46 @@ export async function createServer() {
4506
4530
  matchType: resolved.matchType,
4507
4531
  };
4508
4532
  });
4533
+ // ── Task handoff state ─────────────────────────────────────────────
4534
+ app.get('/tasks/:id/handoff', async (request, reply) => {
4535
+ const resolved = taskManager.resolveTaskId(request.params.id);
4536
+ if (!resolved.task) {
4537
+ reply.code(404);
4538
+ return { error: 'Task not found' };
4539
+ }
4540
+ const meta = resolved.task.metadata;
4541
+ const handoff = meta?.handoff_state ?? null;
4542
+ return {
4543
+ taskId: resolved.resolvedId,
4544
+ status: resolved.task.status,
4545
+ handoff_state: handoff,
4546
+ };
4547
+ });
4548
+ app.put('/tasks/:id/handoff', async (request, reply) => {
4549
+ const resolved = taskManager.resolveTaskId(request.params.id);
4550
+ if (!resolved.task || !resolved.resolvedId) {
4551
+ reply.code(404);
4552
+ return { error: 'Task not found' };
4553
+ }
4554
+ const body = request.body;
4555
+ const result = HandoffStateSchema.safeParse(body);
4556
+ if (!result.success) {
4557
+ reply.code(422);
4558
+ return {
4559
+ error: `Invalid handoff_state: ${result.error.issues.map(i => i.message).join(', ')}`,
4560
+ hint: 'Required: reviewed_by (string), decision (approved|rejected|needs_changes|escalated). Optional: next_owner (string).',
4561
+ };
4562
+ }
4563
+ const existingMeta = (resolved.task.metadata || {});
4564
+ taskManager.updateTask(resolved.resolvedId, {
4565
+ metadata: { ...existingMeta, handoff_state: result.data },
4566
+ });
4567
+ return {
4568
+ success: true,
4569
+ taskId: resolved.resolvedId,
4570
+ handoff_state: result.data,
4571
+ };
4572
+ });
4509
4573
  // Task artifact visibility — resolves artifact paths and checks accessibility
4510
4574
  app.get('/tasks/:id/artifacts', async (request, reply) => {
4511
4575
  const resolved = resolveTaskFromParam(request.params.id, reply);
@@ -5672,7 +5736,9 @@ export async function createServer() {
5672
5736
  || data.metadata?.skip_dedup === true
5673
5737
  || data.metadata?.is_test === true;
5674
5738
  if (!skipDedup && data.assignee) {
5675
- const TASK_DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
5739
+ // 60-second window targets gateway reconnect double-fire (typical gap: <10s)
5740
+ // without blocking legitimate same-title task creation later in the day.
5741
+ const TASK_DEDUP_WINDOW_MS = 60_000; // 60 seconds
5676
5742
  const cutoff = Date.now() - TASK_DEDUP_WINDOW_MS;
5677
5743
  const normalizedTitle = data.title.trim().toLowerCase();
5678
5744
  const activeTasks = taskManager.listTasks({ includeTest: true }).filter(t => t.status !== 'done'
@@ -5680,16 +5746,15 @@ export async function createServer() {
5680
5746
  && t.createdAt >= cutoff
5681
5747
  && t.title.trim().toLowerCase() === normalizedTitle);
5682
5748
  if (activeTasks.length > 0) {
5749
+ // Return 200 with the existing task (collapse, not reject).
5750
+ // Agents that create-on-reconnect receive a success response identical
5751
+ // to what they'd get from a new creation — no retry loop triggered.
5683
5752
  const existing = activeTasks[0];
5684
- reply.code(409);
5685
5753
  return {
5686
- success: false,
5687
- error: 'Duplicate task',
5688
- code: 'DUPLICATE_TASK',
5689
- status: 409,
5690
- existing_id: existing.id,
5691
- existing_status: existing.status,
5692
- hint: `Task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}). Created ${Math.round((Date.now() - existing.createdAt) / 60000)}m ago.`,
5754
+ success: true,
5755
+ task: existing,
5756
+ deduplicated: true,
5757
+ hint: `Duplicate suppressed — task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}, created ${Math.round((Date.now() - existing.createdAt) / 1000)}s ago).`,
5693
5758
  };
5694
5759
  }
5695
5760
  }
@@ -6082,6 +6147,14 @@ export async function createServer() {
6082
6147
  const pruned = boardHealthWorker.pruneAuditLog(maxAgeDays);
6083
6148
  return { success: true, pruned };
6084
6149
  });
6150
+ app.post('/board-health/quiet-window', async () => {
6151
+ boardHealthWorker.resetQuietWindow();
6152
+ return {
6153
+ success: true,
6154
+ quietUntil: Date.now() + (boardHealthWorker.getStatus().config?.restartQuietWindowMs ?? 300_000),
6155
+ message: 'Quiet window reset — ready-queue alerts suppressed for restart window',
6156
+ };
6157
+ });
6085
6158
  // ── Agent change feed ─────────────────────────────────────────────────
6086
6159
  app.get('/feed/:agent', async (request) => {
6087
6160
  const { agent } = request.params;
@@ -6366,6 +6439,22 @@ export async function createServer() {
6366
6439
  mergedMeta.reopened_from = existing.status;
6367
6440
  }
6368
6441
  }
6442
+ // ── Handoff state validation ──
6443
+ if (mergedMeta.handoff_state && typeof mergedMeta.handoff_state === 'object') {
6444
+ const handoffResult = HandoffStateSchema.safeParse(mergedMeta.handoff_state);
6445
+ if (!handoffResult.success) {
6446
+ reply.code(422);
6447
+ return {
6448
+ success: false,
6449
+ error: `Invalid handoff_state: ${handoffResult.error.issues.map(i => i.message).join(', ')}`,
6450
+ code: 'INVALID_HANDOFF_STATE',
6451
+ hint: 'handoff_state must have: reviewed_by (string), decision (approved|rejected|needs_changes|escalated), optional next_owner (string). Max 3 fields per COO rule.',
6452
+ gate: 'handoff_state',
6453
+ };
6454
+ }
6455
+ // Stamp validated handoff
6456
+ mergedMeta.handoff_state = handoffResult.data;
6457
+ }
6369
6458
  // ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
6370
6459
  if (parsed.status === 'cancelled') {
6371
6460
  const meta = (incomingMeta ?? {});
@@ -6818,20 +6907,24 @@ export async function createServer() {
6818
6907
  if (task.assignee) {
6819
6908
  if (parsed.status === 'done') {
6820
6909
  presenceManager.recordActivity(task.assignee, 'task_completed');
6821
- presenceManager.updatePresence(task.assignee, 'working');
6910
+ presenceManager.updatePresence(task.assignee, 'working', null);
6822
6911
  trackTaskEvent('completed');
6823
6912
  }
6824
6913
  else if (parsed.status === 'doing') {
6825
- presenceManager.updatePresence(task.assignee, 'working');
6914
+ presenceManager.updatePresence(task.assignee, 'working', task.id);
6826
6915
  }
6827
6916
  else if (parsed.status === 'blocked') {
6828
- presenceManager.updatePresence(task.assignee, 'blocked');
6917
+ presenceManager.updatePresence(task.assignee, 'blocked', task.id);
6829
6918
  }
6830
6919
  else if (parsed.status === 'validating') {
6831
- presenceManager.updatePresence(task.assignee, 'reviewing');
6920
+ presenceManager.updatePresence(task.assignee, 'reviewing', task.id);
6832
6921
  }
6833
6922
  }
6834
6923
  // ── Reviewer notification: @mention reviewer when task enters validating ──
6924
+ // NOTE: A dedup_key is set here so the inline chat dedup guard suppresses
6925
+ // any duplicate reviewRequested send that may arrive via the statusNotifTargets
6926
+ // loop below for the same task+transition. Without it, two messages fire for
6927
+ // every todo→validating transition (this direct send + the loop send).
6835
6928
  if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
6836
6929
  const taskMeta = task.metadata;
6837
6930
  const prUrl = taskMeta?.review_handoff?.pr_url
@@ -6848,6 +6941,7 @@ export async function createServer() {
6848
6941
  taskId: task.id,
6849
6942
  reviewer: existing.reviewer,
6850
6943
  prUrl: prUrl || undefined,
6944
+ dedup_key: `review-requested:${task.id}:${task.updatedAt}`,
6851
6945
  },
6852
6946
  }).catch(() => { }); // Non-blocking
6853
6947
  }
@@ -6971,13 +7065,17 @@ export async function createServer() {
6971
7065
  // Dedupe guard: prevent stale/out-of-order notification events
6972
7066
  const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
6973
7067
  for (const target of statusNotifTargets) {
6974
- // Check dedupe guard before emitting
7068
+ // Check dedupe guard before emitting.
7069
+ // Pass targetAgent so each recipient gets an independent cursor — prevents
7070
+ // the first recipient's cursor update from suppressing later recipients for
7071
+ // the same event (e.g. assignee + reviewer both getting taskCompleted on 'done').
6975
7072
  const dedupeCheck = shouldEmitNotification({
6976
7073
  taskId: task.id,
6977
7074
  eventUpdatedAt: task.updatedAt,
6978
7075
  eventStatus: parsed.status,
6979
7076
  currentTaskStatus: task.status,
6980
7077
  currentTaskUpdatedAt: task.updatedAt,
7078
+ targetAgent: target.agent,
6981
7079
  });
6982
7080
  if (!dedupeCheck.emit) {
6983
7081
  console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
@@ -6990,7 +7088,13 @@ export async function createServer() {
6990
7088
  message: `Task ${task.id} → ${parsed.status}`,
6991
7089
  });
6992
7090
  if (routing.shouldNotify) {
6993
- // Route through inbox/chat based on delivery method preference
7091
+ // Route through inbox/chat based on delivery method preference.
7092
+ // For reviewRequested, set a dedup_key matching the direct send above so the
7093
+ // inline chat dedup suppresses this copy (the direct send fires first with a
7094
+ // richer payload including PR URL and `to:` routing).
7095
+ const dedupKey = target.type === 'reviewRequested'
7096
+ ? `review-requested:${task.id}:${task.updatedAt}`
7097
+ : undefined;
6994
7098
  chatManager.sendMessage({
6995
7099
  from: 'system',
6996
7100
  content: `@${target.agent} [${target.type}:${task.id}] ${task.title} → ${parsed.status}`,
@@ -7001,6 +7105,7 @@ export async function createServer() {
7001
7105
  status: parsed.status,
7002
7106
  updatedAt: task.updatedAt,
7003
7107
  deliveryMethod: routing.deliveryMethod,
7108
+ ...(dedupKey ? { dedup_key: dedupKey } : {}),
7004
7109
  },
7005
7110
  }).catch(() => { }); // Non-blocking
7006
7111
  }
@@ -7071,6 +7176,27 @@ export async function createServer() {
7071
7176
  };
7072
7177
  app.get('/agents', async () => buildRoleRegistryPayload());
7073
7178
  app.get('/agents/roles', async () => buildRoleRegistryPayload());
7179
+ // Host-native identity resolution — resolves agent by name, alias, or display name
7180
+ // without requiring the OpenClaw gateway. Merges YAML roles + agent_config table.
7181
+ app.get('/agents/:name/identity', async (request) => {
7182
+ const { name } = request.params;
7183
+ const resolved = resolveAgentMention(name);
7184
+ const role = resolved ? getAgentRole(resolved) : getAgentRole(name);
7185
+ if (!role) {
7186
+ return { found: false, query: name, hint: 'Agent not found in YAML roles or config' };
7187
+ }
7188
+ return {
7189
+ found: true,
7190
+ agentId: role.name,
7191
+ displayName: role.displayName ?? role.name,
7192
+ role: role.role,
7193
+ description: role.description ?? null,
7194
+ aliases: role.aliases ?? [],
7195
+ affinityTags: role.affinityTags ?? [],
7196
+ wipCap: role.wipCap,
7197
+ source: 'yaml',
7198
+ };
7199
+ });
7074
7200
  // Team-scoped alias for assignment-engine consumers
7075
7201
  app.get('/team/roles', async () => {
7076
7202
  const payload = buildRoleRegistryPayload();
@@ -7494,55 +7620,7 @@ export async function createServer() {
7494
7620
  };
7495
7621
  });
7496
7622
  // ── Approval Queue ──────────────────────────────────────────────────
7497
- app.get('/approval-queue', async () => {
7498
- // Tasks in 'todo' that were auto-assigned (have suggestedAgent in metadata) or need assignment review
7499
- const allTasks = taskManager.listTasks({});
7500
- const todoTasks = allTasks.filter(t => t.status === 'todo');
7501
- const items = todoTasks.map(t => {
7502
- const task = t;
7503
- const meta = task.metadata || {};
7504
- const title = task.title || '';
7505
- const tags = Array.isArray(task.tags) ? task.tags : [];
7506
- const doneCriteria = Array.isArray(task.done_criteria) ? task.done_criteria : [];
7507
- // Score all agents for this task
7508
- const roles = getAgentRoles();
7509
- const agentOptions = roles.map(agent => {
7510
- const wipCount = allTasks.filter(at => at.status === 'doing' && (at.assignee || '').toLowerCase() === agent.name).length;
7511
- const s = scoreAssignment(agent, { title, tags, done_criteria: doneCriteria }, wipCount);
7512
- return {
7513
- agentId: agent.name,
7514
- name: agent.name,
7515
- confidenceScore: Math.max(0, Math.min(1, s.score)),
7516
- affinityTags: agent.affinityTags,
7517
- };
7518
- }).sort((a, b) => b.confidenceScore - a.confidenceScore);
7519
- const topAgent = agentOptions[0];
7520
- const suggestedAgent = task.assignee || topAgent?.agentId || null;
7521
- const confidenceScore = topAgent?.confidenceScore || 0;
7522
- const confidenceReason = topAgent && topAgent.confidenceScore > 0
7523
- ? `${topAgent.name}: affinity match on ${topAgent.affinityTags.slice(0, 3).join(', ')}`
7524
- : 'No strong affinity match';
7525
- return {
7526
- taskId: task.id,
7527
- title,
7528
- description: task.description || '',
7529
- priority: task.priority || 'P3',
7530
- suggestedAgent,
7531
- confidenceScore,
7532
- confidenceReason,
7533
- agentOptions,
7534
- status: 'pending',
7535
- };
7536
- });
7537
- const highConfidence = items.filter(i => i.confidenceScore >= 0.85);
7538
- const needsReview = items.filter(i => i.confidenceScore < 0.85);
7539
- return {
7540
- items: [...highConfidence, ...needsReview],
7541
- total: items.length,
7542
- highConfidenceCount: highConfidence.length,
7543
- needsReviewCount: needsReview.length,
7544
- };
7545
- });
7623
+ // Note: GET /approval-queue is defined below near /approval-queue/:approvalId/decide
7546
7624
  app.post('/approval-queue/:taskId/approve', async (request, reply) => {
7547
7625
  const body = request.body;
7548
7626
  const taskId = request.params.taskId;
@@ -7698,6 +7776,88 @@ export async function createServer() {
7698
7776
  warnings: result.warnings,
7699
7777
  };
7700
7778
  });
7779
+ // ── Presence Layer canvas state ─────────────────────────────────────
7780
+ // Agent emits canvas_render state transitions for the Presence Layer.
7781
+ // Deterministic event types. No "AI can emit anything" protocol.
7782
+ const CANVAS_STATES = ['floor', 'listening', 'thinking', 'rendering', 'ambient', 'decision', 'urgent', 'handoff'];
7783
+ const SENSOR_VALUES = [null, 'mic', 'camera', 'mic+camera'];
7784
+ const CanvasRenderSchema = z.object({
7785
+ state: z.enum(CANVAS_STATES),
7786
+ sensors: z.enum(['mic', 'camera', 'mic+camera']).nullable().default(null),
7787
+ agentId: z.string().min(1),
7788
+ payload: z.object({
7789
+ text: z.string().optional(),
7790
+ media: z.unknown().optional(),
7791
+ decision: z.object({
7792
+ question: z.string(),
7793
+ context: z.string().optional(),
7794
+ decisionId: z.string(),
7795
+ expiresAt: z.number().optional(),
7796
+ autoAction: z.string().optional(),
7797
+ }).optional(),
7798
+ agents: z.array(z.object({
7799
+ name: z.string(),
7800
+ state: z.string(),
7801
+ task: z.string().optional(),
7802
+ })).optional(),
7803
+ summary: z.object({
7804
+ headline: z.string(),
7805
+ items: z.array(z.string()).optional(),
7806
+ cost: z.string().optional(),
7807
+ duration: z.string().optional(),
7808
+ }).optional(),
7809
+ }).default({}),
7810
+ });
7811
+ // Current state per agent — in-memory, not persisted
7812
+ const canvasStateMap = new Map();
7813
+ // POST /canvas/state — agent emits a state transition
7814
+ app.post('/canvas/state', async (request, reply) => {
7815
+ const result = CanvasRenderSchema.safeParse(request.body);
7816
+ if (!result.success) {
7817
+ reply.code(422);
7818
+ return {
7819
+ error: `Invalid canvas state: ${result.error.issues.map(i => i.message).join(', ')}`,
7820
+ hint: `state must be one of: ${CANVAS_STATES.join(', ')}`,
7821
+ validStates: CANVAS_STATES,
7822
+ };
7823
+ }
7824
+ const { state, sensors, agentId, payload } = result.data;
7825
+ const now = Date.now();
7826
+ // Store current state
7827
+ canvasStateMap.set(agentId, { state, sensors, payload, updatedAt: now });
7828
+ // Emit canvas_render event over SSE
7829
+ eventBus.emit({
7830
+ id: `crender-${now}-${Math.random().toString(36).slice(2, 8)}`,
7831
+ type: 'canvas_render',
7832
+ timestamp: now,
7833
+ data: { state, sensors, agentId, payload },
7834
+ });
7835
+ return { success: true, state, agentId, timestamp: now };
7836
+ });
7837
+ // GET /canvas/state — current state for all agents (or one)
7838
+ app.get('/canvas/state', async (request) => {
7839
+ const query = request.query;
7840
+ if (query.agentId) {
7841
+ const entry = canvasStateMap.get(query.agentId);
7842
+ return entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
7843
+ }
7844
+ const all = {};
7845
+ for (const [id, entry] of canvasStateMap) {
7846
+ all[id] = entry;
7847
+ }
7848
+ return { agents: all, count: canvasStateMap.size };
7849
+ });
7850
+ // GET /canvas/states — valid state + sensor values (discovery)
7851
+ app.get('/canvas/states', async () => ({
7852
+ states: CANVAS_STATES,
7853
+ sensors: SENSOR_VALUES,
7854
+ schema: {
7855
+ state: 'floor | listening | thinking | rendering | ambient | decision | urgent | handoff',
7856
+ sensors: 'null | mic | camera | mic+camera (non-dismissable trust indicator)',
7857
+ agentId: 'required — which agent is driving the canvas',
7858
+ payload: 'optional — text, media, decision, agents, summary',
7859
+ },
7860
+ }));
7701
7861
  // GET /canvas/slots — current active slots
7702
7862
  app.get('/canvas/slots', async () => {
7703
7863
  return {
@@ -9272,6 +9432,26 @@ export async function createServer() {
9272
9432
  const allDrops = chatManager.getDropStats();
9273
9433
  const agentDrops = allDrops[agent];
9274
9434
  const focusSummary = getFocusSummary();
9435
+ // Boot context: recent memories + active run (survives restart)
9436
+ let bootMemories = [];
9437
+ let activeRun = null;
9438
+ try {
9439
+ const { listMemories } = await import('./agent-memories.js');
9440
+ const memories = listMemories({ agentId: agent, limit: 5 });
9441
+ bootMemories = memories.map(m => ({
9442
+ key: m.key, content: m.content.slice(0, 200),
9443
+ namespace: m.namespace, updatedAt: m.updatedAt,
9444
+ }));
9445
+ }
9446
+ catch { /* agent-memories not available */ }
9447
+ try {
9448
+ const { getActiveAgentRun } = await import('./agent-runs.js');
9449
+ const run = getActiveAgentRun(agent, 'default');
9450
+ if (run) {
9451
+ activeRun = { id: run.id, objective: run.objective, status: run.status, startedAt: run.startedAt };
9452
+ }
9453
+ }
9454
+ catch { /* agent-runs not available */ }
9275
9455
  return {
9276
9456
  agent, ts: Date.now(),
9277
9457
  active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
@@ -9281,6 +9461,12 @@ export async function createServer() {
9281
9461
  ...(focusSummary ? { focus: focusSummary } : {}),
9282
9462
  ...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
9283
9463
  ...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
9464
+ ...(bootMemories.length > 0 ? { memories: bootMemories } : {}),
9465
+ ...(activeRun ? { run: activeRun } : {}),
9466
+ ...(() => {
9467
+ const p = presenceManager.getAllPresence().find(p => p.agent === agent);
9468
+ return p?.waiting ? { waiting: p.waiting } : {};
9469
+ })(),
9284
9470
  action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
9285
9471
  : activeTask ? `Continue ${activeTask.id}`
9286
9472
  : nextTask ? `Claim ${nextTask.id}`
@@ -9288,6 +9474,21 @@ export async function createServer() {
9288
9474
  : 'HEARTBEAT_OK',
9289
9475
  };
9290
9476
  });
9477
+ // ── Agent Waiting State ──────────────────────────────────────────────
9478
+ // Agents signal they're blocked on human input. Shows in heartbeat + presence.
9479
+ app.post('/agents/:agent/waiting', async (request, reply) => {
9480
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9481
+ const body = request.body ?? {};
9482
+ if (!body.reason)
9483
+ return reply.code(400).send({ error: 'reason is required' });
9484
+ presenceManager.setWaiting(agent, { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt });
9485
+ return { success: true, agent, status: 'waiting', waiting: { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt } };
9486
+ });
9487
+ app.delete('/agents/:agent/waiting', async (request) => {
9488
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9489
+ presenceManager.clearWaiting(agent);
9490
+ return { success: true, agent, status: 'idle' };
9491
+ });
9291
9492
  // ── Bootstrap: dynamic agent config generation ──────────────────────
9292
9493
  app.get('/bootstrap/heartbeat/:agent', async (request) => {
9293
9494
  const agent = String(request.params.agent || '').trim().toLowerCase();
@@ -9351,6 +9552,7 @@ If your heartbeat shows **no active task** and **no next task**:
9351
9552
  - Do not load full chat history.
9352
9553
  - Do not post plan-only updates.
9353
9554
  - If nothing changed and no direct action is required, reply \`HEARTBEAT_OK\`.
9555
+ - **Decision authority:** Team owns product/arch/process decisions. Escalate credentials, legal, and vision decisions to the admin/owner. See \`decision_authority\` block in \`defaults/TEAM-ROLES.yaml\` for the full list.
9354
9556
  `;
9355
9557
  // Stable hash for change detection (agents can cache and compare)
9356
9558
  const { createHash } = await import('node:crypto');
@@ -10455,17 +10657,12 @@ If your heartbeat shows **no active task** and **no next task**:
10455
10657
  return { success: false, error: 'agent and model are required' };
10456
10658
  }
10457
10659
  const event = recordUsage({
10458
- agent: body.agent,
10459
- task_id: body.task_id,
10660
+ agentId: body.agent,
10460
10661
  model: body.model,
10461
- provider: body.provider || 'unknown',
10462
- input_tokens: Number(body.input_tokens) || 0,
10463
- output_tokens: Number(body.output_tokens) || 0,
10464
- estimated_cost_usd: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : undefined,
10465
- category: body.category || 'other',
10662
+ inputTokens: Number(body.input_tokens) || 0,
10663
+ outputTokens: Number(body.output_tokens) || 0,
10664
+ cost: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : 0,
10466
10665
  timestamp: Number(body.timestamp) || Date.now(),
10467
- team_id: body.team_id,
10468
- metadata: body.metadata,
10469
10666
  });
10470
10667
  return { success: true, event };
10471
10668
  });
@@ -11027,6 +11224,21 @@ If your heartbeat shows **no active task** and **no next task**:
11027
11224
  });
11028
11225
  events.push(event);
11029
11226
  }
11227
+ // Post GitHub events to the 'github' chat channel with remapped mentions.
11228
+ // Pass enrichedBody (not body) so formatGitHubEvent has access to
11229
+ // _reflectt_attribution and can mention the correct agent (@link not @kai).
11230
+ if (provider === 'github') {
11231
+ const ghEventType = request.headers['x-github-event'] || eventType;
11232
+ const chatMessage = formatGitHubEvent(ghEventType, enrichedBody);
11233
+ if (chatMessage) {
11234
+ chatManager.sendMessage({
11235
+ from: 'github',
11236
+ content: chatMessage,
11237
+ channel: 'github',
11238
+ metadata: { source: 'github-webhook', eventType: ghEventType, delivery: request.headers['x-github-delivery'] },
11239
+ }).catch(() => { }); // non-blocking
11240
+ }
11241
+ }
11030
11242
  reply.code(202);
11031
11243
  return { success: true, accepted: events.length, events: events.map(e => ({ id: e.id, idempotencyKey: e.idempotencyKey, status: e.status })) };
11032
11244
  });
@@ -11989,6 +12201,1020 @@ If your heartbeat shows **no active task** and **no next task**:
11989
12201
  });
11990
12202
  // Start hourly auto-snapshot for alert-preflight daily metrics
11991
12203
  startAutoSnapshot();
12204
+ // ─── Browser capability routes ───────────────────────────────────────────────
12205
+ const browser = await import('./capabilities/browser.js');
12206
+ app.get('/browser/config', async () => {
12207
+ return browser.getBrowserConfig();
12208
+ });
12209
+ app.post('/browser/sessions', async (request, reply) => {
12210
+ try {
12211
+ const body = request.body;
12212
+ if (!body?.agent)
12213
+ return reply.code(400).send({ error: 'agent is required' });
12214
+ const session = await browser.createSession({
12215
+ agent: body.agent,
12216
+ url: body.url,
12217
+ headless: body.headless,
12218
+ viewport: body.viewport,
12219
+ });
12220
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12221
+ return reply.code(201).send(safe);
12222
+ }
12223
+ catch (err) {
12224
+ const status = err.message?.includes('Max concurrent') || err.message?.includes('exceeded max') ? 429 : 500;
12225
+ return reply.code(status).send({ error: err.message });
12226
+ }
12227
+ });
12228
+ app.get('/browser/sessions', async () => {
12229
+ return { sessions: browser.listSessions() };
12230
+ });
12231
+ app.get('/browser/sessions/:id', async (request, reply) => {
12232
+ const session = browser.getSession(request.params.id);
12233
+ if (!session)
12234
+ return reply.code(404).send({ error: 'Session not found' });
12235
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12236
+ return safe;
12237
+ });
12238
+ app.delete('/browser/sessions/:id', async (request, reply) => {
12239
+ await browser.closeSession(request.params.id);
12240
+ return { ok: true };
12241
+ });
12242
+ app.post('/browser/sessions/:id/act', async (request, reply) => {
12243
+ try {
12244
+ const body = request.body;
12245
+ if (!body?.instruction)
12246
+ return reply.code(400).send({ error: 'instruction is required' });
12247
+ const result = await browser.act(request.params.id, body.instruction);
12248
+ return result;
12249
+ }
12250
+ catch (err) {
12251
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12252
+ }
12253
+ });
12254
+ app.post('/browser/sessions/:id/extract', async (request, reply) => {
12255
+ try {
12256
+ const body = request.body;
12257
+ if (!body?.instruction)
12258
+ return reply.code(400).send({ error: 'instruction is required' });
12259
+ const result = await browser.extract(request.params.id, body.instruction, body.schema);
12260
+ return result;
12261
+ }
12262
+ catch (err) {
12263
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12264
+ }
12265
+ });
12266
+ app.post('/browser/sessions/:id/observe', async (request, reply) => {
12267
+ try {
12268
+ const body = request.body;
12269
+ if (!body?.instruction)
12270
+ return reply.code(400).send({ error: 'instruction is required' });
12271
+ const result = await browser.observe(request.params.id, body.instruction);
12272
+ return result;
12273
+ }
12274
+ catch (err) {
12275
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12276
+ }
12277
+ });
12278
+ app.post('/browser/sessions/:id/navigate', async (request, reply) => {
12279
+ try {
12280
+ const body = request.body;
12281
+ if (!body?.url)
12282
+ return reply.code(400).send({ error: 'url is required' });
12283
+ const result = await browser.navigate(request.params.id, body.url);
12284
+ return result;
12285
+ }
12286
+ catch (err) {
12287
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12288
+ }
12289
+ });
12290
+ app.get('/browser/sessions/:id/screenshot', async (request, reply) => {
12291
+ try {
12292
+ const result = await browser.screenshot(request.params.id);
12293
+ return result;
12294
+ }
12295
+ catch (err) {
12296
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12297
+ }
12298
+ });
12299
+ // ── Agent Runs & Events ──────────────────────────────────────────────────
12300
+ const { createAgentRun, updateAgentRun, getAgentRun, getActiveAgentRun, listAgentRuns, appendAgentEvent, listAgentEvents, VALID_RUN_STATUSES, } = await import('./agent-runs.js');
12301
+ // Create a new agent run
12302
+ app.post('/agents/:agentId/runs', async (request, reply) => {
12303
+ const { agentId } = request.params;
12304
+ const body = request.body;
12305
+ if (!body?.objective)
12306
+ return reply.code(400).send({ error: 'objective is required' });
12307
+ const teamId = body.teamId ?? 'default';
12308
+ try {
12309
+ const run = createAgentRun(agentId, teamId, body.objective, {
12310
+ taskId: body.taskId,
12311
+ parentRunId: body.parentRunId,
12312
+ });
12313
+ return reply.code(201).send(run);
12314
+ }
12315
+ catch (err) {
12316
+ return reply.code(500).send({ error: err.message });
12317
+ }
12318
+ });
12319
+ // Update an agent run (status, context, artifacts)
12320
+ app.patch('/agents/:agentId/runs/:runId', async (request, reply) => {
12321
+ const { runId } = request.params;
12322
+ const body = request.body;
12323
+ if (body?.status && !VALID_RUN_STATUSES.includes(body.status)) {
12324
+ return reply.code(400).send({ error: `Invalid status. Valid: ${VALID_RUN_STATUSES.join(', ')}` });
12325
+ }
12326
+ try {
12327
+ const run = updateAgentRun(runId, {
12328
+ status: body?.status,
12329
+ contextSnapshot: body?.contextSnapshot,
12330
+ artifacts: body?.artifacts,
12331
+ });
12332
+ if (!run)
12333
+ return reply.code(404).send({ error: 'Run not found' });
12334
+ return run;
12335
+ }
12336
+ catch (err) {
12337
+ return reply.code(500).send({ error: err.message });
12338
+ }
12339
+ });
12340
+ // List agent runs
12341
+ app.get('/agents/:agentId/runs', async (request, reply) => {
12342
+ const { agentId } = request.params;
12343
+ const query = request.query;
12344
+ const teamId = query.teamId ?? 'default';
12345
+ const limit = query.limit ? parseInt(query.limit, 10) : undefined;
12346
+ return listAgentRuns(agentId, teamId, { status: query.status, limit });
12347
+ });
12348
+ // Get active run for an agent
12349
+ app.get('/agents/:agentId/runs/current', async (request, reply) => {
12350
+ const { agentId } = request.params;
12351
+ const query = request.query;
12352
+ const teamId = query.teamId ?? 'default';
12353
+ const run = getActiveAgentRun(agentId, teamId);
12354
+ if (!run)
12355
+ return reply.code(404).send({ error: 'No active run' });
12356
+ return run;
12357
+ });
12358
+ // Append an event
12359
+ const { validateRoutingSemantics } = await import('./agent-runs.js');
12360
+ // GET /events/routing/validate — check if a payload passes routing semantics
12361
+ app.post('/events/routing/validate', async (request) => {
12362
+ const body = request.body;
12363
+ if (!body?.eventType)
12364
+ return { valid: false, errors: ['eventType is required'], warnings: [] };
12365
+ return validateRoutingSemantics(body.eventType, body.payload ?? {});
12366
+ });
12367
+ app.post('/agents/:agentId/events', async (request, reply) => {
12368
+ const { agentId } = request.params;
12369
+ const body = request.body;
12370
+ if (!body?.eventType)
12371
+ return reply.code(400).send({ error: 'eventType is required' });
12372
+ try {
12373
+ const event = appendAgentEvent({
12374
+ agentId,
12375
+ runId: body.runId,
12376
+ eventType: body.eventType,
12377
+ payload: body.payload,
12378
+ enforceRouting: body.enforceRouting,
12379
+ });
12380
+ return reply.code(201).send(event);
12381
+ }
12382
+ catch (err) {
12383
+ if (err.message.includes('Routing semantics violation')) {
12384
+ return reply.code(422).send({ error: err.message, hint: 'Actionable events require: action_required (string), urgency (low|normal|high|critical), owner (string). Optional: expires_at (number).' });
12385
+ }
12386
+ return reply.code(500).send({ error: err.message });
12387
+ const message = String(err?.message || err);
12388
+ if (message.includes('rationale')) {
12389
+ return reply.code(400).send({ error: message });
12390
+ }
12391
+ return reply.code(500).send({ error: message });
12392
+ }
12393
+ });
12394
+ // List agent events
12395
+ app.get('/agents/:agentId/events', async (request, reply) => {
12396
+ const { agentId } = request.params;
12397
+ const query = request.query;
12398
+ return listAgentEvents({
12399
+ agentId,
12400
+ runId: query.runId,
12401
+ eventType: query.type,
12402
+ since: query.since ? parseInt(query.since, 10) : undefined,
12403
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12404
+ });
12405
+ });
12406
+ // ── Run Event Stream (SSE) ─────────────────────────────────────────────
12407
+ // Real-time SSE stream for run events. Canvas subscribes here instead of polling.
12408
+ // GET /agents/:agentId/runs/:runId/stream — stream events for a specific run
12409
+ // GET /agents/:agentId/stream — stream all events for an agent
12410
+ app.get('/agents/:agentId/runs/:runId/stream', async (request, reply) => {
12411
+ const { agentId, runId } = request.params;
12412
+ const run = getAgentRun(runId);
12413
+ if (!run) {
12414
+ reply.code(404);
12415
+ return { error: 'Run not found' };
12416
+ }
12417
+ reply.raw.writeHead(200, {
12418
+ 'Content-Type': 'text/event-stream',
12419
+ 'Cache-Control': 'no-cache',
12420
+ 'Connection': 'keep-alive',
12421
+ 'X-Accel-Buffering': 'no',
12422
+ });
12423
+ // Send current run state as initial snapshot
12424
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
12425
+ // Subscribe to eventBus for this run's events
12426
+ const listenerId = `run-stream-${runId}-${Date.now()}`;
12427
+ let closed = false;
12428
+ eventBus.on(listenerId, (event) => {
12429
+ if (closed)
12430
+ return;
12431
+ const data = event.data;
12432
+ // Forward events that match this agent or run
12433
+ if (data && (data.runId === runId || data.agentId === agentId)) {
12434
+ try {
12435
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12436
+ }
12437
+ catch { /* connection closed */ }
12438
+ }
12439
+ });
12440
+ // Heartbeat
12441
+ const heartbeat = setInterval(() => {
12442
+ if (closed) {
12443
+ clearInterval(heartbeat);
12444
+ return;
12445
+ }
12446
+ try {
12447
+ reply.raw.write(`:heartbeat\n\n`);
12448
+ }
12449
+ catch {
12450
+ clearInterval(heartbeat);
12451
+ }
12452
+ }, 15_000);
12453
+ // Cleanup
12454
+ request.raw.on('close', () => {
12455
+ closed = true;
12456
+ eventBus.off(listenerId);
12457
+ clearInterval(heartbeat);
12458
+ });
12459
+ });
12460
+ // Stream all events for an agent
12461
+ app.get('/agents/:agentId/stream', async (request, reply) => {
12462
+ const { agentId } = request.params;
12463
+ reply.raw.writeHead(200, {
12464
+ 'Content-Type': 'text/event-stream',
12465
+ 'Cache-Control': 'no-cache',
12466
+ 'Connection': 'keep-alive',
12467
+ 'X-Accel-Buffering': 'no',
12468
+ });
12469
+ // Send recent events as snapshot
12470
+ const recentEvents = listAgentEvents({ agentId, limit: 20 });
12471
+ const activeRun = getActiveAgentRun(agentId, 'default');
12472
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ activeRun, events: recentEvents })}\n\n`);
12473
+ const listenerId = `agent-stream-${agentId}-${Date.now()}`;
12474
+ let closed = false;
12475
+ eventBus.on(listenerId, (event) => {
12476
+ if (closed)
12477
+ return;
12478
+ const data = event.data;
12479
+ if (data && data.agentId === agentId) {
12480
+ try {
12481
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12482
+ }
12483
+ catch { /* connection closed */ }
12484
+ }
12485
+ });
12486
+ const heartbeat = setInterval(() => {
12487
+ if (closed) {
12488
+ clearInterval(heartbeat);
12489
+ return;
12490
+ }
12491
+ try {
12492
+ reply.raw.write(`:heartbeat\n\n`);
12493
+ }
12494
+ catch {
12495
+ clearInterval(heartbeat);
12496
+ }
12497
+ }, 15_000);
12498
+ request.raw.on('close', () => {
12499
+ closed = true;
12500
+ eventBus.off(listenerId);
12501
+ clearInterval(heartbeat);
12502
+ });
12503
+ });
12504
+ // ── Run Stream (by run ID only) ──────────────────────────────────────
12505
+ // GET /runs/:runId/stream — SSE stream for a run without requiring agentId.
12506
+ // Cloud Presence surface subscribes here to show live run activity.
12507
+ app.get('/runs/:runId/stream', async (request, reply) => {
12508
+ const { runId } = request.params;
12509
+ const run = getAgentRun(runId);
12510
+ if (!run) {
12511
+ reply.code(404);
12512
+ return { error: 'Run not found' };
12513
+ }
12514
+ reply.raw.writeHead(200, {
12515
+ 'Content-Type': 'text/event-stream',
12516
+ 'Cache-Control': 'no-cache',
12517
+ 'Connection': 'keep-alive',
12518
+ 'X-Accel-Buffering': 'no',
12519
+ });
12520
+ // Initial snapshot: run state + recent events
12521
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
12522
+ const listenerId = `run-direct-stream-${runId}-${Date.now()}`;
12523
+ let closed = false;
12524
+ eventBus.on(listenerId, (event) => {
12525
+ if (closed)
12526
+ return;
12527
+ const data = event.data;
12528
+ if (data && (data.runId === runId || data.agentId === run.agentId)) {
12529
+ try {
12530
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12531
+ }
12532
+ catch { /* connection closed */ }
12533
+ }
12534
+ });
12535
+ const heartbeat = setInterval(() => {
12536
+ if (closed) {
12537
+ clearInterval(heartbeat);
12538
+ return;
12539
+ }
12540
+ try {
12541
+ reply.raw.write(`:heartbeat\n\n`);
12542
+ }
12543
+ catch {
12544
+ clearInterval(heartbeat);
12545
+ }
12546
+ }, 15_000);
12547
+ request.raw.on('close', () => {
12548
+ closed = true;
12549
+ eventBus.off(listenerId);
12550
+ clearInterval(heartbeat);
12551
+ });
12552
+ });
12553
+ // ── Workflow Templates ─────────────────────────────────────────────────
12554
+ const { listWorkflowTemplates, getWorkflowTemplate, runWorkflow } = await import('./workflow-templates.js');
12555
+ // GET /workflows — list available workflow templates
12556
+ app.get('/workflows', async () => ({ templates: listWorkflowTemplates() }));
12557
+ // GET /workflows/:id — get template details
12558
+ app.get('/workflows/:id', async (request, reply) => {
12559
+ const template = getWorkflowTemplate(request.params.id);
12560
+ if (!template) {
12561
+ reply.code(404);
12562
+ return { error: 'Template not found' };
12563
+ }
12564
+ return {
12565
+ id: template.id,
12566
+ name: template.name,
12567
+ description: template.description,
12568
+ steps: template.steps.map(s => ({ name: s.name, description: s.description })),
12569
+ };
12570
+ });
12571
+ // POST /workflows/:id/run — execute a workflow
12572
+ app.post('/workflows/:id/run', async (request, reply) => {
12573
+ const template = getWorkflowTemplate(request.params.id);
12574
+ if (!template) {
12575
+ reply.code(404);
12576
+ return { error: 'Template not found' };
12577
+ }
12578
+ const body = request.body ?? {};
12579
+ const agentId = body.agentId ?? 'link';
12580
+ const teamId = body.teamId ?? 'default';
12581
+ const result = await runWorkflow(template, agentId, teamId, body);
12582
+ return result;
12583
+ });
12584
+ // ── Agent Messaging (Host-native) ─────────────────────────────────────
12585
+ // Local agent-to-agent messaging. Replaces gateway for same-Host agents.
12586
+ const { sendAgentMessage, listAgentMessages, listSentMessages, markMessagesRead, getUnreadCount, listChannelMessages } = await import('./agent-messaging.js');
12587
+ // Send message
12588
+ app.post('/agents/:agentId/messages/send', async (request, reply) => {
12589
+ const { agentId } = request.params;
12590
+ const body = request.body;
12591
+ if (!body?.to)
12592
+ return reply.code(400).send({ error: 'to (recipient agent) is required' });
12593
+ if (!body?.content)
12594
+ return reply.code(400).send({ error: 'content is required' });
12595
+ const msg = sendAgentMessage({
12596
+ fromAgent: agentId,
12597
+ toAgent: body.to,
12598
+ channel: body.channel,
12599
+ content: body.content,
12600
+ metadata: body.metadata,
12601
+ });
12602
+ // Emit event for SSE subscribers
12603
+ eventBus.emit({
12604
+ id: `amsg-evt-${Date.now()}`,
12605
+ type: 'message_posted',
12606
+ timestamp: Date.now(),
12607
+ data: { messageId: msg.id, from: agentId, to: body.to, channel: msg.channel },
12608
+ });
12609
+ return reply.code(201).send(msg);
12610
+ });
12611
+ // Inbox
12612
+ app.get('/agents/:agentId/messages', async (request) => {
12613
+ const { agentId } = request.params;
12614
+ const query = request.query;
12615
+ return {
12616
+ messages: listAgentMessages({
12617
+ agentId,
12618
+ channel: query.channel,
12619
+ unreadOnly: query.unread === 'true',
12620
+ since: query.since ? parseInt(query.since, 10) : undefined,
12621
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12622
+ }),
12623
+ unreadCount: getUnreadCount(agentId),
12624
+ };
12625
+ });
12626
+ // Sent
12627
+ app.get('/agents/:agentId/messages/sent', async (request) => {
12628
+ const { agentId } = request.params;
12629
+ const query = request.query;
12630
+ return { messages: listSentMessages(agentId, query.limit ? parseInt(query.limit, 10) : undefined) };
12631
+ });
12632
+ // Mark read
12633
+ app.post('/agents/:agentId/messages/read', async (request) => {
12634
+ const { agentId } = request.params;
12635
+ const body = request.body ?? {};
12636
+ const marked = markMessagesRead(agentId, body.messageIds);
12637
+ return { marked };
12638
+ });
12639
+ // Channel messages
12640
+ app.get('/messages/channel/:channel', async (request) => {
12641
+ const { channel } = request.params;
12642
+ const query = request.query;
12643
+ return {
12644
+ messages: listChannelMessages(channel, {
12645
+ since: query.since ? parseInt(query.since, 10) : undefined,
12646
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12647
+ }),
12648
+ };
12649
+ });
12650
+ // ── Run Retention / Archive ────────────────────────────────────────────
12651
+ const { applyRunRetention, getRetentionStats } = await import('./agent-runs.js');
12652
+ // GET /runs/retention/stats — preview what retention policy would do
12653
+ app.get('/runs/retention/stats', async (request) => {
12654
+ const query = request.query;
12655
+ return getRetentionStats({
12656
+ maxAgeDays: query.maxAgeDays ? parseInt(query.maxAgeDays, 10) : undefined,
12657
+ maxCompletedRuns: query.maxCompletedRuns ? parseInt(query.maxCompletedRuns, 10) : undefined,
12658
+ });
12659
+ });
12660
+ // POST /runs/retention/apply — apply retention policy
12661
+ app.post('/runs/retention/apply', async (request) => {
12662
+ const body = request.body ?? {};
12663
+ return applyRunRetention({
12664
+ policy: {
12665
+ maxAgeDays: body.maxAgeDays,
12666
+ maxCompletedRuns: body.maxCompletedRuns,
12667
+ deleteArchived: body.deleteArchived,
12668
+ },
12669
+ agentId: body.agentId,
12670
+ dryRun: body.dryRun,
12671
+ });
12672
+ });
12673
+ // ── Artifact Store (Host-native) ──────────────────────────────────────
12674
+ const { storeArtifact, getArtifact, readArtifactContent, listArtifacts, deleteArtifact, getStorageUsage } = await import('./artifact-store.js');
12675
+ // Upload artifact
12676
+ app.post('/agents/:agentId/artifacts', async (request, reply) => {
12677
+ const { agentId } = request.params;
12678
+ const body = request.body;
12679
+ if (!body?.name)
12680
+ return reply.code(400).send({ error: 'name is required' });
12681
+ if (!body?.content)
12682
+ return reply.code(400).send({ error: 'content is required' });
12683
+ const contentBuf = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : Buffer.from(body.content);
12684
+ const art = storeArtifact({ agentId, name: body.name, content: contentBuf, mimeType: body.mimeType, runId: body.runId, taskId: body.taskId, metadata: body.metadata });
12685
+ return reply.code(201).send(art);
12686
+ });
12687
+ // List artifacts
12688
+ app.get('/agents/:agentId/artifacts', async (request) => {
12689
+ const { agentId } = request.params;
12690
+ const query = request.query;
12691
+ return {
12692
+ artifacts: listArtifacts({ agentId, runId: query.runId, taskId: query.taskId, limit: query.limit ? parseInt(query.limit, 10) : undefined }),
12693
+ usage: getStorageUsage(agentId),
12694
+ };
12695
+ });
12696
+ // Get artifact metadata
12697
+ app.get('/artifacts/:artifactId', async (request, reply) => {
12698
+ const { artifactId } = request.params;
12699
+ const art = getArtifact(artifactId);
12700
+ if (!art)
12701
+ return reply.code(404).send({ error: 'Artifact not found' });
12702
+ return art;
12703
+ });
12704
+ // Download artifact content
12705
+ app.get('/artifacts/:artifactId/content', async (request, reply) => {
12706
+ const { artifactId } = request.params;
12707
+ const content = readArtifactContent(artifactId);
12708
+ if (!content)
12709
+ return reply.code(404).send({ error: 'Artifact not found or file missing' });
12710
+ const art = getArtifact(artifactId);
12711
+ return reply.type(art.mimeType).send(content);
12712
+ });
12713
+ // Delete artifact
12714
+ app.delete('/artifacts/:artifactId', async (request, reply) => {
12715
+ const { artifactId } = request.params;
12716
+ const deleted = deleteArtifact(artifactId);
12717
+ if (!deleted)
12718
+ return reply.code(404).send({ error: 'Artifact not found' });
12719
+ return { deleted: true };
12720
+ });
12721
+ // Storage usage
12722
+ app.get('/agents/:agentId/storage', async (request) => {
12723
+ const { agentId } = request.params;
12724
+ return getStorageUsage(agentId);
12725
+ });
12726
+ // ── Webhook Storage ──────────────────────────────────────────────────
12727
+ const { storeWebhookPayload, getWebhookPayload, listWebhookPayloads, markPayloadProcessed, getUnprocessedCount, purgeOldPayloads } = await import('./webhook-storage.js');
12728
+ // Ingest webhook payload
12729
+ app.post('/webhooks/ingest', async (request, reply) => {
12730
+ const body = request.body;
12731
+ if (!body?.source)
12732
+ return reply.code(400).send({ error: 'source is required' });
12733
+ if (!body?.eventType)
12734
+ return reply.code(400).send({ error: 'eventType is required' });
12735
+ if (!body?.body)
12736
+ return reply.code(400).send({ error: 'body (payload) is required' });
12737
+ const headers = {};
12738
+ for (const [k, v] of Object.entries(request.headers)) {
12739
+ if (typeof v === 'string')
12740
+ headers[k] = v;
12741
+ }
12742
+ const payload = storeWebhookPayload({ source: body.source, eventType: body.eventType, agentId: body.agentId, body: body.body, headers });
12743
+ return reply.code(201).send(payload);
12744
+ });
12745
+ // List payloads
12746
+ app.get('/webhooks/payloads', async (request) => {
12747
+ const query = request.query;
12748
+ return {
12749
+ payloads: listWebhookPayloads({
12750
+ source: query.source,
12751
+ agentId: query.agentId,
12752
+ unprocessedOnly: query.unprocessed === 'true',
12753
+ since: query.since ? parseInt(query.since, 10) : undefined,
12754
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12755
+ }),
12756
+ unprocessedCount: getUnprocessedCount({ source: query.source, agentId: query.agentId }),
12757
+ };
12758
+ });
12759
+ // Get single payload
12760
+ app.get('/webhooks/payloads/:payloadId', async (request, reply) => {
12761
+ const { payloadId } = request.params;
12762
+ const payload = getWebhookPayload(payloadId);
12763
+ if (!payload)
12764
+ return reply.code(404).send({ error: 'Payload not found' });
12765
+ return payload;
12766
+ });
12767
+ // Mark processed
12768
+ app.post('/webhooks/payloads/:payloadId/process', async (request, reply) => {
12769
+ const { payloadId } = request.params;
12770
+ const marked = markPayloadProcessed(payloadId);
12771
+ if (!marked)
12772
+ return reply.code(404).send({ error: 'Payload not found or already processed' });
12773
+ return { processed: true };
12774
+ });
12775
+ // Purge old processed payloads
12776
+ app.post('/webhooks/purge', async (request) => {
12777
+ const body = request.body ?? {};
12778
+ const deleted = purgeOldPayloads(body.maxAgeDays ?? 30);
12779
+ return { deleted };
12780
+ });
12781
+ // ── Approval Routing ────────────────────────────────────────────────────
12782
+ const { listPendingApprovals, listApprovalQueue, submitApprovalDecision, } = await import('./agent-runs.js');
12783
+ // List pending approvals (review_requested events needing action)
12784
+ app.get('/approvals/pending', async (request) => {
12785
+ const query = request.query;
12786
+ return listPendingApprovals({
12787
+ agentId: query.agentId,
12788
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12789
+ });
12790
+ });
12791
+ // Dedicated approval queue — unified view of everything needing human decision.
12792
+ // Answers: what needs decision, who owns it, when it expires, what happens if ignored.
12793
+ app.get('/approval-queue', async (request) => {
12794
+ const query = request.query;
12795
+ const items = listApprovalQueue({
12796
+ agentId: query.agentId,
12797
+ category: query.category === 'review' || query.category === 'agent_action' ? query.category : undefined,
12798
+ includeExpired: query.includeExpired === 'true',
12799
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12800
+ });
12801
+ return {
12802
+ items,
12803
+ count: items.length,
12804
+ hasExpired: items.some(i => i.isExpired),
12805
+ };
12806
+ });
12807
+ // Submit agent-action approval (approve_requested events)
12808
+ app.post('/approval-queue/:approvalId/decide', async (request, reply) => {
12809
+ const { approvalId } = request.params;
12810
+ const body = request.body;
12811
+ if (!body?.decision || !['approve', 'reject', 'defer'].includes(body.decision)) {
12812
+ return reply.code(400).send({ error: 'decision must be "approve", "reject", or "defer"' });
12813
+ }
12814
+ if (!body?.actor) {
12815
+ return reply.code(400).send({ error: 'actor is required' });
12816
+ }
12817
+ try {
12818
+ // Auto-supply minimal rationale if omitted — humans approving via UI won't know to send it
12819
+ const rationale = body.rationale ?? {
12820
+ choice: `${body.decision === 'approve' ? 'Approved' : 'Rejected'} by ${body.actor}`,
12821
+ considered: ['approve', 'reject'],
12822
+ constraint: 'Human decision via approval queue',
12823
+ };
12824
+ const result = submitApprovalDecision({
12825
+ eventId: approvalId,
12826
+ decision: body.decision,
12827
+ reviewer: body.actor,
12828
+ comment: body.comment,
12829
+ rationale,
12830
+ });
12831
+ // Emit canvas_input event so Presence Layer updates
12832
+ eventBus.emit({
12833
+ id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12834
+ type: 'canvas_input',
12835
+ timestamp: Date.now(),
12836
+ data: {
12837
+ action: 'decision',
12838
+ approvalId,
12839
+ decision: body.decision,
12840
+ actor: body.actor,
12841
+ },
12842
+ });
12843
+ return result;
12844
+ }
12845
+ catch (err) {
12846
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12847
+ }
12848
+ });
12849
+ // Submit approval decision
12850
+ app.post('/approvals/:eventId/decide', async (request, reply) => {
12851
+ const { eventId } = request.params;
12852
+ const body = request.body;
12853
+ if (!body?.decision || !['approve', 'reject'].includes(body.decision)) {
12854
+ return reply.code(400).send({ error: 'decision must be "approve" or "reject"' });
12855
+ }
12856
+ if (!body?.reviewer) {
12857
+ return reply.code(400).send({ error: 'reviewer is required' });
12858
+ }
12859
+ try {
12860
+ const result = submitApprovalDecision({
12861
+ eventId,
12862
+ decision: body.decision,
12863
+ reviewer: body.reviewer,
12864
+ comment: body.comment,
12865
+ rationale: body.rationale,
12866
+ });
12867
+ return result;
12868
+ }
12869
+ catch (err) {
12870
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12871
+ }
12872
+ });
12873
+ // ── Canvas Input ──────────────────────────────────────────────────────
12874
+ // Human → agent control seam for the Presence Layer.
12875
+ // Payload is intentionally small per COO spec: action + target + actor.
12876
+ const CANVAS_INPUT_ACTIONS = ['decision', 'interrupt', 'pause', 'resume', 'mute', 'unmute'];
12877
+ const CanvasInputSchema = z.object({
12878
+ action: z.enum(CANVAS_INPUT_ACTIONS),
12879
+ targetRunId: z.string().optional(), // which run to act on
12880
+ decisionId: z.string().optional(), // for decision actions
12881
+ choice: z.enum(['approve', 'deny', 'defer']).optional(), // for decision actions
12882
+ actor: z.string().min(1), // who made this input
12883
+ comment: z.string().optional(), // optional rationale
12884
+ });
12885
+ app.post('/canvas/input', async (request, reply) => {
12886
+ const body = request.body;
12887
+ const result = CanvasInputSchema.safeParse(body);
12888
+ if (!result.success) {
12889
+ reply.code(422);
12890
+ return {
12891
+ error: `Invalid canvas input: ${result.error.issues.map(i => i.message).join(', ')}`,
12892
+ hint: 'Required: action (decision|interrupt|pause|resume|mute|unmute), actor. Optional: targetRunId, decisionId, choice, comment.',
12893
+ };
12894
+ }
12895
+ const input = result.data;
12896
+ const now = Date.now();
12897
+ // Route by action type
12898
+ if (input.action === 'decision') {
12899
+ if (!input.decisionId || !input.choice) {
12900
+ reply.code(422);
12901
+ return { error: 'Decision action requires decisionId and choice (approve|deny|defer)' };
12902
+ }
12903
+ // Emit canvas_input event for SSE subscribers
12904
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12905
+ action: input.action,
12906
+ decisionId: input.decisionId,
12907
+ choice: input.choice,
12908
+ actor: input.actor,
12909
+ comment: input.comment,
12910
+ timestamp: now,
12911
+ } });
12912
+ return {
12913
+ success: true,
12914
+ action: 'decision',
12915
+ decisionId: input.decisionId,
12916
+ choice: input.choice,
12917
+ actor: input.actor,
12918
+ timestamp: now,
12919
+ };
12920
+ }
12921
+ if (input.action === 'interrupt' || input.action === 'pause') {
12922
+ // Update active run if specified
12923
+ const runId = input.targetRunId;
12924
+ if (runId) {
12925
+ try {
12926
+ updateAgentRun(runId, {
12927
+ status: input.action === 'interrupt' ? 'cancelled' : 'blocked',
12928
+ });
12929
+ }
12930
+ catch { /* run may not exist — still emit event */ }
12931
+ }
12932
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12933
+ action: input.action,
12934
+ targetRunId: runId || null,
12935
+ actor: input.actor,
12936
+ timestamp: now,
12937
+ } });
12938
+ return {
12939
+ success: true,
12940
+ action: input.action,
12941
+ targetRunId: runId || null,
12942
+ actor: input.actor,
12943
+ timestamp: now,
12944
+ };
12945
+ }
12946
+ if (input.action === 'resume') {
12947
+ const runId = input.targetRunId;
12948
+ if (runId) {
12949
+ try {
12950
+ updateAgentRun(runId, { status: 'working' });
12951
+ }
12952
+ catch { /* run may not exist */ }
12953
+ }
12954
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12955
+ action: 'resume',
12956
+ targetRunId: runId || null,
12957
+ actor: input.actor,
12958
+ timestamp: now,
12959
+ } });
12960
+ return { success: true, action: 'resume', targetRunId: runId || null, actor: input.actor, timestamp: now };
12961
+ }
12962
+ // Mute/unmute — emit event only, no state change needed
12963
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12964
+ action: input.action,
12965
+ actor: input.actor,
12966
+ timestamp: now,
12967
+ } });
12968
+ return { success: true, action: input.action, actor: input.actor, timestamp: now };
12969
+ });
12970
+ // GET /canvas/input/schema — discovery endpoint
12971
+ app.get('/canvas/input/schema', async () => ({
12972
+ actions: CANVAS_INPUT_ACTIONS,
12973
+ schema: {
12974
+ action: 'decision | interrupt | pause | resume | mute | unmute',
12975
+ targetRunId: 'optional — which run to act on',
12976
+ decisionId: 'required for decision action — approval event ID',
12977
+ choice: 'required for decision — approve | deny | defer',
12978
+ actor: 'required — who made this input',
12979
+ comment: 'optional — rationale',
12980
+ },
12981
+ }));
12982
+ // ── Email / SMS relay ──────────────────────────────────────────────────
12983
+ async function cloudRelay(path, body, reply) {
12984
+ const cloudUrl = process.env.REFLECTT_CLOUD_URL;
12985
+ const hostToken = process.env.REFLECTT_HOST_TOKEN;
12986
+ if (!cloudUrl || !hostToken) {
12987
+ reply.code(503);
12988
+ return { error: 'Not connected to cloud. Configure REFLECTT_CLOUD_URL and REFLECTT_HOST_TOKEN.' };
12989
+ }
12990
+ try {
12991
+ const res = await fetch(`${cloudUrl}${path}`, {
12992
+ method: 'POST',
12993
+ headers: {
12994
+ 'Content-Type': 'application/json',
12995
+ Authorization: `Bearer ${hostToken}`,
12996
+ },
12997
+ body: JSON.stringify(body),
12998
+ });
12999
+ const data = await res.json().catch(() => ({}));
13000
+ if (!res.ok) {
13001
+ reply.code(res.status);
13002
+ return data;
13003
+ }
13004
+ return data;
13005
+ }
13006
+ catch (err) {
13007
+ reply.code(502);
13008
+ return { error: `Cloud relay failed: ${err.message}` };
13009
+ }
13010
+ }
13011
+ // Send email via cloud relay
13012
+ app.post('/email/send', async (request, reply) => {
13013
+ const body = request.body;
13014
+ const from = typeof body.from === 'string' ? body.from.trim() : '';
13015
+ const to = body.to;
13016
+ const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
13017
+ if (!from)
13018
+ return reply.code(400).send({ error: 'from is required' });
13019
+ if (!to)
13020
+ return reply.code(400).send({ error: 'to is required' });
13021
+ if (!subject)
13022
+ return reply.code(400).send({ error: 'subject is required' });
13023
+ if (!body.html && !body.text)
13024
+ return reply.code(400).send({ error: 'html or text body is required' });
13025
+ // Use host-relay endpoint — authenticates with host credential, uses host's own teamId server-side
13026
+ const hostId = process.env.REFLECTT_HOST_ID;
13027
+ const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
13028
+ return cloudRelay(relayPath, {
13029
+ from,
13030
+ to,
13031
+ subject,
13032
+ html: body.html,
13033
+ text: body.text,
13034
+ replyTo: body.replyTo,
13035
+ cc: body.cc,
13036
+ bcc: body.bcc,
13037
+ agent: body.agentId || body.agent || 'unknown',
13038
+ }, reply);
13039
+ });
13040
+ // Send SMS via cloud relay
13041
+ app.post('/sms/send', async (request, reply) => {
13042
+ const body = request.body;
13043
+ const to = typeof body.to === 'string' ? body.to.trim() : '';
13044
+ const msgBody = typeof body.body === 'string' ? body.body.trim() : '';
13045
+ if (!to)
13046
+ return reply.code(400).send({ error: 'to is required (phone number)' });
13047
+ if (!msgBody)
13048
+ return reply.code(400).send({ error: 'body is required' });
13049
+ const hostIdSms = process.env.REFLECTT_HOST_ID;
13050
+ const smsRelayPath = hostIdSms ? `/api/hosts/${encodeURIComponent(hostIdSms)}/relay/sms` : '/api/hosts/relay/sms';
13051
+ return cloudRelay(smsRelayPath, {
13052
+ to,
13053
+ body: msgBody,
13054
+ from: body.from,
13055
+ agent: body.agentId || body.agent || 'unknown',
13056
+ }, reply);
13057
+ });
13058
+ // ── Agent Config ──────────────────────────────────────────────────────
13059
+ // Per-agent model preference, cost cap, and settings.
13060
+ // This is the policy anchor for cost enforcement.
13061
+ const { getAgentConfig, listAgentConfigs, setAgentConfig, deleteAgentConfig, checkCostCap } = await import('./agent-config.js');
13062
+ // GET /agents/:agentId/config — get config for an agent
13063
+ app.get('/agents/:agentId/config', async (request) => {
13064
+ const config = getAgentConfig(request.params.agentId);
13065
+ return config ?? { agentId: request.params.agentId, configured: false };
13066
+ });
13067
+ // PUT /agents/:agentId/config — upsert config for an agent
13068
+ app.put('/agents/:agentId/config', async (request, reply) => {
13069
+ const body = request.body ?? {};
13070
+ try {
13071
+ const config = setAgentConfig(request.params.agentId, {
13072
+ teamId: typeof body.teamId === 'string' ? body.teamId : undefined,
13073
+ model: body.model !== undefined ? body.model : undefined,
13074
+ fallbackModel: body.fallbackModel !== undefined ? body.fallbackModel : undefined,
13075
+ costCapDaily: body.costCapDaily !== undefined ? body.costCapDaily : undefined,
13076
+ costCapMonthly: body.costCapMonthly !== undefined ? body.costCapMonthly : undefined,
13077
+ maxTokensPerCall: body.maxTokensPerCall !== undefined ? body.maxTokensPerCall : undefined,
13078
+ settings: body.settings !== undefined ? body.settings : undefined,
13079
+ });
13080
+ return config;
13081
+ }
13082
+ catch (err) {
13083
+ reply.code(400);
13084
+ return { error: err.message };
13085
+ }
13086
+ });
13087
+ // DELETE /agents/:agentId/config — remove config for an agent
13088
+ app.delete('/agents/:agentId/config', async (request, reply) => {
13089
+ const deleted = deleteAgentConfig(request.params.agentId);
13090
+ if (!deleted) {
13091
+ reply.code(404);
13092
+ return { error: 'Config not found' };
13093
+ }
13094
+ return { success: true };
13095
+ });
13096
+ // GET /agent-configs — list all agent configs
13097
+ app.get('/agent-configs', async (request) => {
13098
+ const query = request.query;
13099
+ return { configs: listAgentConfigs({ teamId: query.teamId }) };
13100
+ });
13101
+ // GET /agents/:agentId/cost-check — runtime cost enforcement check
13102
+ // Used by the runtime before making model calls.
13103
+ app.get('/agents/:agentId/cost-check', async (request) => {
13104
+ const query = request.query;
13105
+ const dailySpend = query.dailySpend ? parseFloat(query.dailySpend) : 0;
13106
+ const monthlySpend = query.monthlySpend ? parseFloat(query.monthlySpend) : 0;
13107
+ return checkCostCap(request.params.agentId, dailySpend, monthlySpend);
13108
+ });
13109
+ // ── Cost-Policy Enforcement Middleware ──────────────────────────────────
13110
+ const { enforcePolicy, recordUsage, getDailySpend, getMonthlySpend, purgeUsageLog, ensureUsageLogTable, } = await import('./cost-enforcement.js');
13111
+ ensureUsageLogTable();
13112
+ // POST /agents/:agentId/enforce-cost — runtime enforcement before model calls
13113
+ app.post('/agents/:agentId/enforce-cost', async (request, reply) => {
13114
+ const result = enforcePolicy(request.params.agentId);
13115
+ const status = result.action === 'deny' ? 403 : 200;
13116
+ return reply.code(status).send(result);
13117
+ });
13118
+ // GET /agents/:agentId/spend — current daily + monthly spend
13119
+ app.get('/agents/:agentId/spend', async (request) => {
13120
+ const { agentId } = request.params;
13121
+ return {
13122
+ agentId,
13123
+ dailySpend: getDailySpend(agentId),
13124
+ monthlySpend: getMonthlySpend(agentId),
13125
+ };
13126
+ });
13127
+ // POST /usage/record — record a usage event
13128
+ app.post('/usage/record', async (request, reply) => {
13129
+ const body = request.body;
13130
+ if (!body?.agentId)
13131
+ return reply.code(400).send({ error: 'agentId is required' });
13132
+ if (!body?.model)
13133
+ return reply.code(400).send({ error: 'model is required' });
13134
+ if (typeof body.cost !== 'number')
13135
+ return reply.code(400).send({ error: 'cost is required (number)' });
13136
+ recordUsage({
13137
+ agentId: body.agentId,
13138
+ model: body.model,
13139
+ inputTokens: body.inputTokens ?? 0,
13140
+ outputTokens: body.outputTokens ?? 0,
13141
+ cost: body.cost,
13142
+ timestamp: Date.now(),
13143
+ });
13144
+ return reply.code(201).send({ ok: true });
13145
+ });
13146
+ // POST /usage/purge — purge old usage records
13147
+ app.post('/usage/purge', async (request) => {
13148
+ const body = request.body;
13149
+ const deleted = purgeUsageLog(body?.maxAgeDays ?? 90);
13150
+ return { deleted };
13151
+ });
13152
+ // ── Agent Memories ─────────────────────────────────────────────────────
13153
+ const { setMemory, getMemory, listMemories, deleteMemory, deleteMemoryById, purgeExpiredMemories, countMemories, } = await import('./agent-memories.js');
13154
+ // Set (create or update) a memory
13155
+ app.put('/agents/:agentId/memories', async (request, reply) => {
13156
+ const { agentId } = request.params;
13157
+ const body = request.body;
13158
+ if (!body?.key)
13159
+ return reply.code(400).send({ error: 'key is required' });
13160
+ if (body.content === undefined || body.content === null)
13161
+ return reply.code(400).send({ error: 'content is required' });
13162
+ try {
13163
+ const memory = setMemory({
13164
+ agentId,
13165
+ namespace: body.namespace,
13166
+ key: body.key,
13167
+ content: body.content,
13168
+ tags: body.tags,
13169
+ expiresAt: body.expiresAt,
13170
+ });
13171
+ return reply.code(200).send(memory);
13172
+ }
13173
+ catch (err) {
13174
+ return reply.code(500).send({ error: err.message });
13175
+ }
13176
+ });
13177
+ // Get a specific memory by key
13178
+ app.get('/agents/:agentId/memories/:key', async (request, reply) => {
13179
+ const { agentId, key } = request.params;
13180
+ const query = request.query;
13181
+ const memory = getMemory(agentId, key, query.namespace);
13182
+ if (!memory)
13183
+ return reply.code(404).send({ error: 'Memory not found' });
13184
+ return memory;
13185
+ });
13186
+ // List memories for an agent
13187
+ app.get('/agents/:agentId/memories', async (request, reply) => {
13188
+ const { agentId } = request.params;
13189
+ const query = request.query;
13190
+ return listMemories({
13191
+ agentId,
13192
+ namespace: query.namespace,
13193
+ tag: query.tag,
13194
+ search: query.search,
13195
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
13196
+ });
13197
+ });
13198
+ // Delete a memory by key
13199
+ app.delete('/agents/:agentId/memories/:key', async (request, reply) => {
13200
+ const { agentId, key } = request.params;
13201
+ const query = request.query;
13202
+ const deleted = deleteMemory(agentId, key, query.namespace);
13203
+ if (!deleted)
13204
+ return reply.code(404).send({ error: 'Memory not found' });
13205
+ return { deleted: true };
13206
+ });
13207
+ // Count memories
13208
+ app.get('/agents/:agentId/memories/count', async (request, reply) => {
13209
+ const { agentId } = request.params;
13210
+ const query = request.query;
13211
+ return { count: countMemories(agentId, query.namespace) };
13212
+ });
13213
+ // Purge expired memories (housekeeping)
13214
+ app.post('/agents/memories/purge', async (_request, reply) => {
13215
+ const purged = purgeExpiredMemories();
13216
+ return { purged };
13217
+ });
11992
13218
  return app;
11993
13219
  }
11994
13220
  //# sourceMappingURL=server.js.map