reflectt-node 0.1.8 → 0.1.11

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 (163) 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 +21 -1
  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/db.d.ts.map +1 -1
  74. package/dist/db.js +131 -0
  75. package/dist/db.js.map +1 -1
  76. package/dist/e2e-loop-proof.test.d.ts +2 -0
  77. package/dist/e2e-loop-proof.test.d.ts.map +1 -0
  78. package/dist/e2e-loop-proof.test.js +104 -0
  79. package/dist/e2e-loop-proof.test.js.map +1 -0
  80. package/dist/email-sms-send.test.d.ts +2 -0
  81. package/dist/email-sms-send.test.d.ts.map +1 -0
  82. package/dist/email-sms-send.test.js +96 -0
  83. package/dist/email-sms-send.test.js.map +1 -0
  84. package/dist/events.d.ts +1 -1
  85. package/dist/events.d.ts.map +1 -1
  86. package/dist/events.js +2 -0
  87. package/dist/events.js.map +1 -1
  88. package/dist/fingerprint.d.ts.map +1 -1
  89. package/dist/fingerprint.js +5 -10
  90. package/dist/fingerprint.js.map +1 -1
  91. package/dist/github-webhook-chat.d.ts +75 -0
  92. package/dist/github-webhook-chat.d.ts.map +1 -0
  93. package/dist/github-webhook-chat.js +108 -0
  94. package/dist/github-webhook-chat.js.map +1 -0
  95. package/dist/handoff-state.test.d.ts +2 -0
  96. package/dist/handoff-state.test.d.ts.map +1 -0
  97. package/dist/handoff-state.test.js +102 -0
  98. package/dist/handoff-state.test.js.map +1 -0
  99. package/dist/health.d.ts +9 -0
  100. package/dist/health.d.ts.map +1 -1
  101. package/dist/health.js +18 -0
  102. package/dist/health.js.map +1 -1
  103. package/dist/host-error-correlation.d.ts +65 -0
  104. package/dist/host-error-correlation.d.ts.map +1 -0
  105. package/dist/host-error-correlation.js +123 -0
  106. package/dist/host-error-correlation.js.map +1 -0
  107. package/dist/index.js +39 -10
  108. package/dist/index.js.map +1 -1
  109. package/dist/notificationDedupeGuard.d.ts +4 -0
  110. package/dist/notificationDedupeGuard.d.ts.map +1 -1
  111. package/dist/notificationDedupeGuard.js +8 -4
  112. package/dist/notificationDedupeGuard.js.map +1 -1
  113. package/dist/presence.d.ts +37 -5
  114. package/dist/presence.d.ts.map +1 -1
  115. package/dist/presence.js +127 -16
  116. package/dist/presence.js.map +1 -1
  117. package/dist/review-sla.d.ts +9 -0
  118. package/dist/review-sla.d.ts.map +1 -0
  119. package/dist/review-sla.js +51 -0
  120. package/dist/review-sla.js.map +1 -0
  121. package/dist/routing-enforcement.test.d.ts +2 -0
  122. package/dist/routing-enforcement.test.d.ts.map +1 -0
  123. package/dist/routing-enforcement.test.js +86 -0
  124. package/dist/routing-enforcement.test.js.map +1 -0
  125. package/dist/run-retention.test.d.ts +2 -0
  126. package/dist/run-retention.test.d.ts.map +1 -0
  127. package/dist/run-retention.test.js +57 -0
  128. package/dist/run-retention.test.js.map +1 -0
  129. package/dist/run-stream.test.d.ts +2 -0
  130. package/dist/run-stream.test.d.ts.map +1 -0
  131. package/dist/run-stream.test.js +70 -0
  132. package/dist/run-stream.test.js.map +1 -0
  133. package/dist/server.d.ts.map +1 -1
  134. package/dist/server.js +1229 -75
  135. package/dist/server.js.map +1 -1
  136. package/dist/tasks.d.ts.map +1 -1
  137. package/dist/tasks.js +45 -0
  138. package/dist/tasks.js.map +1 -1
  139. package/dist/todoHoardingGuard.d.ts +17 -0
  140. package/dist/todoHoardingGuard.d.ts.map +1 -1
  141. package/dist/todoHoardingGuard.js +25 -2
  142. package/dist/todoHoardingGuard.js.map +1 -1
  143. package/dist/webhook-storage.d.ts +50 -0
  144. package/dist/webhook-storage.d.ts.map +1 -0
  145. package/dist/webhook-storage.js +102 -0
  146. package/dist/webhook-storage.js.map +1 -0
  147. package/dist/webhook-storage.test.d.ts +2 -0
  148. package/dist/webhook-storage.test.d.ts.map +1 -0
  149. package/dist/webhook-storage.test.js +86 -0
  150. package/dist/webhook-storage.test.js.map +1 -0
  151. package/dist/workflow-templates.d.ts +44 -0
  152. package/dist/workflow-templates.d.ts.map +1 -0
  153. package/dist/workflow-templates.js +154 -0
  154. package/dist/workflow-templates.js.map +1 -0
  155. package/dist/workflow-templates.test.d.ts +2 -0
  156. package/dist/workflow-templates.test.d.ts.map +1 -0
  157. package/dist/workflow-templates.test.js +76 -0
  158. package/dist/workflow-templates.test.js.map +1 -0
  159. package/package.json +3 -1
  160. package/public/dashboard.js +11 -0
  161. package/public/design-tokens-platform.md +118 -0
  162. package/public/design-tokens.css +195 -0
  163. package/public/docs.md +127 -2
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(),
@@ -4506,6 +4514,46 @@ export async function createServer() {
4506
4514
  matchType: resolved.matchType,
4507
4515
  };
4508
4516
  });
4517
+ // ── Task handoff state ─────────────────────────────────────────────
4518
+ app.get('/tasks/:id/handoff', async (request, reply) => {
4519
+ const resolved = taskManager.resolveTaskId(request.params.id);
4520
+ if (!resolved.task) {
4521
+ reply.code(404);
4522
+ return { error: 'Task not found' };
4523
+ }
4524
+ const meta = resolved.task.metadata;
4525
+ const handoff = meta?.handoff_state ?? null;
4526
+ return {
4527
+ taskId: resolved.resolvedId,
4528
+ status: resolved.task.status,
4529
+ handoff_state: handoff,
4530
+ };
4531
+ });
4532
+ app.put('/tasks/:id/handoff', async (request, reply) => {
4533
+ const resolved = taskManager.resolveTaskId(request.params.id);
4534
+ if (!resolved.task || !resolved.resolvedId) {
4535
+ reply.code(404);
4536
+ return { error: 'Task not found' };
4537
+ }
4538
+ const body = request.body;
4539
+ const result = HandoffStateSchema.safeParse(body);
4540
+ if (!result.success) {
4541
+ reply.code(422);
4542
+ return {
4543
+ error: `Invalid handoff_state: ${result.error.issues.map(i => i.message).join(', ')}`,
4544
+ hint: 'Required: reviewed_by (string), decision (approved|rejected|needs_changes|escalated). Optional: next_owner (string).',
4545
+ };
4546
+ }
4547
+ const existingMeta = (resolved.task.metadata || {});
4548
+ taskManager.updateTask(resolved.resolvedId, {
4549
+ metadata: { ...existingMeta, handoff_state: result.data },
4550
+ });
4551
+ return {
4552
+ success: true,
4553
+ taskId: resolved.resolvedId,
4554
+ handoff_state: result.data,
4555
+ };
4556
+ });
4509
4557
  // Task artifact visibility — resolves artifact paths and checks accessibility
4510
4558
  app.get('/tasks/:id/artifacts', async (request, reply) => {
4511
4559
  const resolved = resolveTaskFromParam(request.params.id, reply);
@@ -5672,7 +5720,9 @@ export async function createServer() {
5672
5720
  || data.metadata?.skip_dedup === true
5673
5721
  || data.metadata?.is_test === true;
5674
5722
  if (!skipDedup && data.assignee) {
5675
- const TASK_DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
5723
+ // 60-second window targets gateway reconnect double-fire (typical gap: <10s)
5724
+ // without blocking legitimate same-title task creation later in the day.
5725
+ const TASK_DEDUP_WINDOW_MS = 60_000; // 60 seconds
5676
5726
  const cutoff = Date.now() - TASK_DEDUP_WINDOW_MS;
5677
5727
  const normalizedTitle = data.title.trim().toLowerCase();
5678
5728
  const activeTasks = taskManager.listTasks({ includeTest: true }).filter(t => t.status !== 'done'
@@ -5680,16 +5730,15 @@ export async function createServer() {
5680
5730
  && t.createdAt >= cutoff
5681
5731
  && t.title.trim().toLowerCase() === normalizedTitle);
5682
5732
  if (activeTasks.length > 0) {
5733
+ // Return 200 with the existing task (collapse, not reject).
5734
+ // Agents that create-on-reconnect receive a success response identical
5735
+ // to what they'd get from a new creation — no retry loop triggered.
5683
5736
  const existing = activeTasks[0];
5684
- reply.code(409);
5685
5737
  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.`,
5738
+ success: true,
5739
+ task: existing,
5740
+ deduplicated: true,
5741
+ 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
5742
  };
5694
5743
  }
5695
5744
  }
@@ -6082,6 +6131,14 @@ export async function createServer() {
6082
6131
  const pruned = boardHealthWorker.pruneAuditLog(maxAgeDays);
6083
6132
  return { success: true, pruned };
6084
6133
  });
6134
+ app.post('/board-health/quiet-window', async () => {
6135
+ boardHealthWorker.resetQuietWindow();
6136
+ return {
6137
+ success: true,
6138
+ quietUntil: Date.now() + (boardHealthWorker.getStatus().config?.restartQuietWindowMs ?? 300_000),
6139
+ message: 'Quiet window reset — ready-queue alerts suppressed for restart window',
6140
+ };
6141
+ });
6085
6142
  // ── Agent change feed ─────────────────────────────────────────────────
6086
6143
  app.get('/feed/:agent', async (request) => {
6087
6144
  const { agent } = request.params;
@@ -6366,6 +6423,22 @@ export async function createServer() {
6366
6423
  mergedMeta.reopened_from = existing.status;
6367
6424
  }
6368
6425
  }
6426
+ // ── Handoff state validation ──
6427
+ if (mergedMeta.handoff_state && typeof mergedMeta.handoff_state === 'object') {
6428
+ const handoffResult = HandoffStateSchema.safeParse(mergedMeta.handoff_state);
6429
+ if (!handoffResult.success) {
6430
+ reply.code(422);
6431
+ return {
6432
+ success: false,
6433
+ error: `Invalid handoff_state: ${handoffResult.error.issues.map(i => i.message).join(', ')}`,
6434
+ code: 'INVALID_HANDOFF_STATE',
6435
+ hint: 'handoff_state must have: reviewed_by (string), decision (approved|rejected|needs_changes|escalated), optional next_owner (string). Max 3 fields per COO rule.',
6436
+ gate: 'handoff_state',
6437
+ };
6438
+ }
6439
+ // Stamp validated handoff
6440
+ mergedMeta.handoff_state = handoffResult.data;
6441
+ }
6369
6442
  // ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
6370
6443
  if (parsed.status === 'cancelled') {
6371
6444
  const meta = (incomingMeta ?? {});
@@ -6818,20 +6891,24 @@ export async function createServer() {
6818
6891
  if (task.assignee) {
6819
6892
  if (parsed.status === 'done') {
6820
6893
  presenceManager.recordActivity(task.assignee, 'task_completed');
6821
- presenceManager.updatePresence(task.assignee, 'working');
6894
+ presenceManager.updatePresence(task.assignee, 'working', null);
6822
6895
  trackTaskEvent('completed');
6823
6896
  }
6824
6897
  else if (parsed.status === 'doing') {
6825
- presenceManager.updatePresence(task.assignee, 'working');
6898
+ presenceManager.updatePresence(task.assignee, 'working', task.id);
6826
6899
  }
6827
6900
  else if (parsed.status === 'blocked') {
6828
- presenceManager.updatePresence(task.assignee, 'blocked');
6901
+ presenceManager.updatePresence(task.assignee, 'blocked', task.id);
6829
6902
  }
6830
6903
  else if (parsed.status === 'validating') {
6831
- presenceManager.updatePresence(task.assignee, 'reviewing');
6904
+ presenceManager.updatePresence(task.assignee, 'reviewing', task.id);
6832
6905
  }
6833
6906
  }
6834
6907
  // ── Reviewer notification: @mention reviewer when task enters validating ──
6908
+ // NOTE: A dedup_key is set here so the inline chat dedup guard suppresses
6909
+ // any duplicate reviewRequested send that may arrive via the statusNotifTargets
6910
+ // loop below for the same task+transition. Without it, two messages fire for
6911
+ // every todo→validating transition (this direct send + the loop send).
6835
6912
  if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
6836
6913
  const taskMeta = task.metadata;
6837
6914
  const prUrl = taskMeta?.review_handoff?.pr_url
@@ -6848,6 +6925,7 @@ export async function createServer() {
6848
6925
  taskId: task.id,
6849
6926
  reviewer: existing.reviewer,
6850
6927
  prUrl: prUrl || undefined,
6928
+ dedup_key: `review-requested:${task.id}:${task.updatedAt}`,
6851
6929
  },
6852
6930
  }).catch(() => { }); // Non-blocking
6853
6931
  }
@@ -6971,13 +7049,17 @@ export async function createServer() {
6971
7049
  // Dedupe guard: prevent stale/out-of-order notification events
6972
7050
  const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
6973
7051
  for (const target of statusNotifTargets) {
6974
- // Check dedupe guard before emitting
7052
+ // Check dedupe guard before emitting.
7053
+ // Pass targetAgent so each recipient gets an independent cursor — prevents
7054
+ // the first recipient's cursor update from suppressing later recipients for
7055
+ // the same event (e.g. assignee + reviewer both getting taskCompleted on 'done').
6975
7056
  const dedupeCheck = shouldEmitNotification({
6976
7057
  taskId: task.id,
6977
7058
  eventUpdatedAt: task.updatedAt,
6978
7059
  eventStatus: parsed.status,
6979
7060
  currentTaskStatus: task.status,
6980
7061
  currentTaskUpdatedAt: task.updatedAt,
7062
+ targetAgent: target.agent,
6981
7063
  });
6982
7064
  if (!dedupeCheck.emit) {
6983
7065
  console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
@@ -6990,7 +7072,13 @@ export async function createServer() {
6990
7072
  message: `Task ${task.id} → ${parsed.status}`,
6991
7073
  });
6992
7074
  if (routing.shouldNotify) {
6993
- // Route through inbox/chat based on delivery method preference
7075
+ // Route through inbox/chat based on delivery method preference.
7076
+ // For reviewRequested, set a dedup_key matching the direct send above so the
7077
+ // inline chat dedup suppresses this copy (the direct send fires first with a
7078
+ // richer payload including PR URL and `to:` routing).
7079
+ const dedupKey = target.type === 'reviewRequested'
7080
+ ? `review-requested:${task.id}:${task.updatedAt}`
7081
+ : undefined;
6994
7082
  chatManager.sendMessage({
6995
7083
  from: 'system',
6996
7084
  content: `@${target.agent} [${target.type}:${task.id}] ${task.title} → ${parsed.status}`,
@@ -7001,6 +7089,7 @@ export async function createServer() {
7001
7089
  status: parsed.status,
7002
7090
  updatedAt: task.updatedAt,
7003
7091
  deliveryMethod: routing.deliveryMethod,
7092
+ ...(dedupKey ? { dedup_key: dedupKey } : {}),
7004
7093
  },
7005
7094
  }).catch(() => { }); // Non-blocking
7006
7095
  }
@@ -7071,6 +7160,27 @@ export async function createServer() {
7071
7160
  };
7072
7161
  app.get('/agents', async () => buildRoleRegistryPayload());
7073
7162
  app.get('/agents/roles', async () => buildRoleRegistryPayload());
7163
+ // Host-native identity resolution — resolves agent by name, alias, or display name
7164
+ // without requiring the OpenClaw gateway. Merges YAML roles + agent_config table.
7165
+ app.get('/agents/:name/identity', async (request) => {
7166
+ const { name } = request.params;
7167
+ const resolved = resolveAgentMention(name);
7168
+ const role = resolved ? getAgentRole(resolved) : getAgentRole(name);
7169
+ if (!role) {
7170
+ return { found: false, query: name, hint: 'Agent not found in YAML roles or config' };
7171
+ }
7172
+ return {
7173
+ found: true,
7174
+ agentId: role.name,
7175
+ displayName: role.displayName ?? role.name,
7176
+ role: role.role,
7177
+ description: role.description ?? null,
7178
+ aliases: role.aliases ?? [],
7179
+ affinityTags: role.affinityTags ?? [],
7180
+ wipCap: role.wipCap,
7181
+ source: 'yaml',
7182
+ };
7183
+ });
7074
7184
  // Team-scoped alias for assignment-engine consumers
7075
7185
  app.get('/team/roles', async () => {
7076
7186
  const payload = buildRoleRegistryPayload();
@@ -7494,55 +7604,7 @@ export async function createServer() {
7494
7604
  };
7495
7605
  });
7496
7606
  // ── 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
- });
7607
+ // Note: GET /approval-queue is defined below near /approval-queue/:approvalId/decide
7546
7608
  app.post('/approval-queue/:taskId/approve', async (request, reply) => {
7547
7609
  const body = request.body;
7548
7610
  const taskId = request.params.taskId;
@@ -7698,6 +7760,88 @@ export async function createServer() {
7698
7760
  warnings: result.warnings,
7699
7761
  };
7700
7762
  });
7763
+ // ── Presence Layer canvas state ─────────────────────────────────────
7764
+ // Agent emits canvas_render state transitions for the Presence Layer.
7765
+ // Deterministic event types. No "AI can emit anything" protocol.
7766
+ const CANVAS_STATES = ['floor', 'listening', 'thinking', 'rendering', 'ambient', 'decision', 'urgent', 'handoff'];
7767
+ const SENSOR_VALUES = [null, 'mic', 'camera', 'mic+camera'];
7768
+ const CanvasRenderSchema = z.object({
7769
+ state: z.enum(CANVAS_STATES),
7770
+ sensors: z.enum(['mic', 'camera', 'mic+camera']).nullable().default(null),
7771
+ agentId: z.string().min(1),
7772
+ payload: z.object({
7773
+ text: z.string().optional(),
7774
+ media: z.unknown().optional(),
7775
+ decision: z.object({
7776
+ question: z.string(),
7777
+ context: z.string().optional(),
7778
+ decisionId: z.string(),
7779
+ expiresAt: z.number().optional(),
7780
+ autoAction: z.string().optional(),
7781
+ }).optional(),
7782
+ agents: z.array(z.object({
7783
+ name: z.string(),
7784
+ state: z.string(),
7785
+ task: z.string().optional(),
7786
+ })).optional(),
7787
+ summary: z.object({
7788
+ headline: z.string(),
7789
+ items: z.array(z.string()).optional(),
7790
+ cost: z.string().optional(),
7791
+ duration: z.string().optional(),
7792
+ }).optional(),
7793
+ }).default({}),
7794
+ });
7795
+ // Current state per agent — in-memory, not persisted
7796
+ const canvasStateMap = new Map();
7797
+ // POST /canvas/state — agent emits a state transition
7798
+ app.post('/canvas/state', async (request, reply) => {
7799
+ const result = CanvasRenderSchema.safeParse(request.body);
7800
+ if (!result.success) {
7801
+ reply.code(422);
7802
+ return {
7803
+ error: `Invalid canvas state: ${result.error.issues.map(i => i.message).join(', ')}`,
7804
+ hint: `state must be one of: ${CANVAS_STATES.join(', ')}`,
7805
+ validStates: CANVAS_STATES,
7806
+ };
7807
+ }
7808
+ const { state, sensors, agentId, payload } = result.data;
7809
+ const now = Date.now();
7810
+ // Store current state
7811
+ canvasStateMap.set(agentId, { state, sensors, payload, updatedAt: now });
7812
+ // Emit canvas_render event over SSE
7813
+ eventBus.emit({
7814
+ id: `crender-${now}-${Math.random().toString(36).slice(2, 8)}`,
7815
+ type: 'canvas_render',
7816
+ timestamp: now,
7817
+ data: { state, sensors, agentId, payload },
7818
+ });
7819
+ return { success: true, state, agentId, timestamp: now };
7820
+ });
7821
+ // GET /canvas/state — current state for all agents (or one)
7822
+ app.get('/canvas/state', async (request) => {
7823
+ const query = request.query;
7824
+ if (query.agentId) {
7825
+ const entry = canvasStateMap.get(query.agentId);
7826
+ return entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
7827
+ }
7828
+ const all = {};
7829
+ for (const [id, entry] of canvasStateMap) {
7830
+ all[id] = entry;
7831
+ }
7832
+ return { agents: all, count: canvasStateMap.size };
7833
+ });
7834
+ // GET /canvas/states — valid state + sensor values (discovery)
7835
+ app.get('/canvas/states', async () => ({
7836
+ states: CANVAS_STATES,
7837
+ sensors: SENSOR_VALUES,
7838
+ schema: {
7839
+ state: 'floor | listening | thinking | rendering | ambient | decision | urgent | handoff',
7840
+ sensors: 'null | mic | camera | mic+camera (non-dismissable trust indicator)',
7841
+ agentId: 'required — which agent is driving the canvas',
7842
+ payload: 'optional — text, media, decision, agents, summary',
7843
+ },
7844
+ }));
7701
7845
  // GET /canvas/slots — current active slots
7702
7846
  app.get('/canvas/slots', async () => {
7703
7847
  return {
@@ -9272,6 +9416,26 @@ export async function createServer() {
9272
9416
  const allDrops = chatManager.getDropStats();
9273
9417
  const agentDrops = allDrops[agent];
9274
9418
  const focusSummary = getFocusSummary();
9419
+ // Boot context: recent memories + active run (survives restart)
9420
+ let bootMemories = [];
9421
+ let activeRun = null;
9422
+ try {
9423
+ const { listMemories } = await import('./agent-memories.js');
9424
+ const memories = listMemories({ agentId: agent, limit: 5 });
9425
+ bootMemories = memories.map(m => ({
9426
+ key: m.key, content: m.content.slice(0, 200),
9427
+ namespace: m.namespace, updatedAt: m.updatedAt,
9428
+ }));
9429
+ }
9430
+ catch { /* agent-memories not available */ }
9431
+ try {
9432
+ const { getActiveAgentRun } = await import('./agent-runs.js');
9433
+ const run = getActiveAgentRun(agent, 'default');
9434
+ if (run) {
9435
+ activeRun = { id: run.id, objective: run.objective, status: run.status, startedAt: run.startedAt };
9436
+ }
9437
+ }
9438
+ catch { /* agent-runs not available */ }
9275
9439
  return {
9276
9440
  agent, ts: Date.now(),
9277
9441
  active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
@@ -9281,6 +9445,12 @@ export async function createServer() {
9281
9445
  ...(focusSummary ? { focus: focusSummary } : {}),
9282
9446
  ...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
9283
9447
  ...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
9448
+ ...(bootMemories.length > 0 ? { memories: bootMemories } : {}),
9449
+ ...(activeRun ? { run: activeRun } : {}),
9450
+ ...(() => {
9451
+ const p = presenceManager.getAllPresence().find(p => p.agent === agent);
9452
+ return p?.waiting ? { waiting: p.waiting } : {};
9453
+ })(),
9284
9454
  action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
9285
9455
  : activeTask ? `Continue ${activeTask.id}`
9286
9456
  : nextTask ? `Claim ${nextTask.id}`
@@ -9288,6 +9458,21 @@ export async function createServer() {
9288
9458
  : 'HEARTBEAT_OK',
9289
9459
  };
9290
9460
  });
9461
+ // ── Agent Waiting State ──────────────────────────────────────────────
9462
+ // Agents signal they're blocked on human input. Shows in heartbeat + presence.
9463
+ app.post('/agents/:agent/waiting', async (request, reply) => {
9464
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9465
+ const body = request.body ?? {};
9466
+ if (!body.reason)
9467
+ return reply.code(400).send({ error: 'reason is required' });
9468
+ presenceManager.setWaiting(agent, { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt });
9469
+ return { success: true, agent, status: 'waiting', waiting: { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt } };
9470
+ });
9471
+ app.delete('/agents/:agent/waiting', async (request) => {
9472
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9473
+ presenceManager.clearWaiting(agent);
9474
+ return { success: true, agent, status: 'idle' };
9475
+ });
9291
9476
  // ── Bootstrap: dynamic agent config generation ──────────────────────
9292
9477
  app.get('/bootstrap/heartbeat/:agent', async (request) => {
9293
9478
  const agent = String(request.params.agent || '').trim().toLowerCase();
@@ -9351,6 +9536,7 @@ If your heartbeat shows **no active task** and **no next task**:
9351
9536
  - Do not load full chat history.
9352
9537
  - Do not post plan-only updates.
9353
9538
  - If nothing changed and no direct action is required, reply \`HEARTBEAT_OK\`.
9539
+ - **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
9540
  `;
9355
9541
  // Stable hash for change detection (agents can cache and compare)
9356
9542
  const { createHash } = await import('node:crypto');
@@ -10455,17 +10641,12 @@ If your heartbeat shows **no active task** and **no next task**:
10455
10641
  return { success: false, error: 'agent and model are required' };
10456
10642
  }
10457
10643
  const event = recordUsage({
10458
- agent: body.agent,
10459
- task_id: body.task_id,
10644
+ agentId: body.agent,
10460
10645
  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',
10646
+ inputTokens: Number(body.input_tokens) || 0,
10647
+ outputTokens: Number(body.output_tokens) || 0,
10648
+ cost: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : 0,
10466
10649
  timestamp: Number(body.timestamp) || Date.now(),
10467
- team_id: body.team_id,
10468
- metadata: body.metadata,
10469
10650
  });
10470
10651
  return { success: true, event };
10471
10652
  });
@@ -11027,6 +11208,21 @@ If your heartbeat shows **no active task** and **no next task**:
11027
11208
  });
11028
11209
  events.push(event);
11029
11210
  }
11211
+ // Post GitHub events to the 'github' chat channel with remapped mentions.
11212
+ // Pass enrichedBody (not body) so formatGitHubEvent has access to
11213
+ // _reflectt_attribution and can mention the correct agent (@link not @kai).
11214
+ if (provider === 'github') {
11215
+ const ghEventType = request.headers['x-github-event'] || eventType;
11216
+ const chatMessage = formatGitHubEvent(ghEventType, enrichedBody);
11217
+ if (chatMessage) {
11218
+ chatManager.sendMessage({
11219
+ from: 'github',
11220
+ content: chatMessage,
11221
+ channel: 'github',
11222
+ metadata: { source: 'github-webhook', eventType: ghEventType, delivery: request.headers['x-github-delivery'] },
11223
+ }).catch(() => { }); // non-blocking
11224
+ }
11225
+ }
11030
11226
  reply.code(202);
11031
11227
  return { success: true, accepted: events.length, events: events.map(e => ({ id: e.id, idempotencyKey: e.idempotencyKey, status: e.status })) };
11032
11228
  });
@@ -11989,6 +12185,964 @@ If your heartbeat shows **no active task** and **no next task**:
11989
12185
  });
11990
12186
  // Start hourly auto-snapshot for alert-preflight daily metrics
11991
12187
  startAutoSnapshot();
12188
+ // ─── Browser capability routes ───────────────────────────────────────────────
12189
+ const browser = await import('./capabilities/browser.js');
12190
+ app.get('/browser/config', async () => {
12191
+ return browser.getBrowserConfig();
12192
+ });
12193
+ app.post('/browser/sessions', async (request, reply) => {
12194
+ try {
12195
+ const body = request.body;
12196
+ if (!body?.agent)
12197
+ return reply.code(400).send({ error: 'agent is required' });
12198
+ const session = await browser.createSession({
12199
+ agent: body.agent,
12200
+ url: body.url,
12201
+ headless: body.headless,
12202
+ viewport: body.viewport,
12203
+ });
12204
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12205
+ return reply.code(201).send(safe);
12206
+ }
12207
+ catch (err) {
12208
+ const status = err.message?.includes('Max concurrent') || err.message?.includes('exceeded max') ? 429 : 500;
12209
+ return reply.code(status).send({ error: err.message });
12210
+ }
12211
+ });
12212
+ app.get('/browser/sessions', async () => {
12213
+ return { sessions: browser.listSessions() };
12214
+ });
12215
+ app.get('/browser/sessions/:id', async (request, reply) => {
12216
+ const session = browser.getSession(request.params.id);
12217
+ if (!session)
12218
+ return reply.code(404).send({ error: 'Session not found' });
12219
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12220
+ return safe;
12221
+ });
12222
+ app.delete('/browser/sessions/:id', async (request, reply) => {
12223
+ await browser.closeSession(request.params.id);
12224
+ return { ok: true };
12225
+ });
12226
+ app.post('/browser/sessions/:id/act', async (request, reply) => {
12227
+ try {
12228
+ const body = request.body;
12229
+ if (!body?.instruction)
12230
+ return reply.code(400).send({ error: 'instruction is required' });
12231
+ const result = await browser.act(request.params.id, body.instruction);
12232
+ return result;
12233
+ }
12234
+ catch (err) {
12235
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12236
+ }
12237
+ });
12238
+ app.post('/browser/sessions/:id/extract', async (request, reply) => {
12239
+ try {
12240
+ const body = request.body;
12241
+ if (!body?.instruction)
12242
+ return reply.code(400).send({ error: 'instruction is required' });
12243
+ const result = await browser.extract(request.params.id, body.instruction, body.schema);
12244
+ return result;
12245
+ }
12246
+ catch (err) {
12247
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12248
+ }
12249
+ });
12250
+ app.post('/browser/sessions/:id/observe', async (request, reply) => {
12251
+ try {
12252
+ const body = request.body;
12253
+ if (!body?.instruction)
12254
+ return reply.code(400).send({ error: 'instruction is required' });
12255
+ const result = await browser.observe(request.params.id, body.instruction);
12256
+ return result;
12257
+ }
12258
+ catch (err) {
12259
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12260
+ }
12261
+ });
12262
+ app.post('/browser/sessions/:id/navigate', async (request, reply) => {
12263
+ try {
12264
+ const body = request.body;
12265
+ if (!body?.url)
12266
+ return reply.code(400).send({ error: 'url is required' });
12267
+ const result = await browser.navigate(request.params.id, body.url);
12268
+ return result;
12269
+ }
12270
+ catch (err) {
12271
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12272
+ }
12273
+ });
12274
+ app.get('/browser/sessions/:id/screenshot', async (request, reply) => {
12275
+ try {
12276
+ const result = await browser.screenshot(request.params.id);
12277
+ return result;
12278
+ }
12279
+ catch (err) {
12280
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12281
+ }
12282
+ });
12283
+ // ── Agent Runs & Events ──────────────────────────────────────────────────
12284
+ const { createAgentRun, updateAgentRun, getAgentRun, getActiveAgentRun, listAgentRuns, appendAgentEvent, listAgentEvents, VALID_RUN_STATUSES, } = await import('./agent-runs.js');
12285
+ // Create a new agent run
12286
+ app.post('/agents/:agentId/runs', async (request, reply) => {
12287
+ const { agentId } = request.params;
12288
+ const body = request.body;
12289
+ if (!body?.objective)
12290
+ return reply.code(400).send({ error: 'objective is required' });
12291
+ const teamId = body.teamId ?? 'default';
12292
+ try {
12293
+ const run = createAgentRun(agentId, teamId, body.objective, {
12294
+ taskId: body.taskId,
12295
+ parentRunId: body.parentRunId,
12296
+ });
12297
+ return reply.code(201).send(run);
12298
+ }
12299
+ catch (err) {
12300
+ return reply.code(500).send({ error: err.message });
12301
+ }
12302
+ });
12303
+ // Update an agent run (status, context, artifacts)
12304
+ app.patch('/agents/:agentId/runs/:runId', async (request, reply) => {
12305
+ const { runId } = request.params;
12306
+ const body = request.body;
12307
+ if (body?.status && !VALID_RUN_STATUSES.includes(body.status)) {
12308
+ return reply.code(400).send({ error: `Invalid status. Valid: ${VALID_RUN_STATUSES.join(', ')}` });
12309
+ }
12310
+ try {
12311
+ const run = updateAgentRun(runId, {
12312
+ status: body?.status,
12313
+ contextSnapshot: body?.contextSnapshot,
12314
+ artifacts: body?.artifacts,
12315
+ });
12316
+ if (!run)
12317
+ return reply.code(404).send({ error: 'Run not found' });
12318
+ return run;
12319
+ }
12320
+ catch (err) {
12321
+ return reply.code(500).send({ error: err.message });
12322
+ }
12323
+ });
12324
+ // List agent runs
12325
+ app.get('/agents/:agentId/runs', async (request, reply) => {
12326
+ const { agentId } = request.params;
12327
+ const query = request.query;
12328
+ const teamId = query.teamId ?? 'default';
12329
+ const limit = query.limit ? parseInt(query.limit, 10) : undefined;
12330
+ return listAgentRuns(agentId, teamId, { status: query.status, limit });
12331
+ });
12332
+ // Get active run for an agent
12333
+ app.get('/agents/:agentId/runs/current', async (request, reply) => {
12334
+ const { agentId } = request.params;
12335
+ const query = request.query;
12336
+ const teamId = query.teamId ?? 'default';
12337
+ const run = getActiveAgentRun(agentId, teamId);
12338
+ if (!run)
12339
+ return reply.code(404).send({ error: 'No active run' });
12340
+ return run;
12341
+ });
12342
+ // Append an event
12343
+ const { validateRoutingSemantics } = await import('./agent-runs.js');
12344
+ // GET /events/routing/validate — check if a payload passes routing semantics
12345
+ app.post('/events/routing/validate', async (request) => {
12346
+ const body = request.body;
12347
+ if (!body?.eventType)
12348
+ return { valid: false, errors: ['eventType is required'], warnings: [] };
12349
+ return validateRoutingSemantics(body.eventType, body.payload ?? {});
12350
+ });
12351
+ app.post('/agents/:agentId/events', async (request, reply) => {
12352
+ const { agentId } = request.params;
12353
+ const body = request.body;
12354
+ if (!body?.eventType)
12355
+ return reply.code(400).send({ error: 'eventType is required' });
12356
+ try {
12357
+ const event = appendAgentEvent({
12358
+ agentId,
12359
+ runId: body.runId,
12360
+ eventType: body.eventType,
12361
+ payload: body.payload,
12362
+ enforceRouting: body.enforceRouting,
12363
+ });
12364
+ return reply.code(201).send(event);
12365
+ }
12366
+ catch (err) {
12367
+ if (err.message.includes('Routing semantics violation')) {
12368
+ 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).' });
12369
+ }
12370
+ return reply.code(500).send({ error: err.message });
12371
+ const message = String(err?.message || err);
12372
+ if (message.includes('rationale')) {
12373
+ return reply.code(400).send({ error: message });
12374
+ }
12375
+ return reply.code(500).send({ error: message });
12376
+ }
12377
+ });
12378
+ // List agent events
12379
+ app.get('/agents/:agentId/events', async (request, reply) => {
12380
+ const { agentId } = request.params;
12381
+ const query = request.query;
12382
+ return listAgentEvents({
12383
+ agentId,
12384
+ runId: query.runId,
12385
+ eventType: query.type,
12386
+ since: query.since ? parseInt(query.since, 10) : undefined,
12387
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12388
+ });
12389
+ });
12390
+ // ── Run Event Stream (SSE) ─────────────────────────────────────────────
12391
+ // Real-time SSE stream for run events. Canvas subscribes here instead of polling.
12392
+ // GET /agents/:agentId/runs/:runId/stream — stream events for a specific run
12393
+ // GET /agents/:agentId/stream — stream all events for an agent
12394
+ app.get('/agents/:agentId/runs/:runId/stream', async (request, reply) => {
12395
+ const { agentId, runId } = request.params;
12396
+ const run = getAgentRun(runId);
12397
+ if (!run) {
12398
+ reply.code(404);
12399
+ return { error: 'Run not found' };
12400
+ }
12401
+ reply.raw.writeHead(200, {
12402
+ 'Content-Type': 'text/event-stream',
12403
+ 'Cache-Control': 'no-cache',
12404
+ 'Connection': 'keep-alive',
12405
+ 'X-Accel-Buffering': 'no',
12406
+ });
12407
+ // Send current run state as initial snapshot
12408
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
12409
+ // Subscribe to eventBus for this run's events
12410
+ const listenerId = `run-stream-${runId}-${Date.now()}`;
12411
+ let closed = false;
12412
+ eventBus.on(listenerId, (event) => {
12413
+ if (closed)
12414
+ return;
12415
+ const data = event.data;
12416
+ // Forward events that match this agent or run
12417
+ if (data && (data.runId === runId || data.agentId === agentId)) {
12418
+ try {
12419
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12420
+ }
12421
+ catch { /* connection closed */ }
12422
+ }
12423
+ });
12424
+ // Heartbeat
12425
+ const heartbeat = setInterval(() => {
12426
+ if (closed) {
12427
+ clearInterval(heartbeat);
12428
+ return;
12429
+ }
12430
+ try {
12431
+ reply.raw.write(`:heartbeat\n\n`);
12432
+ }
12433
+ catch {
12434
+ clearInterval(heartbeat);
12435
+ }
12436
+ }, 15_000);
12437
+ // Cleanup
12438
+ request.raw.on('close', () => {
12439
+ closed = true;
12440
+ eventBus.off(listenerId);
12441
+ clearInterval(heartbeat);
12442
+ });
12443
+ });
12444
+ // Stream all events for an agent
12445
+ app.get('/agents/:agentId/stream', async (request, reply) => {
12446
+ const { agentId } = request.params;
12447
+ reply.raw.writeHead(200, {
12448
+ 'Content-Type': 'text/event-stream',
12449
+ 'Cache-Control': 'no-cache',
12450
+ 'Connection': 'keep-alive',
12451
+ 'X-Accel-Buffering': 'no',
12452
+ });
12453
+ // Send recent events as snapshot
12454
+ const recentEvents = listAgentEvents({ agentId, limit: 20 });
12455
+ const activeRun = getActiveAgentRun(agentId, 'default');
12456
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ activeRun, events: recentEvents })}\n\n`);
12457
+ const listenerId = `agent-stream-${agentId}-${Date.now()}`;
12458
+ let closed = false;
12459
+ eventBus.on(listenerId, (event) => {
12460
+ if (closed)
12461
+ return;
12462
+ const data = event.data;
12463
+ if (data && data.agentId === agentId) {
12464
+ try {
12465
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12466
+ }
12467
+ catch { /* connection closed */ }
12468
+ }
12469
+ });
12470
+ const heartbeat = setInterval(() => {
12471
+ if (closed) {
12472
+ clearInterval(heartbeat);
12473
+ return;
12474
+ }
12475
+ try {
12476
+ reply.raw.write(`:heartbeat\n\n`);
12477
+ }
12478
+ catch {
12479
+ clearInterval(heartbeat);
12480
+ }
12481
+ }, 15_000);
12482
+ request.raw.on('close', () => {
12483
+ closed = true;
12484
+ eventBus.off(listenerId);
12485
+ clearInterval(heartbeat);
12486
+ });
12487
+ });
12488
+ // ── Workflow Templates ─────────────────────────────────────────────────
12489
+ const { listWorkflowTemplates, getWorkflowTemplate, runWorkflow } = await import('./workflow-templates.js');
12490
+ // GET /workflows — list available workflow templates
12491
+ app.get('/workflows', async () => ({ templates: listWorkflowTemplates() }));
12492
+ // GET /workflows/:id — get template details
12493
+ app.get('/workflows/:id', async (request, reply) => {
12494
+ const template = getWorkflowTemplate(request.params.id);
12495
+ if (!template) {
12496
+ reply.code(404);
12497
+ return { error: 'Template not found' };
12498
+ }
12499
+ return {
12500
+ id: template.id,
12501
+ name: template.name,
12502
+ description: template.description,
12503
+ steps: template.steps.map(s => ({ name: s.name, description: s.description })),
12504
+ };
12505
+ });
12506
+ // POST /workflows/:id/run — execute a workflow
12507
+ app.post('/workflows/:id/run', async (request, reply) => {
12508
+ const template = getWorkflowTemplate(request.params.id);
12509
+ if (!template) {
12510
+ reply.code(404);
12511
+ return { error: 'Template not found' };
12512
+ }
12513
+ const body = request.body ?? {};
12514
+ const agentId = body.agentId ?? 'link';
12515
+ const teamId = body.teamId ?? 'default';
12516
+ const result = await runWorkflow(template, agentId, teamId, body);
12517
+ return result;
12518
+ });
12519
+ // ── Agent Messaging (Host-native) ─────────────────────────────────────
12520
+ // Local agent-to-agent messaging. Replaces gateway for same-Host agents.
12521
+ const { sendAgentMessage, listAgentMessages, listSentMessages, markMessagesRead, getUnreadCount, listChannelMessages } = await import('./agent-messaging.js');
12522
+ // Send message
12523
+ app.post('/agents/:agentId/messages/send', async (request, reply) => {
12524
+ const { agentId } = request.params;
12525
+ const body = request.body;
12526
+ if (!body?.to)
12527
+ return reply.code(400).send({ error: 'to (recipient agent) is required' });
12528
+ if (!body?.content)
12529
+ return reply.code(400).send({ error: 'content is required' });
12530
+ const msg = sendAgentMessage({
12531
+ fromAgent: agentId,
12532
+ toAgent: body.to,
12533
+ channel: body.channel,
12534
+ content: body.content,
12535
+ metadata: body.metadata,
12536
+ });
12537
+ // Emit event for SSE subscribers
12538
+ eventBus.emit({
12539
+ id: `amsg-evt-${Date.now()}`,
12540
+ type: 'message_posted',
12541
+ timestamp: Date.now(),
12542
+ data: { messageId: msg.id, from: agentId, to: body.to, channel: msg.channel },
12543
+ });
12544
+ return reply.code(201).send(msg);
12545
+ });
12546
+ // Inbox
12547
+ app.get('/agents/:agentId/messages', async (request) => {
12548
+ const { agentId } = request.params;
12549
+ const query = request.query;
12550
+ return {
12551
+ messages: listAgentMessages({
12552
+ agentId,
12553
+ channel: query.channel,
12554
+ unreadOnly: query.unread === 'true',
12555
+ since: query.since ? parseInt(query.since, 10) : undefined,
12556
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12557
+ }),
12558
+ unreadCount: getUnreadCount(agentId),
12559
+ };
12560
+ });
12561
+ // Sent
12562
+ app.get('/agents/:agentId/messages/sent', async (request) => {
12563
+ const { agentId } = request.params;
12564
+ const query = request.query;
12565
+ return { messages: listSentMessages(agentId, query.limit ? parseInt(query.limit, 10) : undefined) };
12566
+ });
12567
+ // Mark read
12568
+ app.post('/agents/:agentId/messages/read', async (request) => {
12569
+ const { agentId } = request.params;
12570
+ const body = request.body ?? {};
12571
+ const marked = markMessagesRead(agentId, body.messageIds);
12572
+ return { marked };
12573
+ });
12574
+ // Channel messages
12575
+ app.get('/messages/channel/:channel', async (request) => {
12576
+ const { channel } = request.params;
12577
+ const query = request.query;
12578
+ return {
12579
+ messages: listChannelMessages(channel, {
12580
+ since: query.since ? parseInt(query.since, 10) : undefined,
12581
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12582
+ }),
12583
+ };
12584
+ });
12585
+ // ── Run Retention / Archive ────────────────────────────────────────────
12586
+ const { applyRunRetention, getRetentionStats } = await import('./agent-runs.js');
12587
+ // GET /runs/retention/stats — preview what retention policy would do
12588
+ app.get('/runs/retention/stats', async (request) => {
12589
+ const query = request.query;
12590
+ return getRetentionStats({
12591
+ maxAgeDays: query.maxAgeDays ? parseInt(query.maxAgeDays, 10) : undefined,
12592
+ maxCompletedRuns: query.maxCompletedRuns ? parseInt(query.maxCompletedRuns, 10) : undefined,
12593
+ });
12594
+ });
12595
+ // POST /runs/retention/apply — apply retention policy
12596
+ app.post('/runs/retention/apply', async (request) => {
12597
+ const body = request.body ?? {};
12598
+ return applyRunRetention({
12599
+ policy: {
12600
+ maxAgeDays: body.maxAgeDays,
12601
+ maxCompletedRuns: body.maxCompletedRuns,
12602
+ deleteArchived: body.deleteArchived,
12603
+ },
12604
+ agentId: body.agentId,
12605
+ dryRun: body.dryRun,
12606
+ });
12607
+ });
12608
+ // ── Artifact Store (Host-native) ──────────────────────────────────────
12609
+ const { storeArtifact, getArtifact, readArtifactContent, listArtifacts, deleteArtifact, getStorageUsage } = await import('./artifact-store.js');
12610
+ // Upload artifact
12611
+ app.post('/agents/:agentId/artifacts', async (request, reply) => {
12612
+ const { agentId } = request.params;
12613
+ const body = request.body;
12614
+ if (!body?.name)
12615
+ return reply.code(400).send({ error: 'name is required' });
12616
+ if (!body?.content)
12617
+ return reply.code(400).send({ error: 'content is required' });
12618
+ const contentBuf = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : Buffer.from(body.content);
12619
+ const art = storeArtifact({ agentId, name: body.name, content: contentBuf, mimeType: body.mimeType, runId: body.runId, taskId: body.taskId, metadata: body.metadata });
12620
+ return reply.code(201).send(art);
12621
+ });
12622
+ // List artifacts
12623
+ app.get('/agents/:agentId/artifacts', async (request) => {
12624
+ const { agentId } = request.params;
12625
+ const query = request.query;
12626
+ return {
12627
+ artifacts: listArtifacts({ agentId, runId: query.runId, taskId: query.taskId, limit: query.limit ? parseInt(query.limit, 10) : undefined }),
12628
+ usage: getStorageUsage(agentId),
12629
+ };
12630
+ });
12631
+ // Get artifact metadata
12632
+ app.get('/artifacts/:artifactId', async (request, reply) => {
12633
+ const { artifactId } = request.params;
12634
+ const art = getArtifact(artifactId);
12635
+ if (!art)
12636
+ return reply.code(404).send({ error: 'Artifact not found' });
12637
+ return art;
12638
+ });
12639
+ // Download artifact content
12640
+ app.get('/artifacts/:artifactId/content', async (request, reply) => {
12641
+ const { artifactId } = request.params;
12642
+ const content = readArtifactContent(artifactId);
12643
+ if (!content)
12644
+ return reply.code(404).send({ error: 'Artifact not found or file missing' });
12645
+ const art = getArtifact(artifactId);
12646
+ return reply.type(art.mimeType).send(content);
12647
+ });
12648
+ // Delete artifact
12649
+ app.delete('/artifacts/:artifactId', async (request, reply) => {
12650
+ const { artifactId } = request.params;
12651
+ const deleted = deleteArtifact(artifactId);
12652
+ if (!deleted)
12653
+ return reply.code(404).send({ error: 'Artifact not found' });
12654
+ return { deleted: true };
12655
+ });
12656
+ // Storage usage
12657
+ app.get('/agents/:agentId/storage', async (request) => {
12658
+ const { agentId } = request.params;
12659
+ return getStorageUsage(agentId);
12660
+ });
12661
+ // ── Webhook Storage ──────────────────────────────────────────────────
12662
+ const { storeWebhookPayload, getWebhookPayload, listWebhookPayloads, markPayloadProcessed, getUnprocessedCount, purgeOldPayloads } = await import('./webhook-storage.js');
12663
+ // Ingest webhook payload
12664
+ app.post('/webhooks/ingest', async (request, reply) => {
12665
+ const body = request.body;
12666
+ if (!body?.source)
12667
+ return reply.code(400).send({ error: 'source is required' });
12668
+ if (!body?.eventType)
12669
+ return reply.code(400).send({ error: 'eventType is required' });
12670
+ if (!body?.body)
12671
+ return reply.code(400).send({ error: 'body (payload) is required' });
12672
+ const headers = {};
12673
+ for (const [k, v] of Object.entries(request.headers)) {
12674
+ if (typeof v === 'string')
12675
+ headers[k] = v;
12676
+ }
12677
+ const payload = storeWebhookPayload({ source: body.source, eventType: body.eventType, agentId: body.agentId, body: body.body, headers });
12678
+ return reply.code(201).send(payload);
12679
+ });
12680
+ // List payloads
12681
+ app.get('/webhooks/payloads', async (request) => {
12682
+ const query = request.query;
12683
+ return {
12684
+ payloads: listWebhookPayloads({
12685
+ source: query.source,
12686
+ agentId: query.agentId,
12687
+ unprocessedOnly: query.unprocessed === 'true',
12688
+ since: query.since ? parseInt(query.since, 10) : undefined,
12689
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12690
+ }),
12691
+ unprocessedCount: getUnprocessedCount({ source: query.source, agentId: query.agentId }),
12692
+ };
12693
+ });
12694
+ // Get single payload
12695
+ app.get('/webhooks/payloads/:payloadId', async (request, reply) => {
12696
+ const { payloadId } = request.params;
12697
+ const payload = getWebhookPayload(payloadId);
12698
+ if (!payload)
12699
+ return reply.code(404).send({ error: 'Payload not found' });
12700
+ return payload;
12701
+ });
12702
+ // Mark processed
12703
+ app.post('/webhooks/payloads/:payloadId/process', async (request, reply) => {
12704
+ const { payloadId } = request.params;
12705
+ const marked = markPayloadProcessed(payloadId);
12706
+ if (!marked)
12707
+ return reply.code(404).send({ error: 'Payload not found or already processed' });
12708
+ return { processed: true };
12709
+ });
12710
+ // Purge old processed payloads
12711
+ app.post('/webhooks/purge', async (request) => {
12712
+ const body = request.body ?? {};
12713
+ const deleted = purgeOldPayloads(body.maxAgeDays ?? 30);
12714
+ return { deleted };
12715
+ });
12716
+ // ── Approval Routing ────────────────────────────────────────────────────
12717
+ const { listPendingApprovals, listApprovalQueue, submitApprovalDecision, } = await import('./agent-runs.js');
12718
+ // List pending approvals (review_requested events needing action)
12719
+ app.get('/approvals/pending', async (request) => {
12720
+ const query = request.query;
12721
+ return listPendingApprovals({
12722
+ agentId: query.agentId,
12723
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12724
+ });
12725
+ });
12726
+ // Dedicated approval queue — unified view of everything needing human decision.
12727
+ // Answers: what needs decision, who owns it, when it expires, what happens if ignored.
12728
+ app.get('/approval-queue', async (request) => {
12729
+ const query = request.query;
12730
+ const items = listApprovalQueue({
12731
+ agentId: query.agentId,
12732
+ category: query.category === 'review' || query.category === 'agent_action' ? query.category : undefined,
12733
+ includeExpired: query.includeExpired === 'true',
12734
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12735
+ });
12736
+ return {
12737
+ items,
12738
+ count: items.length,
12739
+ hasExpired: items.some(i => i.isExpired),
12740
+ };
12741
+ });
12742
+ // Submit agent-action approval (approve_requested events)
12743
+ app.post('/approval-queue/:approvalId/decide', async (request, reply) => {
12744
+ const { approvalId } = request.params;
12745
+ const body = request.body;
12746
+ if (!body?.decision || !['approve', 'reject', 'defer'].includes(body.decision)) {
12747
+ return reply.code(400).send({ error: 'decision must be "approve", "reject", or "defer"' });
12748
+ }
12749
+ if (!body?.actor) {
12750
+ return reply.code(400).send({ error: 'actor is required' });
12751
+ }
12752
+ try {
12753
+ const result = submitApprovalDecision({
12754
+ eventId: approvalId,
12755
+ decision: body.decision,
12756
+ reviewer: body.actor,
12757
+ comment: body.comment,
12758
+ });
12759
+ // Emit canvas_input event so Presence Layer updates
12760
+ eventBus.emit({
12761
+ id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12762
+ type: 'canvas_input',
12763
+ timestamp: Date.now(),
12764
+ data: {
12765
+ action: 'decision',
12766
+ approvalId,
12767
+ decision: body.decision,
12768
+ actor: body.actor,
12769
+ },
12770
+ });
12771
+ return result;
12772
+ }
12773
+ catch (err) {
12774
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12775
+ }
12776
+ });
12777
+ // Submit approval decision
12778
+ app.post('/approvals/:eventId/decide', async (request, reply) => {
12779
+ const { eventId } = request.params;
12780
+ const body = request.body;
12781
+ if (!body?.decision || !['approve', 'reject'].includes(body.decision)) {
12782
+ return reply.code(400).send({ error: 'decision must be "approve" or "reject"' });
12783
+ }
12784
+ if (!body?.reviewer) {
12785
+ return reply.code(400).send({ error: 'reviewer is required' });
12786
+ }
12787
+ try {
12788
+ const result = submitApprovalDecision({
12789
+ eventId,
12790
+ decision: body.decision,
12791
+ reviewer: body.reviewer,
12792
+ comment: body.comment,
12793
+ rationale: body.rationale,
12794
+ });
12795
+ return result;
12796
+ }
12797
+ catch (err) {
12798
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12799
+ }
12800
+ });
12801
+ // ── Canvas Input ──────────────────────────────────────────────────────
12802
+ // Human → agent control seam for the Presence Layer.
12803
+ // Payload is intentionally small per COO spec: action + target + actor.
12804
+ const CANVAS_INPUT_ACTIONS = ['decision', 'interrupt', 'pause', 'resume', 'mute', 'unmute'];
12805
+ const CanvasInputSchema = z.object({
12806
+ action: z.enum(CANVAS_INPUT_ACTIONS),
12807
+ targetRunId: z.string().optional(), // which run to act on
12808
+ decisionId: z.string().optional(), // for decision actions
12809
+ choice: z.enum(['approve', 'deny', 'defer']).optional(), // for decision actions
12810
+ actor: z.string().min(1), // who made this input
12811
+ comment: z.string().optional(), // optional rationale
12812
+ });
12813
+ app.post('/canvas/input', async (request, reply) => {
12814
+ const body = request.body;
12815
+ const result = CanvasInputSchema.safeParse(body);
12816
+ if (!result.success) {
12817
+ reply.code(422);
12818
+ return {
12819
+ error: `Invalid canvas input: ${result.error.issues.map(i => i.message).join(', ')}`,
12820
+ hint: 'Required: action (decision|interrupt|pause|resume|mute|unmute), actor. Optional: targetRunId, decisionId, choice, comment.',
12821
+ };
12822
+ }
12823
+ const input = result.data;
12824
+ const now = Date.now();
12825
+ // Route by action type
12826
+ if (input.action === 'decision') {
12827
+ if (!input.decisionId || !input.choice) {
12828
+ reply.code(422);
12829
+ return { error: 'Decision action requires decisionId and choice (approve|deny|defer)' };
12830
+ }
12831
+ // Emit canvas_input event for SSE subscribers
12832
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12833
+ action: input.action,
12834
+ decisionId: input.decisionId,
12835
+ choice: input.choice,
12836
+ actor: input.actor,
12837
+ comment: input.comment,
12838
+ timestamp: now,
12839
+ } });
12840
+ return {
12841
+ success: true,
12842
+ action: 'decision',
12843
+ decisionId: input.decisionId,
12844
+ choice: input.choice,
12845
+ actor: input.actor,
12846
+ timestamp: now,
12847
+ };
12848
+ }
12849
+ if (input.action === 'interrupt' || input.action === 'pause') {
12850
+ // Update active run if specified
12851
+ const runId = input.targetRunId;
12852
+ if (runId) {
12853
+ try {
12854
+ updateAgentRun(runId, {
12855
+ status: input.action === 'interrupt' ? 'cancelled' : 'blocked',
12856
+ });
12857
+ }
12858
+ catch { /* run may not exist — still emit event */ }
12859
+ }
12860
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12861
+ action: input.action,
12862
+ targetRunId: runId || null,
12863
+ actor: input.actor,
12864
+ timestamp: now,
12865
+ } });
12866
+ return {
12867
+ success: true,
12868
+ action: input.action,
12869
+ targetRunId: runId || null,
12870
+ actor: input.actor,
12871
+ timestamp: now,
12872
+ };
12873
+ }
12874
+ if (input.action === 'resume') {
12875
+ const runId = input.targetRunId;
12876
+ if (runId) {
12877
+ try {
12878
+ updateAgentRun(runId, { status: 'working' });
12879
+ }
12880
+ catch { /* run may not exist */ }
12881
+ }
12882
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12883
+ action: 'resume',
12884
+ targetRunId: runId || null,
12885
+ actor: input.actor,
12886
+ timestamp: now,
12887
+ } });
12888
+ return { success: true, action: 'resume', targetRunId: runId || null, actor: input.actor, timestamp: now };
12889
+ }
12890
+ // Mute/unmute — emit event only, no state change needed
12891
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12892
+ action: input.action,
12893
+ actor: input.actor,
12894
+ timestamp: now,
12895
+ } });
12896
+ return { success: true, action: input.action, actor: input.actor, timestamp: now };
12897
+ });
12898
+ // GET /canvas/input/schema — discovery endpoint
12899
+ app.get('/canvas/input/schema', async () => ({
12900
+ actions: CANVAS_INPUT_ACTIONS,
12901
+ schema: {
12902
+ action: 'decision | interrupt | pause | resume | mute | unmute',
12903
+ targetRunId: 'optional — which run to act on',
12904
+ decisionId: 'required for decision action — approval event ID',
12905
+ choice: 'required for decision — approve | deny | defer',
12906
+ actor: 'required — who made this input',
12907
+ comment: 'optional — rationale',
12908
+ },
12909
+ }));
12910
+ // ── Email / SMS relay ──────────────────────────────────────────────────
12911
+ async function cloudRelay(path, body, reply) {
12912
+ const cloudUrl = process.env.REFLECTT_CLOUD_URL;
12913
+ const hostToken = process.env.REFLECTT_HOST_TOKEN;
12914
+ if (!cloudUrl || !hostToken) {
12915
+ reply.code(503);
12916
+ return { error: 'Not connected to cloud. Configure REFLECTT_CLOUD_URL and REFLECTT_HOST_TOKEN.' };
12917
+ }
12918
+ try {
12919
+ const res = await fetch(`${cloudUrl}${path}`, {
12920
+ method: 'POST',
12921
+ headers: {
12922
+ 'Content-Type': 'application/json',
12923
+ Authorization: `Bearer ${hostToken}`,
12924
+ },
12925
+ body: JSON.stringify(body),
12926
+ });
12927
+ const data = await res.json().catch(() => ({}));
12928
+ if (!res.ok) {
12929
+ reply.code(res.status);
12930
+ return data;
12931
+ }
12932
+ return data;
12933
+ }
12934
+ catch (err) {
12935
+ reply.code(502);
12936
+ return { error: `Cloud relay failed: ${err.message}` };
12937
+ }
12938
+ }
12939
+ // Send email via cloud relay
12940
+ app.post('/email/send', async (request, reply) => {
12941
+ const body = request.body;
12942
+ const from = typeof body.from === 'string' ? body.from.trim() : '';
12943
+ const to = body.to;
12944
+ const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
12945
+ if (!from)
12946
+ return reply.code(400).send({ error: 'from is required' });
12947
+ if (!to)
12948
+ return reply.code(400).send({ error: 'to is required' });
12949
+ if (!subject)
12950
+ return reply.code(400).send({ error: 'subject is required' });
12951
+ if (!body.html && !body.text)
12952
+ return reply.code(400).send({ error: 'html or text body is required' });
12953
+ // Use host-relay endpoint — authenticates with host credential, uses host's own teamId server-side
12954
+ const hostId = process.env.REFLECTT_HOST_ID;
12955
+ const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
12956
+ return cloudRelay(relayPath, {
12957
+ from,
12958
+ to,
12959
+ subject,
12960
+ html: body.html,
12961
+ text: body.text,
12962
+ replyTo: body.replyTo,
12963
+ cc: body.cc,
12964
+ bcc: body.bcc,
12965
+ agent: body.agentId || body.agent || 'unknown',
12966
+ }, reply);
12967
+ });
12968
+ // Send SMS via cloud relay
12969
+ app.post('/sms/send', async (request, reply) => {
12970
+ const body = request.body;
12971
+ const to = typeof body.to === 'string' ? body.to.trim() : '';
12972
+ const msgBody = typeof body.body === 'string' ? body.body.trim() : '';
12973
+ if (!to)
12974
+ return reply.code(400).send({ error: 'to is required (phone number)' });
12975
+ if (!msgBody)
12976
+ return reply.code(400).send({ error: 'body is required' });
12977
+ const hostIdSms = process.env.REFLECTT_HOST_ID;
12978
+ const smsRelayPath = hostIdSms ? `/api/hosts/${encodeURIComponent(hostIdSms)}/relay/sms` : '/api/hosts/relay/sms';
12979
+ return cloudRelay(smsRelayPath, {
12980
+ to,
12981
+ body: msgBody,
12982
+ from: body.from,
12983
+ agent: body.agentId || body.agent || 'unknown',
12984
+ }, reply);
12985
+ });
12986
+ // ── Agent Config ──────────────────────────────────────────────────────
12987
+ // Per-agent model preference, cost cap, and settings.
12988
+ // This is the policy anchor for cost enforcement.
12989
+ const { getAgentConfig, listAgentConfigs, setAgentConfig, deleteAgentConfig, checkCostCap } = await import('./agent-config.js');
12990
+ // GET /agents/:agentId/config — get config for an agent
12991
+ app.get('/agents/:agentId/config', async (request) => {
12992
+ const config = getAgentConfig(request.params.agentId);
12993
+ return config ?? { agentId: request.params.agentId, configured: false };
12994
+ });
12995
+ // PUT /agents/:agentId/config — upsert config for an agent
12996
+ app.put('/agents/:agentId/config', async (request, reply) => {
12997
+ const body = request.body ?? {};
12998
+ try {
12999
+ const config = setAgentConfig(request.params.agentId, {
13000
+ teamId: typeof body.teamId === 'string' ? body.teamId : undefined,
13001
+ model: body.model !== undefined ? body.model : undefined,
13002
+ fallbackModel: body.fallbackModel !== undefined ? body.fallbackModel : undefined,
13003
+ costCapDaily: body.costCapDaily !== undefined ? body.costCapDaily : undefined,
13004
+ costCapMonthly: body.costCapMonthly !== undefined ? body.costCapMonthly : undefined,
13005
+ maxTokensPerCall: body.maxTokensPerCall !== undefined ? body.maxTokensPerCall : undefined,
13006
+ settings: body.settings !== undefined ? body.settings : undefined,
13007
+ });
13008
+ return config;
13009
+ }
13010
+ catch (err) {
13011
+ reply.code(400);
13012
+ return { error: err.message };
13013
+ }
13014
+ });
13015
+ // DELETE /agents/:agentId/config — remove config for an agent
13016
+ app.delete('/agents/:agentId/config', async (request, reply) => {
13017
+ const deleted = deleteAgentConfig(request.params.agentId);
13018
+ if (!deleted) {
13019
+ reply.code(404);
13020
+ return { error: 'Config not found' };
13021
+ }
13022
+ return { success: true };
13023
+ });
13024
+ // GET /agent-configs — list all agent configs
13025
+ app.get('/agent-configs', async (request) => {
13026
+ const query = request.query;
13027
+ return { configs: listAgentConfigs({ teamId: query.teamId }) };
13028
+ });
13029
+ // GET /agents/:agentId/cost-check — runtime cost enforcement check
13030
+ // Used by the runtime before making model calls.
13031
+ app.get('/agents/:agentId/cost-check', async (request) => {
13032
+ const query = request.query;
13033
+ const dailySpend = query.dailySpend ? parseFloat(query.dailySpend) : 0;
13034
+ const monthlySpend = query.monthlySpend ? parseFloat(query.monthlySpend) : 0;
13035
+ return checkCostCap(request.params.agentId, dailySpend, monthlySpend);
13036
+ });
13037
+ // ── Cost-Policy Enforcement Middleware ──────────────────────────────────
13038
+ const { enforcePolicy, recordUsage, getDailySpend, getMonthlySpend, purgeUsageLog, ensureUsageLogTable, } = await import('./cost-enforcement.js');
13039
+ ensureUsageLogTable();
13040
+ // POST /agents/:agentId/enforce-cost — runtime enforcement before model calls
13041
+ app.post('/agents/:agentId/enforce-cost', async (request, reply) => {
13042
+ const result = enforcePolicy(request.params.agentId);
13043
+ const status = result.action === 'deny' ? 403 : 200;
13044
+ return reply.code(status).send(result);
13045
+ });
13046
+ // GET /agents/:agentId/spend — current daily + monthly spend
13047
+ app.get('/agents/:agentId/spend', async (request) => {
13048
+ const { agentId } = request.params;
13049
+ return {
13050
+ agentId,
13051
+ dailySpend: getDailySpend(agentId),
13052
+ monthlySpend: getMonthlySpend(agentId),
13053
+ };
13054
+ });
13055
+ // POST /usage/record — record a usage event
13056
+ app.post('/usage/record', async (request, reply) => {
13057
+ const body = request.body;
13058
+ if (!body?.agentId)
13059
+ return reply.code(400).send({ error: 'agentId is required' });
13060
+ if (!body?.model)
13061
+ return reply.code(400).send({ error: 'model is required' });
13062
+ if (typeof body.cost !== 'number')
13063
+ return reply.code(400).send({ error: 'cost is required (number)' });
13064
+ recordUsage({
13065
+ agentId: body.agentId,
13066
+ model: body.model,
13067
+ inputTokens: body.inputTokens ?? 0,
13068
+ outputTokens: body.outputTokens ?? 0,
13069
+ cost: body.cost,
13070
+ timestamp: Date.now(),
13071
+ });
13072
+ return reply.code(201).send({ ok: true });
13073
+ });
13074
+ // POST /usage/purge — purge old usage records
13075
+ app.post('/usage/purge', async (request) => {
13076
+ const body = request.body;
13077
+ const deleted = purgeUsageLog(body?.maxAgeDays ?? 90);
13078
+ return { deleted };
13079
+ });
13080
+ // ── Agent Memories ─────────────────────────────────────────────────────
13081
+ const { setMemory, getMemory, listMemories, deleteMemory, deleteMemoryById, purgeExpiredMemories, countMemories, } = await import('./agent-memories.js');
13082
+ // Set (create or update) a memory
13083
+ app.put('/agents/:agentId/memories', async (request, reply) => {
13084
+ const { agentId } = request.params;
13085
+ const body = request.body;
13086
+ if (!body?.key)
13087
+ return reply.code(400).send({ error: 'key is required' });
13088
+ if (body.content === undefined || body.content === null)
13089
+ return reply.code(400).send({ error: 'content is required' });
13090
+ try {
13091
+ const memory = setMemory({
13092
+ agentId,
13093
+ namespace: body.namespace,
13094
+ key: body.key,
13095
+ content: body.content,
13096
+ tags: body.tags,
13097
+ expiresAt: body.expiresAt,
13098
+ });
13099
+ return reply.code(200).send(memory);
13100
+ }
13101
+ catch (err) {
13102
+ return reply.code(500).send({ error: err.message });
13103
+ }
13104
+ });
13105
+ // Get a specific memory by key
13106
+ app.get('/agents/:agentId/memories/:key', async (request, reply) => {
13107
+ const { agentId, key } = request.params;
13108
+ const query = request.query;
13109
+ const memory = getMemory(agentId, key, query.namespace);
13110
+ if (!memory)
13111
+ return reply.code(404).send({ error: 'Memory not found' });
13112
+ return memory;
13113
+ });
13114
+ // List memories for an agent
13115
+ app.get('/agents/:agentId/memories', async (request, reply) => {
13116
+ const { agentId } = request.params;
13117
+ const query = request.query;
13118
+ return listMemories({
13119
+ agentId,
13120
+ namespace: query.namespace,
13121
+ tag: query.tag,
13122
+ search: query.search,
13123
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
13124
+ });
13125
+ });
13126
+ // Delete a memory by key
13127
+ app.delete('/agents/:agentId/memories/:key', async (request, reply) => {
13128
+ const { agentId, key } = request.params;
13129
+ const query = request.query;
13130
+ const deleted = deleteMemory(agentId, key, query.namespace);
13131
+ if (!deleted)
13132
+ return reply.code(404).send({ error: 'Memory not found' });
13133
+ return { deleted: true };
13134
+ });
13135
+ // Count memories
13136
+ app.get('/agents/:agentId/memories/count', async (request, reply) => {
13137
+ const { agentId } = request.params;
13138
+ const query = request.query;
13139
+ return { count: countMemories(agentId, query.namespace) };
13140
+ });
13141
+ // Purge expired memories (housekeeping)
13142
+ app.post('/agents/memories/purge', async (_request, reply) => {
13143
+ const purged = purgeExpiredMemories();
13144
+ return { purged };
13145
+ });
11992
13146
  return app;
11993
13147
  }
11994
13148
  //# sourceMappingURL=server.js.map