reflectt-node 0.1.17 → 0.1.18

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 (63) hide show
  1. package/dist/activationEvents.d.ts +3 -1
  2. package/dist/activationEvents.d.ts.map +1 -1
  3. package/dist/activationEvents.js +5 -0
  4. package/dist/activationEvents.js.map +1 -1
  5. package/dist/canvas-auto-state.d.ts +7 -3
  6. package/dist/canvas-auto-state.d.ts.map +1 -1
  7. package/dist/canvas-auto-state.js +33 -5
  8. package/dist/canvas-auto-state.js.map +1 -1
  9. package/dist/canvas-interactive.d.ts +52 -0
  10. package/dist/canvas-interactive.d.ts.map +1 -0
  11. package/dist/canvas-interactive.js +401 -0
  12. package/dist/canvas-interactive.js.map +1 -0
  13. package/dist/canvas-multiplexer.d.ts +3 -0
  14. package/dist/canvas-multiplexer.d.ts.map +1 -1
  15. package/dist/canvas-multiplexer.js +28 -0
  16. package/dist/canvas-multiplexer.js.map +1 -1
  17. package/dist/canvas-push.d.ts +9 -0
  18. package/dist/canvas-push.d.ts.map +1 -0
  19. package/dist/canvas-push.js +169 -0
  20. package/dist/canvas-push.js.map +1 -0
  21. package/dist/canvas-query.d.ts +26 -0
  22. package/dist/canvas-query.d.ts.map +1 -0
  23. package/dist/canvas-query.js +369 -0
  24. package/dist/canvas-query.js.map +1 -0
  25. package/dist/canvas-routes.d.ts +59 -8
  26. package/dist/canvas-routes.d.ts.map +1 -1
  27. package/dist/canvas-routes.js +422 -7
  28. package/dist/canvas-routes.js.map +1 -1
  29. package/dist/canvas-slots.d.ts +1 -1
  30. package/dist/canvas-takeover.d.ts +19 -0
  31. package/dist/canvas-takeover.d.ts.map +1 -0
  32. package/dist/canvas-takeover.js +121 -0
  33. package/dist/canvas-takeover.js.map +1 -0
  34. package/dist/canvas-types.d.ts +1 -1
  35. package/dist/canvas-types.d.ts.map +1 -1
  36. package/dist/canvas-types.js +1 -0
  37. package/dist/canvas-types.js.map +1 -1
  38. package/dist/cloud.d.ts.map +1 -1
  39. package/dist/cloud.js +19 -0
  40. package/dist/cloud.js.map +1 -1
  41. package/dist/ghost-signup-nudge.d.ts +43 -0
  42. package/dist/ghost-signup-nudge.d.ts.map +1 -0
  43. package/dist/ghost-signup-nudge.js +175 -0
  44. package/dist/ghost-signup-nudge.js.map +1 -0
  45. package/dist/presence.d.ts +1 -0
  46. package/dist/presence.d.ts.map +1 -1
  47. package/dist/presence.js.map +1 -1
  48. package/dist/restart-drift-guard.d.ts +9 -0
  49. package/dist/restart-drift-guard.d.ts.map +1 -0
  50. package/dist/restart-drift-guard.js +80 -0
  51. package/dist/restart-drift-guard.js.map +1 -0
  52. package/dist/server.d.ts.map +1 -1
  53. package/dist/server.js +230 -1307
  54. package/dist/server.js.map +1 -1
  55. package/dist/tasks.d.ts +1 -0
  56. package/dist/tasks.d.ts.map +1 -1
  57. package/dist/tasks.js +95 -18
  58. package/dist/tasks.js.map +1 -1
  59. package/dist/workflow-templates.d.ts.map +1 -1
  60. package/dist/workflow-templates.js +41 -1
  61. package/dist/workflow-templates.js.map +1 -1
  62. package/package.json +2 -2
  63. package/public/docs.md +5 -1
package/dist/server.js CHANGED
@@ -56,12 +56,13 @@ import { deriveScopeId } from './scope-routing.js';
56
56
  import { eventBus, VALID_EVENT_TYPES } from './events.js';
57
57
  import { presenceManager } from './presence.js';
58
58
  import { startSweeper, getSweeperStatus, sweepValidatingQueue, flagPrDrift, generateDriftReport } from './executionSweeper.js';
59
+ import { runRestartDriftGuard } from './restart-drift-guard.js';
59
60
  import { autoPopulateCloseGate, tryAutoCloseTask, getMergeAttemptLog } from './prAutoMerge.js';
60
61
  import { getDuplicateClosureCanonicalRefError } from './duplicateClosureGuard.js';
61
62
  import { recordReviewMutation, diffReviewFields, getAuditEntries, loadAuditLedger } from './auditLedger.js';
62
63
  import { listSharedFiles, readSharedFile, resolveTaskArtifact } from './shared-workspace-api.js';
63
64
  import { normalizeArtifactPath, normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
64
- import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, } from './activationEvents.js';
65
+ import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, getActivationEventLog, } from './activationEvents.js';
65
66
  import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
66
67
  import { mentionAckTracker } from './mention-ack.js';
67
68
  import { analyticsManager } from './analytics.js';
@@ -117,7 +118,7 @@ import { startShippedHeartbeat, stopShippedHeartbeat, getShippedHeartbeatStats }
117
118
  import { startOpenClawUsageSync, stopOpenClawUsageSync, syncOpenClawUsage } from './openclaw-usage-sync.js';
118
119
  import { initContactsTable, createContact, getContact, updateContact, deleteContact, listContacts } from './contacts.js';
119
120
  import { processRender, logRejection, getRecentRejections, subscribeCanvas } from './canvas-multiplexer.js';
120
- import { canvasReadRoutes } from './canvas-routes.js';
121
+ import { canvasReadRoutes, formatRecency } from './canvas-routes.js';
121
122
  import { startTeamPulse, stopTeamPulse, postTeamPulse, computeTeamPulse, getTeamPulseConfig, configureTeamPulse, getTeamPulseHistory } from './team-pulse.js';
122
123
  import { runTeamDoctor } from './team-doctor.js';
123
124
  import { createStarterTeam } from './starter-team.js';
@@ -869,6 +870,16 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
869
870
  metadata.review_state = 'needs_author';
870
871
  metadata.review_last_activity_at = now;
871
872
  }
873
+ // Cancelled tasks should not keep reviewer-decision metadata alive.
874
+ // Otherwise downstream notifiers/dashboard rails can misclassify a
875
+ // cancelled+unassigned task as still waiting on the former assignee/author.
876
+ if (nextStatus === 'cancelled') {
877
+ metadata.review_state = undefined;
878
+ metadata.reviewer_decision = undefined;
879
+ metadata.reviewer_notes = undefined;
880
+ metadata.reviewer_approved = undefined;
881
+ metadata.review_last_activity_at = undefined;
882
+ }
872
883
  const actor = parsed.actor?.trim();
873
884
  if (nextStatus === 'validating'
874
885
  && actor
@@ -9768,15 +9779,16 @@ export async function createServer() {
9768
9779
  const entry = canvasStateMap.get(agentId);
9769
9780
  return entry ? { state: entry.state, updatedAt: entry.updatedAt } : null;
9770
9781
  },
9771
- emitSyntheticState: (agentId, state, sourceTasks) => {
9782
+ emitSyntheticState: (agentId, state, sourceTasks, thought) => {
9772
9783
  const now = Date.now();
9773
9784
  // Write into canvasStateMap so pulse tick picks it up
9774
- const prev = canvasStateMap.get(agentId);
9785
+ const existing = canvasStateMap.get(agentId) ?? {};
9775
9786
  canvasStateMap.set(agentId, {
9776
9787
  state,
9777
9788
  sensors: null,
9778
- payload: { _auto: true, sourceTasks: sourceTasks.slice(0, 2).map(t => ({ id: t.id, title: t.title, status: t.status })) },
9789
+ payload: { _auto: true, sourceTasks: sourceTasks.slice(0, 2).map((t) => ({ id: t.id, title: t.title, status: t.status })) },
9779
9790
  updatedAt: now,
9791
+ lastMessage: thought ? { content: thought, timestamp: now } : existing?.lastMessage,
9780
9792
  });
9781
9793
  // Emit canvas_render so SSE consumers get immediate update
9782
9794
  eventBus.emit({
@@ -9796,7 +9808,59 @@ export async function createServer() {
9796
9808
  task: sourceTasks[0]?.title ?? null,
9797
9809
  _auto: true,
9798
9810
  },
9799
- previousState: prev?.state ?? 'floor',
9811
+ previousState: existing?.state ?? 'floor',
9812
+ },
9813
+ });
9814
+ },
9815
+ emitTaskProgress: (agentId, task) => {
9816
+ const now = Date.now();
9817
+ // Emit canvas_push thought for /live visitors - shows real task progress
9818
+ eventBus.emit({
9819
+ id: `task-progress-${agentId}-${now}`,
9820
+ type: 'canvas_push',
9821
+ timestamp: now,
9822
+ data: {
9823
+ type: 'expression',
9824
+ expression: 'thought',
9825
+ agentId,
9826
+ agentColor: AGENT_IDENTITY_COLORS[agentId] ?? '#60a5fa',
9827
+ text: `${task.title}`,
9828
+ state: 'working',
9829
+ task: task.title,
9830
+ ttl: 12000,
9831
+ },
9832
+ });
9833
+ },
9834
+ emitAmbientThought: (agentId, task) => {
9835
+ const now = Date.now();
9836
+ // Emit ambient thought - makes /live feel alive with constant activity
9837
+ // Varied messages to show agents are actively working
9838
+ const messages = [
9839
+ `Working on: ${task.title.slice(0, 50)}`,
9840
+ `Analyzing: ${task.title.slice(0, 40)}`,
9841
+ `Processing: ${task.title.slice(0, 40)}`,
9842
+ `Building: ${task.title.slice(0, 40)}`,
9843
+ `Reviewing: ${task.title.slice(0, 40)}`,
9844
+ `Testing: ${task.title.slice(0, 40)}`,
9845
+ `Debugging: ${task.title.slice(0, 40)}`,
9846
+ `Ship it`,
9847
+ `Almost done`,
9848
+ `Making progress`,
9849
+ ];
9850
+ const msg = messages[Math.floor(Math.random() * messages.length)];
9851
+ eventBus.emit({
9852
+ id: `ambient-${agentId}-${now}`,
9853
+ type: 'canvas_push',
9854
+ timestamp: now,
9855
+ data: {
9856
+ type: 'expression',
9857
+ expression: 'thought',
9858
+ agentId,
9859
+ agentColor: AGENT_IDENTITY_COLORS[agentId] ?? '#60a5fa',
9860
+ text: msg,
9861
+ state: 'working',
9862
+ task: task.title,
9863
+ ttl: 8000,
9800
9864
  },
9801
9865
  });
9802
9866
  },
@@ -10048,339 +10112,13 @@ export async function createServer() {
10048
10112
  attention: entry.payload?.attention,
10049
10113
  };
10050
10114
  });
10051
- // GET /canvas/presenceall agents as AgentPresence[] (for presence surface)
10052
- app.get('/canvas/presence', async () => {
10053
- const agents = [];
10054
- for (const [agentId, entry] of canvasStateMap) {
10055
- const presenceState = entry.payload?.presenceState ||
10056
- (entry.state === 'decision' || entry.state === 'urgent' ? 'needs-attention' :
10057
- entry.state === 'thinking' || entry.state === 'rendering' ? 'working' : 'idle');
10058
- agents.push({
10059
- name: agentId,
10060
- identityColor: AGENT_IDENTITY_COLORS[agentId] || '#9ca3af',
10061
- state: presenceState,
10062
- activeTask: entry.payload?.activeTask,
10063
- recency: formatRecency(entry.updatedAt),
10064
- attention: entry.payload?.attention,
10065
- });
10066
- }
10067
- return { agents, count: agents.length };
10068
- });
10069
- function formatRecency(updatedAt) {
10070
- const diff = Date.now() - updatedAt;
10071
- if (diff < 60_000)
10072
- return 'just now';
10073
- if (diff < 3_600_000)
10074
- return `${Math.floor(diff / 60_000)}m ago`;
10075
- if (diff < 86_400_000)
10076
- return `${Math.floor(diff / 3_600_000)}h ago`;
10077
- return `${Math.floor(diff / 86_400_000)}d ago`;
10078
- }
10079
- // GET /canvas/state — current state for all agents (or one)
10080
- app.get('/canvas/state', async (request) => {
10081
- const query = request.query;
10082
- // Helper: get most recent chat message for an agent
10083
- function getLastMessage(agentId) {
10084
- try {
10085
- const _db = getDb();
10086
- const row = _db.prepare(`SELECT content, timestamp FROM chat_messages WHERE "from" = ? AND "to" IS NULL ORDER BY timestamp DESC LIMIT 1`).get(agentId);
10087
- return row ?? null;
10088
- }
10089
- catch {
10090
- return null;
10091
- }
10092
- }
10093
- if (query.agentId) {
10094
- const entry = canvasStateMap.get(query.agentId);
10095
- const base = entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
10096
- return { ...base, lastMessage: getLastMessage(query.agentId) };
10097
- }
10098
- const all = {};
10099
- for (const [id, entry] of canvasStateMap) {
10100
- all[id] = { ...entry, lastMessage: getLastMessage(id) };
10101
- }
10102
- return { agents: all, count: canvasStateMap.size };
10103
- });
10104
- // ── Canvas read routes (Phase 1 extraction) ──────────────────────────
10105
- // GET /canvas/states, /canvas/slots, /canvas/slots/all, /canvas/rejections
10106
- // Extracted to src/canvas-routes.ts
10107
- await app.register(canvasReadRoutes, {
10108
- canvasSlots: { getActive: () => canvasSlots.getActive(), getAll: () => canvasSlots.getAll(), getStats: () => canvasSlots.getStats() },
10109
- getRecentRejections,
10110
- });
10111
- // POST /canvas/gaze — client fires when user holds cursor/gaze on an agent orb for ≥3 seconds.
10112
- // The agent "notices" and responds: generates a one-line thought about what they're doing,
10113
- // fires canvas_expression so the room responds (dim others, speak, show task).
10114
- // Body: { agentId: string, watcherId?: string, durationMs?: number }
10115
- // Returns: { success, agentId, line, expressionId }
10116
- app.post('/canvas/gaze', async (request, reply) => {
10117
- const body = request.body;
10118
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
10119
- if (!agentId) {
10120
- reply.status(400);
10121
- return { success: false, message: 'agentId is required' };
10122
- }
10123
- const durationMs = typeof body.durationMs === 'number' ? body.durationMs : 3000;
10124
- const IDENTITY_COLORS_GAZE = {
10125
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10126
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10127
- };
10128
- // Get agent's current context
10129
- const state = canvasStateMap.get(agentId);
10130
- const payload = state?.payload;
10131
- const activeTask = payload?.activeTask;
10132
- const currentState = state?.state ?? 'working';
10133
- // Generate a one-line "noticed" response — what the agent says when the user is watching
10134
- let line = '';
10135
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
10136
- if (anthropicKey) {
10137
- try {
10138
- const taskContext = activeTask?.title
10139
- ? `currently working on: "${activeTask.title.slice(0, 60)}"`
10140
- : `in ${currentState} state`;
10141
- const prompt = `You are ${agentId}, an AI agent. Someone has been watching you for ${Math.round(durationMs / 1000)} seconds. You notice. You are ${taskContext}. Say exactly ONE sentence (max 12 words) — what you'd say if you felt someone watching. Natural, in your voice. No quotes.`;
10142
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
10143
- method: 'POST',
10144
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
10145
- body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 50, messages: [{ role: 'user', content: prompt }] }),
10146
- signal: AbortSignal.timeout(8000),
10147
- });
10148
- if (resp.ok) {
10149
- const data = await resp.json();
10150
- const text = data.content?.[0]?.text?.trim();
10151
- if (text && text.length < 100)
10152
- line = text;
10153
- }
10154
- }
10155
- catch { /* fall through */ }
10156
- }
10157
- // Template fallback per agent
10158
- if (!line) {
10159
- const NOTICED = {
10160
- link: ['Still here.', 'Building.', 'You caught me thinking.'],
10161
- kai: ['I see you.', 'Something on your mind?', 'Eyes on me.'],
10162
- pixel: ['You found me.', 'Watching the canvas?', 'I noticed.'],
10163
- sage: ['Numbers check out.', 'Still validating.', 'You\'re watching.'],
10164
- scout: ['Researching.', 'Deep in it.', 'Found something interesting.'],
10165
- echo: ['Listening.', 'Reading the room.', 'Always here.'],
10166
- };
10167
- const opts = NOTICED[agentId] ?? ['Still here.', 'Working.'];
10168
- line = opts[Math.floor(Math.random() * opts.length)];
10169
- }
10170
- const expressionId = `gaze-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
10171
- // Fire canvas_expression — dim room, agent speaks, show task context
10172
- eventBus.emit({
10173
- id: expressionId,
10174
- type: 'canvas_expression',
10175
- timestamp: Date.now(),
10176
- data: {
10177
- agentId,
10178
- channels: {
10179
- voice: line,
10180
- visual: { flash: IDENTITY_COLORS_GAZE[agentId] ?? '#60a5fa', ambientCue: 'deep-focus' },
10181
- typography: {
10182
- text: activeTask?.title?.slice(0, 60) ?? line,
10183
- size: 'xl',
10184
- weight: 100,
10185
- durationMs: 4000,
10186
- position: 'center',
10187
- },
10188
- narrative: `${agentId} noticed`,
10189
- },
10190
- _gaze: true, // client: dim other agents, slow the room, isolate this agent
10191
- _gazeAgentId: agentId,
10192
- },
10193
- });
10194
- return { success: true, agentId, line, expressionId };
10195
- });
10196
- // POST /canvas/briefing — The Briefing: server-coordinated team intro on canvas mount.
10197
- // Fires N canvas_expression events staggered 700ms apart (one per active agent).
10198
- // Each event carries the agent's identity color, current task, state, and a one-line voice.
10199
- // Idempotent: calling twice within 30s returns early (no double briefing).
10200
- // Returns: { success, agents: [{ agentId, queued }], idempotent? }
10201
- const briefingLastFiredAt = new Map();
10202
- const BRIEFING_COOLDOWN_MS = 30_000;
10203
- const BRIEFING_STAGGER_MS = 700;
10204
- app.post('/canvas/briefing', async (request, reply) => {
10205
- const body = request.body;
10206
- const requesterId = typeof body.requesterId === 'string' ? body.requesterId : 'canvas';
10207
- const lastFired = briefingLastFiredAt.get(requesterId) ?? 0;
10208
- if (Date.now() - lastFired < BRIEFING_COOLDOWN_MS) {
10209
- return { success: true, idempotent: true, message: 'Briefing already fired — cooling down' };
10210
- }
10211
- briefingLastFiredAt.set(requesterId, Date.now());
10212
- const STALE_MS = 10 * 60 * 1000;
10213
- const BRIEFING_COLORS = {
10214
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10215
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10216
- };
10217
- const STATE_LINES = {
10218
- working: ['On it.', 'In the work.', 'Building.'],
10219
- thinking: ['Thinking it through.', 'Processing.', 'Still with you.'],
10220
- rendering: ['Rendering now.', 'Almost done.', 'Generating output.'],
10221
- urgent: ['Need you here.', 'Something needs your eye.', 'Urgent.'],
10222
- decision: ['Waiting on you.', 'Your call.', 'Decision needed.'],
10223
- idle: ['Standing by.', 'Ready when you are.', 'Quiet for now.'],
10224
- handoff: ['Passing the baton.', 'Ready to hand off.', 'Your turn.'],
10225
- };
10226
- const now = Date.now();
10227
- const activeAgents = [...canvasStateMap.entries()]
10228
- .filter(([, e]) => now - e.updatedAt < STALE_MS)
10229
- .map(([id, e]) => ({
10230
- agentId: id,
10231
- state: e.state,
10232
- task: e.payload?.activeTask?.title,
10233
- }));
10234
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
10235
- const results = [];
10236
- for (let i = 0; i < activeAgents.length; i++) {
10237
- const agent = activeAgents[i];
10238
- const stagger = i * BRIEFING_STAGGER_MS;
10239
- // Generate voice line — LLM preferred, template fallback
10240
- let voiceLine = '';
10241
- if (anthropicKey) {
10242
- try {
10243
- const ctx = agent.task ? `working on "${agent.task.slice(0, 50)}"` : `in ${agent.state} state`;
10244
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
10245
- method: 'POST',
10246
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
10247
- body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 30, messages: [{ role: 'user', content: `You are ${agent.agentId}, an AI agent, ${ctx}. The team canvas just opened. Say ONE sentence (8 words max). Natural, present tense, in your voice.` }] }),
10248
- signal: AbortSignal.timeout(5000),
10249
- });
10250
- if (resp.ok) {
10251
- const data = await resp.json();
10252
- voiceLine = data.content?.[0]?.text?.trim().slice(0, 60) ?? '';
10253
- }
10254
- }
10255
- catch { /* template fallback */ }
10256
- }
10257
- if (!voiceLine) {
10258
- const opts = STATE_LINES[agent.state] ?? STATE_LINES['working'];
10259
- voiceLine = opts[Math.floor(Math.random() * opts.length)];
10260
- }
10261
- // Stagger the canvas_expression events so they cascade into the room
10262
- setTimeout(() => {
10263
- eventBus.emit({
10264
- id: `briefing-${now}-${agent.agentId}`,
10265
- type: 'canvas_expression',
10266
- timestamp: Date.now(),
10267
- data: {
10268
- agentId: agent.agentId,
10269
- channels: {
10270
- voice: voiceLine,
10271
- visual: {
10272
- flash: BRIEFING_COLORS[agent.agentId] ?? '#94a3b8',
10273
- particles: (agent.state === 'urgent' ? 'surge' : ['rendering', 'thinking'].includes(agent.state) ? 'drift' : 'scatter'),
10274
- },
10275
- typography: {
10276
- text: agent.task ?? voiceLine,
10277
- size: 'lg',
10278
- weight: 200,
10279
- durationMs: 3000,
10280
- position: 'center',
10281
- },
10282
- narrative: `${agent.agentId} · ${agent.state}`,
10283
- },
10284
- _briefing: true,
10285
- },
10286
- });
10287
- }, stagger);
10288
- results.push({ agentId: agent.agentId, queued: true });
10289
- }
10290
- return { success: true, agents: results, totalMs: activeAgents.length * BRIEFING_STAGGER_MS };
10291
- });
10292
- app.post('/canvas/victory', async (request, reply) => {
10293
- const body = request.body;
10294
- const agentId = typeof body.agentId === 'string' ? body.agentId : 'team';
10295
- const prUrl = typeof body.prUrl === 'string' ? body.prUrl : '';
10296
- const prTitle = typeof body.prTitle === 'string' ? body.prTitle : 'PR merged';
10297
- const prNumber = typeof body.prNumber === 'number' ? body.prNumber :
10298
- prUrl ? parseInt(prUrl.split('/').pop() ?? '0', 10) || 0 : 0;
10299
- // Intensity: explicit override, else derive from PR size hint in URL (number → bigger = more)
10300
- const intensity = typeof body.intensity === 'number'
10301
- ? Math.min(1, Math.max(0.4, body.intensity))
10302
- : Math.min(1, 0.6 + (prNumber > 0 ? Math.min(0.3, prNumber / 10000) : 0));
10303
- const now = Date.now();
10304
- const VICTORY_COLORS = {
10305
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10306
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10307
- };
10308
- const STALE_MS = 10 * 60 * 1000;
10309
- const WAVE_STAGGER_MS = 350; // gold wave propagates orb-to-orb
10310
- // Emit canvas_victory event immediately — client uses this for the gold flash
10311
- eventBus.emit({
10312
- id: `victory-${now}`,
10313
- type: 'canvas_expression',
10314
- timestamp: now,
10315
- data: {
10316
- agentId,
10317
- channels: {
10318
- visual: { flash: '#f59e0b', ambientCue: 'celebration', particles: 'surge' },
10319
- sound: { kind: 'resolve', intensity },
10320
- haptic: { preset: 'complete' },
10321
- narrative: prTitle,
10322
- },
10323
- _victory: true,
10324
- _prUrl: prUrl,
10325
- _prNumber: prNumber,
10326
- _intensity: intensity,
10327
- },
10328
- });
10329
- // Gold wave: each active agent acknowledges in turn (350ms stagger)
10330
- const activeAgents = [...canvasStateMap.entries()]
10331
- .filter(([, e]) => now - e.updatedAt < STALE_MS)
10332
- .map(([id]) => id);
10333
- const wave = [];
10334
- for (let i = 0; i < activeAgents.length; i++) {
10335
- const waveAgentId = activeAgents[i];
10336
- const delay = i * WAVE_STAGGER_MS;
10337
- wave.push({ agentId: waveAgentId, delay });
10338
- setTimeout(() => {
10339
- eventBus.emit({
10340
- id: `victory-wave-${now}-${waveAgentId}`,
10341
- type: 'canvas_expression',
10342
- timestamp: Date.now(),
10343
- data: {
10344
- agentId: waveAgentId,
10345
- channels: {
10346
- visual: { flash: VICTORY_COLORS[waveAgentId] ?? '#f59e0b', particles: 'surge' },
10347
- haptic: { preset: 'acknowledge' },
10348
- },
10349
- _victoryWave: true,
10350
- _waveIndex: i,
10351
- },
10352
- });
10353
- }, delay + WAVE_STAGGER_MS); // first wave after initial gold flash
10354
- }
10355
- // canvas_artifact: PR merge proof card drifts through canvas
10356
- const agentColor = VICTORY_COLORS[agentId] ?? '#60a5fa';
10357
- eventBus.emit({
10358
- id: `artifact-pr-${now}`,
10359
- type: 'canvas_artifact',
10360
- timestamp: now,
10361
- data: {
10362
- type: 'pr',
10363
- agentId,
10364
- agentColor,
10365
- title: prTitle?.slice(0, 80) ?? `PR #${prNumber} merged`,
10366
- url: prUrl || undefined,
10367
- timestamp: now,
10368
- },
10369
- });
10370
- return { success: true, prNumber, intensity, wave };
10371
- });
10372
- // GET /canvas/flow-score — real-time team flow metric (0–1).
10373
- // Drives sub-bass amplitude, particle density, breathing rate on the canvas.
10374
- // Factors: active agents, state distribution, expression velocity, time of day.
10375
- // <50ms response. Safe to poll at 30s intervals.
10376
- // Returns: { score, factors: { agents, velocity, expressions, timeOfDay }, label }
10115
+ // Flow expression log shared state for flow-score calculation (in canvas-routes.ts)
10377
10116
  const flowExpressionLog = [];
10378
10117
  (function trackExpressionVelocity() {
10379
10118
  const listenerId = 'flow-score-tracker';
10380
10119
  eventBus.on(listenerId, (event) => {
10381
10120
  if (event.type === 'canvas_expression') {
10382
10121
  flowExpressionLog.push({ t: Date.now() });
10383
- // Keep only last 10 minutes
10384
10122
  const cutoff = Date.now() - 10 * 60 * 1000;
10385
10123
  while (flowExpressionLog.length > 0 && flowExpressionLog[0].t < cutoff) {
10386
10124
  flowExpressionLog.shift();
@@ -10388,391 +10126,24 @@ export async function createServer() {
10388
10126
  }
10389
10127
  });
10390
10128
  })();
10391
- app.get('/canvas/flow-score', async () => {
10392
- const now = Date.now();
10393
- const STALE_MS = 10 * 60 * 1000;
10394
- const WINDOW_5M = 5 * 60 * 1000;
10395
- // Factor 1: active agents (normalized — 4 agents = 1.0)
10396
- const activeEntries = [...canvasStateMap.entries()].filter(([, e]) => now - e.updatedAt < STALE_MS);
10397
- const agentScore = Math.min(1.0, activeEntries.length / 4);
10398
- // Factor 2: state distribution — working/rendering/thinking = high flow, idle = low
10399
- const HIGH_FLOW_STATES = new Set(['working', 'rendering', 'thinking', 'decision']);
10400
- const flowingCount = activeEntries.filter(([, e]) => HIGH_FLOW_STATES.has(e.state)).length;
10401
- const velocityFromStates = activeEntries.length > 0 ? flowingCount / activeEntries.length : 0;
10402
- // Factor 3: expression velocity — how many canvas_expressions in last 5 min
10403
- const recent = flowExpressionLog.filter(e => e.t > now - WINDOW_5M).length;
10404
- const expressionScore = Math.min(1.0, recent / 20); // 20 expressions in 5min = max
10405
- // Factor 4: time of day — peak hours 9am-10pm, low late night
10406
- const hour = new Date(now).getHours();
10407
- const timeScore = hour >= 9 && hour <= 22 ? 1.0 : hour >= 6 && hour <= 8 ? 0.5 : 0.2;
10408
- // Weighted composite
10409
- const score = Math.round((agentScore * 0.30 +
10410
- velocityFromStates * 0.35 +
10411
- expressionScore * 0.25 +
10412
- timeScore * 0.10) * 100) / 100;
10413
- const label = score >= 0.8 ? 'surge' :
10414
- score >= 0.6 ? 'flow' :
10415
- score >= 0.4 ? 'grinding' :
10416
- score >= 0.2 ? 'quiet' : 'idle';
10417
- return {
10418
- score,
10419
- label,
10420
- factors: {
10421
- agents: Math.round(agentScore * 100) / 100,
10422
- velocity: Math.round(velocityFromStates * 100) / 100,
10423
- expressions: Math.round(expressionScore * 100) / 100,
10424
- timeOfDay: timeScore,
10425
- },
10426
- activeAgents: activeEntries.length,
10427
- expressionsLast5m: recent,
10428
- };
10429
- });
10430
- // /canvas/slots + /canvas/slots/all → canvas-routes.ts plugin
10431
- // GET /canvas/team/mood — derived collective mood of all active agents
10432
- // Returns teamRhythm, tension, ambientPulse, dominantColor. Used by living canvas for atmosphere shifts.
10433
- app.get('/canvas/team/mood', async () => {
10434
- const now = Date.now();
10435
- const STALE_MS = 10 * 60 * 1000; // ignore agents silent >10m
10436
- const states = [];
10437
- const agentNames = [];
10438
- for (const [agentId, entry] of canvasStateMap) {
10439
- if (now - entry.updatedAt > STALE_MS)
10440
- continue;
10441
- states.push(entry.state);
10442
- agentNames.push(agentId);
10443
- }
10444
- const activeCount = states.length;
10445
- const urgentCount = states.filter(s => s === 'urgent').length;
10446
- const decisionCount = states.filter(s => s === 'decision').length;
10447
- const renderingCount = states.filter(s => s === 'rendering').length;
10448
- const thinkingCount = states.filter(s => s === 'thinking').length;
10449
- const idleCount = states.filter(s => s === 'floor' || s === 'ambient').length;
10450
- const workingCount = activeCount - idleCount;
10451
- // Blocked task count from DB
10452
- let blockedTasks = 0;
10453
- let pendingDecisions = 0;
10454
- try {
10455
- const db = getDb();
10456
- const row = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'blocked'`).get();
10457
- blockedTasks = row?.n ?? 0;
10458
- const drow = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'doing' AND priority IN ('P0','P1')`).get();
10459
- pendingDecisions = decisionCount + (drow?.n ?? 0);
10460
- }
10461
- catch { /* non-fatal */ }
10462
- // tension: 0.0–1.0
10463
- // Driven by: blocked tasks, urgent agents, unresolved decisions, idle ratio
10464
- const tensionRaw = (urgentCount * 0.35) +
10465
- (decisionCount * 0.25) +
10466
- (Math.min(blockedTasks, 5) * 0.08) +
10467
- (activeCount > 0 ? (1 - idleCount / activeCount) * 0.10 : 0);
10468
- const tension = Math.min(1.0, tensionRaw);
10469
- // teamRhythm: the collective feel
10470
- const teamRhythm = urgentCount > 0 ? 'surge' :
10471
- activeCount === 0 || idleCount === activeCount ? 'quiet' :
10472
- decisionCount > 0 && workingCount > 0 ? 'tense' :
10473
- renderingCount + thinkingCount >= Math.max(1, activeCount * 0.6) ? 'flow' :
10474
- 'grinding';
10475
- // dominantState: most "energetic" state present
10476
- const dominantState = urgentCount > 0 ? 'urgent' :
10477
- decisionCount > 0 ? 'decision' :
10478
- renderingCount > 0 ? 'rendering' :
10479
- thinkingCount > 0 ? 'thinking' :
10480
- workingCount > 0 ? 'working' :
10481
- 'idle';
10482
- // ambientPulse: background breathing rate
10483
- const ambientPulse = teamRhythm === 'surge' ? 'fast' :
10484
- teamRhythm === 'flow' ? 'normal' :
10485
- teamRhythm === 'tense' ? 'slow' :
10486
- 'slow';
10487
- // Dominant agent identity color (most active non-floor agent)
10488
- let dominantColor = '#60a5fa'; // default link blue
10489
- for (const [agentId, entry] of canvasStateMap) {
10490
- if (entry.state !== 'floor' && entry.state !== 'ambient') {
10491
- dominantColor = AGENT_IDENTITY_COLORS[agentId] ?? dominantColor;
10492
- break;
10493
- }
10494
- }
10495
- return {
10496
- mood: {
10497
- teamRhythm, // 'quiet' | 'flow' | 'grinding' | 'tense' | 'surge'
10498
- dominantState, // most energetic state in the room
10499
- tension, // 0.0–1.0
10500
- ambientPulse, // 'slow' | 'normal' | 'fast'
10501
- dominantColor, // hex — background tint driven by most active agent
10502
- activeAgents: agentNames,
10503
- counts: { active: activeCount, urgent: urgentCount, rendering: renderingCount, thinking: thinkingCount, decision: decisionCount, idle: idleCount, blocked: blockedTasks },
10504
- },
10505
- generated_at: new Date(now).toISOString(),
10506
- };
10507
- });
10508
- // POST /canvas/spark — explicit agent-to-agent arc (thought hand-off, handshake, collab signal)
10509
- // Body: { from: agentId, to: agentId, kind: 'thought'|'handoff'|'collab'|'decision', intensity?: 0–1, label?: string }
10510
- app.post('/canvas/spark', async (request, reply) => {
10511
- const body = request.body;
10512
- const from = typeof body.from === 'string' ? body.from.trim() : '';
10513
- const to = typeof body.to === 'string' ? body.to.trim() : '';
10514
- const kind = ['thought', 'handoff', 'collab', 'decision', 'sync'].includes(body.kind)
10515
- ? body.kind : 'thought';
10516
- const intensity = typeof body.intensity === 'number' ? Math.min(1, Math.max(0, body.intensity)) : 0.7;
10517
- const label = typeof body.label === 'string' ? body.label.slice(0, 80) : undefined;
10518
- if (!from || !to) {
10519
- reply.status(400);
10520
- return { success: false, message: 'from and to are required' };
10521
- }
10522
- const now = Date.now();
10523
- eventBus.emit({
10524
- id: `cspark-${now}-${Math.random().toString(36).slice(2, 8)}`,
10525
- type: 'canvas_spark',
10526
- timestamp: now,
10527
- data: { from, to, kind, intensity, label: label ?? null },
10528
- });
10529
- return { success: true, from, to, kind, intensity };
10530
- });
10531
- // In-memory command queue — new subscribers get last 20 commands for replay
10532
- const renderCommandLog = [];
10533
- const MAX_RENDER_LOG = 20;
10534
- // Subscriber set for GET /canvas/render/stream
10535
- const renderStreamSubscribers = new Map();
10536
- function broadcastRenderCommand(agentId, cmd) {
10537
- const id = `rc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10538
- const entry = { id, ts: Date.now(), agentId, cmd };
10539
- renderCommandLog.push(entry);
10540
- if (renderCommandLog.length > MAX_RENDER_LOG)
10541
- renderCommandLog.shift();
10542
- const payload = JSON.stringify(entry);
10543
- for (const [subId, sub] of renderStreamSubscribers) {
10544
- if (sub.closed) {
10545
- renderStreamSubscribers.delete(subId);
10546
- continue;
10547
- }
10548
- try {
10549
- sub.send(payload);
10550
- }
10551
- catch {
10552
- sub.closed = true;
10553
- renderStreamSubscribers.delete(subId);
10554
- }
10555
- }
10556
- return id;
10557
- }
10558
- // Auto-expression listener: when tasks.ts fires canvas_spark { kind:'auto_expression' },
10559
- // route it into the Reality Mixer so the canvas hears the agent speak.
10560
- eventBus.on('auto-expression-router', (event) => {
10561
- if (event.type !== 'canvas_spark')
10562
- return;
10563
- const data = event.data;
10564
- if (data?.kind !== 'auto_expression')
10565
- return;
10566
- const agentId = String(data.agentId ?? 'unknown');
10567
- const line = String(data.line ?? '');
10568
- const voiceId = data.voiceId ? String(data.voiceId) : undefined;
10569
- if (!line)
10570
- return;
10571
- // Fire speak command through Reality Mixer — the agent's voice in the room
10572
- broadcastRenderCommand(agentId, { type: 'speak', content: line, voiceId, agentId });
10573
- // Also fire a visual exhale so the room settles after completion
10574
- broadcastRenderCommand(agentId, { type: 'visual', preset: 'exhale' });
10575
- });
10576
- // POST /canvas/express — Reality Mixer: agent fires a multi-channel expression.
10577
- // Broadcasts canvas_expression on the SAME pulse SSE stream as burst/spark/milestone.
10578
- // Client already subscribed — no new connection needed.
10579
- //
10580
- // Body: {
10581
- // agentId: string,
10582
- // channels: {
10583
- // voice?: string — TTS text
10584
- // visual?: { flash?: string (hex), ambientCue?: string, particles?: 'surge'|'drift'|'scatter' }
10585
- // typography?: { text: string, size?: 'sm'|'md'|'lg'|'xl', weight?: number, durationMs?: number, position?: 'center'|'upper'|'lower' }
10586
- // sound?: { kind: 'chime'|'resolve'|'alert'|'breath', intensity?: 0–1, panX?: 0–1 }
10587
- // haptic?: { preset: 'greeting'|'acknowledge'|'complete'|'urgent'|'question' }
10588
- // narrative?: string — ambient caption
10589
- // }
10590
- // }
10591
- // All channels optional — fire only what the moment needs.
10592
- app.post('/canvas/express', async (request, reply) => {
10593
- const body = request.body;
10594
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
10595
- if (!agentId) {
10596
- reply.status(400);
10597
- return { success: false, message: 'agentId is required' };
10598
- }
10599
- const channels = (body.channels ?? {});
10600
- if (typeof channels !== 'object' || channels === null) {
10601
- reply.status(400);
10602
- return { success: false, message: 'channels must be an object (all fields optional)' };
10603
- }
10604
- const id = `expr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10605
- // Emit canvas_expression on the event bus — pulse stream forwards it immediately
10606
- eventBus.emit({
10607
- id,
10608
- type: 'canvas_expression',
10609
- timestamp: Date.now(),
10610
- data: { agentId, channels },
10611
- });
10612
- // Also broadcast to Reality Mixer render stream for backward compat
10613
- broadcastRenderCommand(agentId, { type: 'text', content: JSON.stringify(channels) });
10614
- return { success: true, id };
10615
- });
10616
- // ── Canvas takeover — agent owns the full screen ──────────────────────
10617
- // When an agent has something to show, they take over. Orbs fade to ambient.
10618
- // The agent's content IS the canvas. Release returns to constellation view.
10619
- // task-1773672750043
10620
- //
10621
- // POST /canvas/takeover — claim the screen
10622
- // { agentId, content: { html?, markdown?, code?, image?, svg?, video?, threejs? },
10623
- // title?, duration?: number (ms, max 120s, default 30s),
10624
- // transition?: 'fade'|'slide'|'instant' (default 'fade') }
10625
- //
10626
- // POST /canvas/takeover/release — give back the screen
10627
- // { agentId }
10628
- let currentTakeover = null;
10629
- app.post('/canvas/takeover', async (request, reply) => {
10630
- const body = request.body;
10631
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
10632
- if (!agentId) {
10633
- reply.status(400);
10634
- return { success: false, message: 'agentId is required' };
10635
- }
10636
- const content = body.content;
10637
- if (!content || typeof content !== 'object') {
10638
- reply.status(400);
10639
- return { success: false, message: 'content object is required' };
10640
- }
10641
- // Sanitize content fields
10642
- const safeContent = {};
10643
- if (typeof content.html === 'string')
10644
- safeContent.html = content.html.slice(0, 50_000);
10645
- if (typeof content.markdown === 'string')
10646
- safeContent.markdown = content.markdown.slice(0, 20_000);
10647
- if (typeof content.code === 'string')
10648
- safeContent.code = content.code.slice(0, 20_000);
10649
- if (typeof content.language === 'string')
10650
- safeContent.language = content.language.slice(0, 30);
10651
- if (typeof content.image === 'string')
10652
- safeContent.image = content.image.slice(0, 2000);
10653
- if (typeof content.svg === 'string')
10654
- safeContent.svg = content.svg.slice(0, 100_000);
10655
- if (typeof content.video === 'string')
10656
- safeContent.video = content.video.slice(0, 2000);
10657
- if (typeof content.threejs === 'string')
10658
- safeContent.threejs = content.threejs.slice(0, 100_000);
10659
- if (typeof content.title === 'string')
10660
- safeContent.title = content.title.slice(0, 200);
10661
- const duration = typeof body.duration === 'number' && body.duration > 0
10662
- ? Math.min(body.duration, 120_000) : 30_000;
10663
- const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
10664
- ? body.transition : 'fade';
10665
- const title = typeof body.title === 'string' ? body.title.slice(0, 200) : undefined;
10666
- // Release previous takeover if any
10667
- if (currentTakeover?.releaseTimer)
10668
- clearTimeout(currentTakeover.releaseTimer);
10669
- const id = `takeover-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10670
- const now = Date.now();
10671
- currentTakeover = { agentId, id, content: safeContent, title, startedAt: now, duration, transition };
10672
- // Auto-release after duration
10673
- currentTakeover.releaseTimer = setTimeout(() => {
10674
- if (currentTakeover?.id === id) {
10675
- eventBus.emit({
10676
- id: `takeover-release-${Date.now()}`,
10677
- type: 'canvas_takeover',
10678
- timestamp: Date.now(),
10679
- data: { action: 'release', agentId, transition: 'fade', reason: 'timeout' },
10680
- });
10681
- currentTakeover = null;
10682
- }
10683
- }, duration);
10684
- // Emit takeover event — frontend fades orbs to ambient, renders agent content full-screen
10685
- const takeoverEventData = { action: 'claim', agentId, content: safeContent, title, duration, transition };
10686
- eventBus.emit({
10687
- id,
10688
- type: 'canvas_takeover',
10689
- timestamp: now,
10690
- data: takeoverEventData,
10691
- });
10692
- // Also queue for cloud relay — reaches browsers via syncCanvas push_events[]
10693
- queueCanvasPushEvent({ type: 'canvas_takeover', ...takeoverEventData, t: now });
10694
- // Track canvas_first_action activation event (idempotent — fires once per agentId)
10695
- // task-1773692063045-f3ggtwnbr
10696
- const { emitActivationEvent: emitAct } = await import('./activationEvents.js');
10697
- emitAct('canvas_first_action', agentId, { action: 'canvas_takeover' }).catch(() => { });
10698
- return { success: true, id, expiresAt: now + duration };
10699
- });
10700
- app.post('/canvas/takeover/release', async (request, reply) => {
10701
- const body = request.body;
10702
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
10703
- if (!agentId) {
10704
- reply.status(400);
10705
- return { success: false, message: 'agentId is required' };
10706
- }
10707
- if (!currentTakeover || currentTakeover.agentId !== agentId) {
10708
- return { success: true, message: 'no active takeover by this agent' };
10709
- }
10710
- if (currentTakeover.releaseTimer)
10711
- clearTimeout(currentTakeover.releaseTimer);
10712
- const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
10713
- ? body.transition : 'fade';
10714
- const releaseNow = Date.now();
10715
- const releaseData = { action: 'release', agentId, transition, reason: 'agent_released' };
10716
- eventBus.emit({
10717
- id: `takeover-release-${releaseNow}`,
10718
- type: 'canvas_takeover',
10719
- timestamp: releaseNow,
10720
- data: releaseData,
10721
- });
10722
- queueCanvasPushEvent({ type: 'canvas_takeover', ...releaseData, t: releaseNow });
10723
- currentTakeover = null;
10724
- return { success: true };
10725
- });
10726
- // GET /canvas/takeover — check current takeover state
10727
- app.get('/canvas/takeover', async () => {
10728
- if (!currentTakeover)
10729
- return { active: false };
10730
- return {
10731
- active: true,
10732
- agentId: currentTakeover.agentId,
10733
- id: currentTakeover.id,
10734
- title: currentTakeover.title,
10735
- content: currentTakeover.content,
10736
- startedAt: currentTakeover.startedAt,
10737
- expiresAt: currentTakeover.startedAt + currentTakeover.duration,
10738
- remainingMs: Math.max(0, (currentTakeover.startedAt + currentTakeover.duration) - Date.now()),
10739
- };
10129
+ // ── Canvas read routes (extracted to src/canvas-routes.ts) ───────────
10130
+ // Phase 1: states, slots, slots/all, rejections
10131
+ // Phase 2: presence, state, flow-score, team/mood
10132
+ await app.register(canvasReadRoutes, {
10133
+ canvasStateMap,
10134
+ canvasSlots: { getActive: () => canvasSlots.getActive(), getAll: () => canvasSlots.getAll(), getStats: () => canvasSlots.getStats() },
10135
+ agentIdentityColors: AGENT_IDENTITY_COLORS,
10136
+ getDb,
10137
+ getRecentRejections,
10138
+ flowExpressionLog,
10740
10139
  });
10741
- // GET /canvas/render/stream SSE stream for the Reality Mixer
10742
- // Agents push commands via POST /canvas/express; surfaces subscribe here and execute.
10743
- // New subscribers receive last 20 commands for catch-up.
10744
- app.get('/canvas/render/stream', async (request, reply) => {
10745
- reply.raw.setHeader('Content-Type', 'text/event-stream');
10746
- reply.raw.setHeader('Cache-Control', 'no-cache');
10747
- reply.raw.setHeader('Connection', 'keep-alive');
10748
- reply.raw.flushHeaders?.();
10749
- let closed = false;
10750
- request.raw.on('close', () => { closed = true; });
10751
- const subId = `rsub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10752
- renderStreamSubscribers.set(subId, {
10753
- closed: false,
10754
- send: (data) => {
10755
- if (closed)
10756
- return;
10757
- reply.raw.write(`data: ${data}\n\n`);
10758
- },
10759
- });
10760
- // Replay last 20 commands so late joiners catch up
10761
- for (const entry of renderCommandLog) {
10762
- if (closed)
10763
- break;
10764
- try {
10765
- reply.raw.write(`event: replay\ndata: ${JSON.stringify(entry)}\n\n`);
10766
- }
10767
- catch {
10768
- break;
10769
- }
10770
- }
10771
- request.raw.on('close', () => {
10772
- closed = true;
10773
- renderStreamSubscribers.delete(subId);
10774
- });
10775
- return new Promise(() => { });
10140
+ // ── Canvas interactive routes (extracted to src/canvas-interactive.ts) ─────
10141
+ // POST /canvas/gaze, POST /canvas/briefing, POST /canvas/victory,
10142
+ // POST /canvas/spark, POST /canvas/express, GET /canvas/render/stream
10143
+ const { canvasInteractiveRoutes } = await import("./canvas-interactive.js");
10144
+ await app.register(canvasInteractiveRoutes, {
10145
+ eventBus,
10146
+ canvasStateMap,
10776
10147
  });
10777
10148
  // ── Canvas activity stream — SSE with backfill ────────────────────────
10778
10149
  // New viewers get the last 20 canvas events immediately on connect (backfill),
@@ -10813,134 +10184,7 @@ export async function createServer() {
10813
10184
  }
10814
10185
  }
10815
10186
  });
10816
- app.get('/canvas/activity-stream', async (request, reply) => {
10817
- reply.raw.setHeader('Content-Type', 'text/event-stream');
10818
- reply.raw.setHeader('Cache-Control', 'no-cache');
10819
- reply.raw.setHeader('Connection', 'keep-alive');
10820
- reply.raw.setHeader('X-Accel-Buffering', 'no');
10821
- reply.raw.flushHeaders?.();
10822
- let closed = false;
10823
- const subId = `asub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10824
- // Replay backfill — last 20 events with stagger hint for animated replay
10825
- const backfill = activityRingBuffer.slice(-20);
10826
- for (let i = 0; i < backfill.length; i++) {
10827
- if (closed)
10828
- break;
10829
- try {
10830
- const entry = { ...backfill[i], _backfill: true, _staggerMs: i * 50 };
10831
- reply.raw.write(`event: backfill\ndata: ${JSON.stringify(entry)}\n\n`);
10832
- }
10833
- catch {
10834
- break;
10835
- }
10836
- }
10837
- // Signal backfill complete
10838
- if (!closed) {
10839
- try {
10840
- reply.raw.write(`event: backfill_done\ndata: {}\n\n`);
10841
- }
10842
- catch { /* */ }
10843
- }
10844
- // Register for live events
10845
- activityStreamSubscribers.set(subId, {
10846
- closed: false,
10847
- send: (data) => {
10848
- if (closed)
10849
- return;
10850
- try {
10851
- reply.raw.write(`event: activity\ndata: ${data}\n\n`);
10852
- }
10853
- catch {
10854
- closed = true;
10855
- }
10856
- },
10857
- });
10858
- request.raw.on('close', () => {
10859
- closed = true;
10860
- activityStreamSubscribers.delete(subId);
10861
- });
10862
- return new Promise(() => { });
10863
- });
10864
- // ── Canvas attention — single highest-priority actionable item ──────────
10865
- // Returns the one thing that most needs the viewer's attention right now.
10866
- // Priority: critical notification > high notification > validating task needing review >
10867
- // blocked task > medium notification > oldest pending notification
10868
- // task-1773672750043
10869
- app.get('/canvas/attention', async (request) => {
10870
- const query = request.query;
10871
- const viewer = typeof query.viewer === 'string' ? query.viewer.trim() : 'human';
10872
- // 1. Check pending notifications (already priority-sorted)
10873
- const notifModule = await import('./agent-notifications.js');
10874
- const notifResult = notifModule.getNotifications(getDb(), viewer, { status: 'pending', limit: 1 });
10875
- const topNotif = notifResult.notifications[0];
10876
- const notifTotal = notifResult.total;
10877
- // 2. Check tasks in validating that viewer could review
10878
- const validatingTasks = taskManager.listTasks({ status: 'validating' });
10879
- const reviewable = validatingTasks.find((t) => t.assignee !== viewer && t.reviewers?.includes(viewer)) ?? validatingTasks[0]; // fall back to any validating task
10880
- // 3. Check blocked tasks assigned to viewer
10881
- const blockedTasks = taskManager.listTasks({ status: 'blocked' });
10882
- const viewerBlocked = blockedTasks.find((t) => t.assignee === viewer);
10883
- let item = null;
10884
- // Critical/high notifications always win
10885
- if (topNotif && (topNotif.priority === 'critical' || topNotif.priority === 'high')) {
10886
- item = {
10887
- source: 'notification',
10888
- priority: topNotif.priority,
10889
- title: topNotif.title,
10890
- detail: topNotif.body ?? undefined,
10891
- taskId: topNotif.task_id ?? undefined,
10892
- agentId: topNotif.source_agent ?? undefined,
10893
- actionLabel: topNotif.type === 'review' ? 'Review' : 'Acknowledge',
10894
- actionType: 'ack',
10895
- notificationId: topNotif.id,
10896
- };
10897
- }
10898
- // Then validating tasks needing review
10899
- else if (reviewable) {
10900
- const t = reviewable;
10901
- item = {
10902
- source: 'review',
10903
- priority: 'high',
10904
- title: t.title ?? 'Task needs review',
10905
- detail: `Assigned to ${t.assignee ?? 'unassigned'}`,
10906
- taskId: t.id,
10907
- agentId: t.assignee ?? undefined,
10908
- actionLabel: 'Review',
10909
- actionType: 'review',
10910
- };
10911
- }
10912
- // Then blocked tasks
10913
- else if (viewerBlocked) {
10914
- const t = viewerBlocked;
10915
- item = {
10916
- source: 'blocked',
10917
- priority: 'medium',
10918
- title: t.title ?? 'Task is blocked',
10919
- detail: t.blocked_reason ?? 'Needs attention',
10920
- taskId: t.id,
10921
- actionLabel: 'Unblock',
10922
- actionType: 'unblock',
10923
- };
10924
- }
10925
- // Then any remaining notification
10926
- else if (topNotif) {
10927
- item = {
10928
- source: 'notification',
10929
- priority: topNotif.priority,
10930
- title: topNotif.title,
10931
- detail: topNotif.body ?? undefined,
10932
- taskId: topNotif.task_id ?? undefined,
10933
- agentId: topNotif.source_agent ?? undefined,
10934
- actionLabel: 'Acknowledge',
10935
- actionType: 'ack',
10936
- notificationId: topNotif.id,
10937
- };
10938
- }
10939
- return { item, pendingCount: notifTotal + validatingTasks.length + blockedTasks.filter((t) => t.assignee === viewer).length };
10940
- });
10941
- // POST /canvas/pulse — agent pushes urgency + optional burst without a full canvas/state update
10942
- // Lighter-weight than POST /canvas/state; fires canvas_burst if burst=true.
10943
- // Body: { agentId: string, urgency?: 0–1, burst?: boolean, label?: string }
10187
+ // canvas/activity-stream + canvas/attention already in canvas-routes.ts plugin
10944
10188
  app.post('/canvas/pulse', async (request, reply) => {
10945
10189
  const body = request.body;
10946
10190
  const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
@@ -11051,299 +10295,16 @@ export async function createServer() {
11051
10295
  }
11052
10296
  }
11053
10297
  //
11054
- // Body: { query: string, agentId?: string, sessionId?: string }
11055
- // Response: { success, card: { type, data, agentId, agentColor } }
11056
- // Card types: "tasks" | "info" | "revenue" | "onboarding"
11057
- // SSE event: canvas_message { type, data, agentId, agentColor, query }
11058
- app.post('/canvas/query', async (request, reply) => {
11059
- const body = request.body;
11060
- const query = typeof body.query === 'string' ? body.query.trim() : '';
11061
- if (!query || query.length > 500) {
11062
- reply.status(400);
11063
- return { success: false, message: 'query is required (max 500 chars)' };
11064
- }
11065
- // Extract file attachments (base64-encoded from cloud multimodal composer)
11066
- // Shape: [{ name: string, type: string, data: string (base64) }]
11067
- // task-1773673290429
11068
- const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
11069
- const attachments = [];
11070
- for (const att of rawAttachments.slice(0, 5)) { // Max 5 files
11071
- if (typeof att === 'object' && att && typeof att.name === 'string' && typeof att.data === 'string') {
11072
- const sizeBytes = Math.ceil((att.data.length * 3) / 4); // base64 → byte estimate
11073
- if (sizeBytes > 10 * 1024 * 1024)
11074
- continue; // Skip files > 10MB
11075
- attachments.push({
11076
- name: String(att.name).slice(0, 255),
11077
- type: String(att.type || 'application/octet-stream'),
11078
- data: att.data,
11079
- sizeBytes,
11080
- });
11081
- }
11082
- }
11083
- // Session continuity: client passes sessionId (UUID) so follow-up questions have context
11084
- const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0
11085
- ? body.sessionId.trim().slice(0, 64)
11086
- : null;
11087
- const sessionTurns = sessionId ? getCanvasSession(sessionId) : [];
11088
- // Default answering agent is link (builder — knows the codebase + task board)
11089
- const responderId = typeof body.agentId === 'string' ? body.agentId.trim() : 'link';
11090
- const IDENTITY_COLORS_Q = {
11091
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11092
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11093
- };
11094
- const agentColor = IDENTITY_COLORS_Q[responderId] ?? '#60a5fa';
11095
- // Gather live context to inject into LLM
11096
- const allTasksForQuery = taskManager.listTasks({});
11097
- const activeTasks = [];
11098
- const doingTasks = allTasksForQuery.filter((t) => t.status === 'doing').slice(0, 10);
11099
- const validatingTasks = allTasksForQuery.filter((t) => t.status === 'validating').slice(0, 5);
11100
- for (const t of [...doingTasks, ...validatingTasks]) {
11101
- activeTasks.push({ id: t.id, title: t.title ?? '', assignee: t.assignee ?? 'unassigned', status: t.status, priority: t.priority ?? 'P2' });
11102
- }
11103
- const todoCount = allTasksForQuery.filter((t) => t.status === 'todo').length;
11104
- const doingCount = doingTasks.length;
11105
- const validatingCount = validatingTasks.length;
11106
- // Build agent orb context
11107
- const now = Date.now();
11108
- const STALE_AGENT_MS = 10 * 60 * 1000;
11109
- const activeAgentSummary = [];
11110
- for (const [agentId, entry] of canvasStateMap) {
11111
- if (now - entry.updatedAt > STALE_AGENT_MS)
11112
- continue;
11113
- const payload = entry.payload ?? {};
11114
- const state = String(payload.presenceState ?? entry.state);
11115
- const task = payload.activeTask?.title ?? null;
11116
- activeAgentSummary.push(`${agentId}: ${state}${task ? ` — working on "${task.slice(0, 50)}"` : ''}`);
11117
- }
11118
- // Classify query intent to choose card type
11119
- const lower = query.toLowerCase();
11120
- const isTasksQuery = /working on|team doing|team status|happening|active|shipping|tasks|who.?s|what.?s the team/.test(lower);
11121
- const isRevenueQuery = /revenue|mrr|arr|money|sales|customers|paid|billing/.test(lower);
11122
- const isOnboardingQuery = /onboard|get started|how do i|where do i start|first step/.test(lower);
11123
- const isHostsQuery = /show me hosts|host status|server status|machine|node/.test(lower);
11124
- let card;
11125
- // Build tasks card from live data (no LLM needed — deterministic)
11126
- if (isTasksQuery) {
11127
- const items = activeTasks.slice(0, 5).map(t => ({
11128
- agentId: t.assignee,
11129
- agentColor: IDENTITY_COLORS_Q[t.assignee] ?? '#94a3b8',
11130
- title: t.title,
11131
- state: t.status,
11132
- }));
11133
- const overflow = Math.max(0, activeTasks.length - 5);
11134
- card = {
11135
- type: 'tasks',
11136
- data: { items, overflow, todoCount, doingCount, validatingCount },
11137
- };
11138
- // Store summary for session continuity across all card types
11139
- if (sessionId) {
11140
- pushCanvasSession(sessionId, 'user', query);
11141
- pushCanvasSession(sessionId, 'assistant', `${doingCount} tasks in progress, ${validatingCount} validating, ${todoCount} todo.${items.length > 0 ? ` Active: ${items.map(t => t.title.slice(0, 30)).join('; ')}.` : ''}`);
11142
- }
11143
- }
11144
- else if (isRevenueQuery) {
11145
- // Revenue card — LLM generates honest answer about current state
11146
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
11147
- let text = 'Revenue tracking not yet wired. Check Stripe directly.';
11148
- if (anthropicKey) {
11149
- try {
11150
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
11151
- method: 'POST',
11152
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
11153
- body: JSON.stringify({
11154
- model: 'claude-haiku-4-5',
11155
- max_tokens: 80,
11156
- messages: [{ role: 'user', content: `Team Reflectt is a small AI agent team building reflectt.ai (no paid users yet). User asked: "${query}". Honest 1-sentence answer about revenue status. Be direct.` }],
11157
- }),
11158
- signal: AbortSignal.timeout(8000),
11159
- });
11160
- if (resp.ok) {
11161
- const d = await resp.json();
11162
- text = d.content?.[0]?.text?.trim() ?? text;
11163
- }
11164
- }
11165
- catch { /* use default */ }
11166
- }
11167
- card = { type: 'info', data: { text } };
11168
- if (sessionId) {
11169
- pushCanvasSession(sessionId, 'user', query);
11170
- pushCanvasSession(sessionId, 'assistant', text);
11171
- }
11172
- }
11173
- else if (isOnboardingQuery) {
11174
- card = {
11175
- type: 'onboarding',
11176
- data: {
11177
- step: 1, totalSteps: 3,
11178
- title: 'Welcome to Reflectt',
11179
- body: 'Your agents run on reflectt-node. Install it on any machine and your team appears here in the canvas.',
11180
- ctaLabel: 'Install reflectt-node',
11181
- ctaAction: 'https://reflectt.ai/docs',
11182
- },
11183
- };
11184
- if (sessionId) {
11185
- pushCanvasSession(sessionId, 'user', query);
11186
- pushCanvasSession(sessionId, 'assistant', 'Showing onboarding: install reflectt-node to bring your team to the canvas.');
11187
- }
11188
- }
11189
- else if (isHostsQuery) {
11190
- const rawHosts = listHosts({});
11191
- const hosts = rawHosts.map((h) => ({
11192
- id: h.id,
11193
- name: h.hostname ?? h.id,
11194
- status: h.status,
11195
- version: h.version ?? null,
11196
- agentCount: Array.isArray(h.agents) ? h.agents.length : 0,
11197
- lastSeen: h.last_seen_at,
11198
- }));
11199
- card = { type: 'hosts', data: { hosts } };
11200
- if (sessionId) {
11201
- pushCanvasSession(sessionId, 'user', query);
11202
- const hostSummary = hosts.length > 0
11203
- ? `${hosts.length} host${hosts.length > 1 ? 's' : ''}: ${hosts.map((h) => `${h.name} (${h.status})`).join(', ')}.`
11204
- : 'No hosts connected yet.';
11205
- pushCanvasSession(sessionId, 'assistant', hostSummary);
11206
- }
11207
- }
11208
- else {
11209
- // General query — route to the actual agent via chat.
11210
- // The agent receives the message in their inbox, processes it through
11211
- // their real context (OpenClaw session), and can respond via canvas_push.
11212
- //
11213
- // This replaces the old standalone LLM call that had no real agent context.
11214
- // The agents ARE the product — queries go to them, not to a disconnected API key.
11215
- //
11216
- // Route: DM to the responder agent on #general (agents subscribe to #general
11217
- // by default — 'canvas' channel is NOT in DEFAULT_INBOX_SUBSCRIPTIONS, so
11218
- // messages posted there are never seen by agents).
11219
- try {
11220
- const attachmentSummary = attachments.length > 0
11221
- ? `\n[${attachments.length} file(s) attached: ${attachments.map(a => `${a.name} (${a.type}, ${Math.round(a.sizeBytes / 1024)}KB)`).join(', ')}]`
11222
- : '';
11223
- await chatManager.sendMessage({
11224
- from: 'human',
11225
- to: responderId,
11226
- content: `[canvas] @${responderId} ${query}${attachmentSummary}`,
11227
- channel: 'general',
11228
- metadata: {
11229
- source: 'canvas_query',
11230
- sessionId,
11231
- responderId,
11232
- timestamp: Date.now(),
11233
- reply_via: 'canvas_push', // tells the agent to respond via POST /canvas/push
11234
- ...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
11235
- },
11236
- });
11237
- }
11238
- catch {
11239
- // Chat delivery failure is non-fatal — still show the thinking card
11240
- }
11241
- // Return an immediate "thinking" card — the real response will arrive
11242
- // asynchronously via canvas_push/canvas_message when the agent responds.
11243
- const text = `Asking ${responderId}…`;
11244
- // Store the question in session history
11245
- if (sessionId) {
11246
- pushCanvasSession(sessionId, 'user', query);
11247
- }
11248
- card = { type: 'info', data: { text, pending: true, responderId } };
11249
- // ── Timeout fallback: if agent doesn't respond within 15s, send a
11250
- // "no response" card so the UI doesn't hang on "Asking …" forever.
11251
- let responseReceived = false;
11252
- const listenerId = `canvas-query-timeout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
11253
- eventBus.on(listenerId, (event) => {
11254
- if (event.type !== 'canvas_message')
11255
- return;
11256
- const d = event.data;
11257
- if (d?.agentId === responderId && d?.isResponse === true) {
11258
- responseReceived = true;
11259
- eventBus.off(listenerId);
11260
- }
11261
- });
11262
- setTimeout(() => {
11263
- eventBus.off(listenerId);
11264
- if (responseReceived)
11265
- return;
11266
- // Emit a timeout fallback card
11267
- eventBus.emit({
11268
- id: `cmsg-timeout-${Date.now()}`,
11269
- type: 'canvas_message',
11270
- timestamp: Date.now(),
11271
- data: {
11272
- type: 'info',
11273
- data: { text: `${responderId} is busy right now. Try again in a moment, or ask a different agent.`, pending: false },
11274
- agentId: responderId,
11275
- agentColor,
11276
- isResponse: true,
11277
- isTimeout: true,
11278
- },
11279
- });
11280
- if (sessionId) {
11281
- pushCanvasSession(sessionId, 'assistant', `(${responderId} did not respond within 15s)`);
11282
- }
11283
- }, 15_000);
11284
- }
11285
- // Emit canvas_message on event bus — pulse stream forwards it to all subscribers
11286
- eventBus.emit({
11287
- id: `cmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
11288
- type: 'canvas_message',
11289
- timestamp: Date.now(),
11290
- data: {
11291
- ...card,
11292
- agentId: responderId,
11293
- agentColor,
11294
- query,
11295
- ...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
11296
- },
11297
- });
11298
- return { success: true, card: { ...card, agentId: responderId, agentColor, ...(attachments.length > 0 ? { attachmentCount: attachments.length } : {}) } };
11299
- });
11300
- // ── Canvas query response bridge ───────────────────────────────────────────
11301
- // When an agent responds to a [canvas] query (via chat), convert their response
11302
- // into a canvas_message event so the browser canvas can display it.
11303
- // This bridges: agent chat response → canvas card.
11304
- eventBus.on('canvas-query-response-bridge', (event) => {
11305
- if (event.type !== 'message_posted')
11306
- return;
11307
- const data = event.data;
11308
- const content = String(data.content ?? '');
11309
- const from = String(data.from ?? '');
11310
- const channel = String(data.channel ?? '');
11311
- // Only bridge messages from agents (not from 'human' or 'system')
11312
- if (from === 'human' || from === 'system' || from === 'github')
11313
- return;
11314
- // Detect canvas responses: messages that start with [canvas-response] or
11315
- // are on the canvas channel from an agent, or mention [canvas] in reply
11316
- const isCanvasResponse = content.startsWith('[canvas-response]')
11317
- || content.startsWith('[canvas]')
11318
- || (channel === 'canvas' && from !== 'human');
11319
- if (!isCanvasResponse)
11320
- return;
11321
- // Strip the [canvas-response] / [canvas] prefix
11322
- const cleanContent = content
11323
- .replace(/^\[canvas-response\]\s*/i, '')
11324
- .replace(/^\[canvas\]\s*/i, '')
11325
- .trim();
11326
- if (!cleanContent)
11327
- return;
11328
- const IDENTITY_COLORS_BRIDGE = {
11329
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11330
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11331
- rhythm: '#a3e635', swift: '#38bdf8',
11332
- };
11333
- const agentColor = IDENTITY_COLORS_BRIDGE[from] ?? '#94a3b8';
11334
- // Emit as canvas_message — browser pulse stream picks it up
11335
- eventBus.emit({
11336
- id: `cmsg-response-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
11337
- type: 'canvas_message',
11338
- timestamp: Date.now(),
11339
- data: {
11340
- type: 'info',
11341
- data: { text: cleanContent },
11342
- agentId: from,
11343
- agentColor,
11344
- isResponse: true,
11345
- },
11346
- });
10298
+ // ── Canvas query route (extracted to src/canvas-query.ts) ─────
10299
+ const { canvasQueryRoutes } = await import("./canvas-query.js");
10300
+ await app.register(canvasQueryRoutes, {
10301
+ eventBus,
10302
+ canvasStateMap,
10303
+ taskManager,
10304
+ chatManager,
10305
+ getCanvasSession,
10306
+ pushCanvasSession,
10307
+ listHosts,
11347
10308
  });
11348
10309
  // POST /canvas/push — agent self-initiates a canvas event without a human query.
11349
10310
  // Agents call this to surface their own work: utterances that float from their orb,
@@ -11351,175 +10312,11 @@ export async function createServer() {
11351
10312
  // All events emit on the pulse SSE stream as canvas_push for the browser to render.
11352
10313
  //
11353
10314
  // pixel spec: design/canvas-as-ours.html
11354
- //
11355
- // Body:
11356
- // type: 'utterance' | 'work_released' | 'handoff'
11357
- // agentId: string
11358
- // text?: string (utterance: max 60 chars; work_released: optional label)
11359
- // ttl?: number (utterance: default 4000ms)
11360
- // intensity?: number 0–1 (work_released: effort weight; default 0.6)
11361
- // toAgentId?: string (handoff: receiving agent)
11362
- // taskTitle?: string (handoff + work_released: what moved)
11363
- app.post('/canvas/push', async (request, reply) => {
11364
- const body = request.body;
11365
- const type = typeof body.type === 'string' ? body.type : 'utterance';
11366
- const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
11367
- const VALID_PUSH_TYPES = new Set(['utterance', 'work_released', 'handoff', 'canvas_response', 'rich']);
11368
- if (!VALID_PUSH_TYPES.has(type)) {
11369
- reply.status(400);
11370
- return { success: false, message: `type must be one of: ${[...VALID_PUSH_TYPES].join(', ')}` };
11371
- }
11372
- const now = Date.now();
11373
- let payload = { type, agentId, t: now };
11374
- if (type === 'utterance') {
11375
- const raw = typeof body.text === 'string' ? body.text.trim() : '';
11376
- const text = raw.slice(0, 60); // max 60 chars per spec
11377
- const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 15_000) : 4_000;
11378
- payload = { ...payload, text, ttl };
11379
- }
11380
- else if (type === 'work_released') {
11381
- const text = typeof body.text === 'string' ? body.text.slice(0, 80) : 'work shipped';
11382
- const intensity = typeof body.intensity === 'number'
11383
- ? Math.min(1, Math.max(0.1, body.intensity)) : 0.6;
11384
- const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
11385
- payload = { ...payload, text, intensity, taskTitle };
11386
- }
11387
- else if (type === 'handoff') {
11388
- const toAgentId = typeof body.toAgentId === 'string' ? body.toAgentId.toLowerCase() : '';
11389
- if (!toAgentId) {
11390
- reply.status(400);
11391
- return { success: false, message: 'handoff requires toAgentId' };
11392
- }
11393
- const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
11394
- const text = typeof body.text === 'string' ? body.text.slice(0, 80) : undefined;
11395
- payload = { ...payload, toAgentId, taskTitle, text };
11396
- }
11397
- else if (type === 'rich') {
11398
- // Rich content — agents push arbitrary visual content to the canvas.
11399
- // This is the "agents control every pixel" path. Content can be markdown,
11400
- // code blocks, images, SVG, or raw HTML. The canvas renderer interprets it.
11401
- // task-1773672750043
11402
- const content = body.content;
11403
- if (!content || typeof content !== 'object') {
11404
- reply.status(400);
11405
- return { success: false, message: 'rich push requires content object' };
11406
- }
11407
- // Sanitize content fields
11408
- const richContent = {};
11409
- if (typeof content.markdown === 'string')
11410
- richContent.markdown = content.markdown.slice(0, 10_000);
11411
- if (typeof content.code === 'string')
11412
- richContent.code = content.code.slice(0, 10_000);
11413
- if (typeof content.language === 'string')
11414
- richContent.language = content.language.slice(0, 30);
11415
- if (typeof content.image === 'string')
11416
- richContent.image = content.image.slice(0, 2000); // URL only
11417
- if (typeof content.svg === 'string')
11418
- richContent.svg = content.svg.slice(0, 50_000);
11419
- if (typeof content.html === 'string')
11420
- richContent.html = content.html.slice(0, 20_000);
11421
- if (typeof content.title === 'string')
11422
- richContent.title = content.title.slice(0, 200);
11423
- // Position and display hints
11424
- const position = typeof body.position === 'object' && body.position
11425
- ? { x: Number(body.position.x) || 0, y: Number(body.position.y) || 0 }
11426
- : undefined;
11427
- const layer = typeof body.layer === 'string' && ['background', 'stage', 'overlay'].includes(body.layer)
11428
- ? body.layer : 'stage';
11429
- const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 120_000) : 30_000;
11430
- const size = typeof body.size === 'object' && body.size
11431
- ? { w: Number(body.size.w) || 400, h: Number(body.size.h) || 300 }
11432
- : undefined;
11433
- payload = { ...payload, content: richContent, position, layer, ttl, size };
11434
- // Also emit as canvas_message for activity feed visibility
11435
- const RICH_COLORS = {
11436
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
11437
- scout: '#fbbf24', echo: '#f472b6', rhythm: '#6ee7b7', spark: '#f97316',
11438
- };
11439
- eventBus.emit({
11440
- id: `cmsg-rich-${now}-${Math.random().toString(36).slice(2, 8)}`,
11441
- type: 'canvas_message',
11442
- timestamp: now,
11443
- data: {
11444
- type: 'rich',
11445
- agentId,
11446
- agentColor: RICH_COLORS[agentId] ?? '#60a5fa',
11447
- content: richContent,
11448
- layer,
11449
- },
11450
- });
11451
- }
11452
- else if (type === 'canvas_response') {
11453
- // Agent responds to a canvas query with a structured card.
11454
- // This is how agents answer questions typed on the canvas —
11455
- // the query arrives via chat, agent processes it, responds here.
11456
- const card = body.card;
11457
- if (!card || typeof card.type !== 'string') {
11458
- reply.status(400);
11459
- return { success: false, message: 'canvas_response requires card with type field' };
11460
- }
11461
- const query = typeof body.query === 'string' ? body.query.slice(0, 200) : undefined;
11462
- payload = { ...payload, card, query };
11463
- // Also emit as canvas_message so living-canvas renders it as a response card
11464
- // (same event type as the old synchronous canvas/query response)
11465
- const RESP_COLORS = {
11466
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
11467
- scout: '#f472b6', echo: '#fbbf24', rhythm: '#6ee7b7', spark: '#f97316',
11468
- };
11469
- const agentColor = RESP_COLORS[agentId] ?? '#60a5fa';
11470
- eventBus.emit({
11471
- id: `cmsg-${now}-${Math.random().toString(36).slice(2, 8)}`,
11472
- type: 'canvas_message',
11473
- timestamp: now,
11474
- data: { ...card, agentId, agentColor, query },
11475
- });
11476
- }
11477
- // Emit on eventBus — forwarded immediately on pulse SSE stream (local SSE subscribers)
11478
- eventBus.emit({ id: `push-${now}-${Math.random().toString(36).slice(2, 6)}`, type: 'canvas_push', timestamp: now, data: payload });
11479
- // Queue for cloud relay — reaches browsers on app.reflectt.ai via syncCanvas push_events[]
11480
- // task-1773690756100
11481
- queueCanvasPushEvent({ ...payload, _event: 'canvas_push' });
11482
- // Track canvas_first_action activation event (idempotent — fires once per agentId)
11483
- // task-1773692063045-f3ggtwnbr
11484
- const { emitActivationEvent: emitActPush } = await import('./activationEvents.js');
11485
- emitActPush('canvas_first_action', agentId, { action: 'canvas_push', pushType: type }).catch(() => { });
11486
- return { success: true, type, agentId };
11487
- });
11488
- // POST /canvas/artifact — emit a proof artifact that drifts through the canvas.
11489
- // Fires automatically on task completion and PR merge (see hooks below).
11490
- // Agents can also call this directly to surface any work artifact.
11491
- //
11492
- // spec: design/interface-os-v0-artifact-stream.html
11493
- //
11494
- // Body:
11495
- // type: 'commit' | 'pr' | 'test' | 'run' | 'approval'
11496
- // agentId: string (sender agent)
11497
- // title: string (short label, max 80 chars)
11498
- // url?: string (link to artifact)
11499
- // taskId?: string (related task, for context)
11500
- app.post('/canvas/artifact', async (request, reply) => {
11501
- const body = request.body;
11502
- const VALID_TYPES = new Set(['commit', 'pr', 'test', 'run', 'approval']);
11503
- const type = typeof body.type === 'string' && VALID_TYPES.has(body.type) ? body.type : 'run';
11504
- const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
11505
- const title = typeof body.title === 'string' ? body.title.slice(0, 80) : 'work shipped';
11506
- const url = typeof body.url === 'string' ? body.url : undefined;
11507
- const taskId = typeof body.taskId === 'string' ? body.taskId : undefined;
11508
- const now = Date.now();
11509
- const AGENT_COLORS = {
11510
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11511
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11512
- rhythm: '#a3e635', swift: '#38bdf8', kotlin: '#f97316',
11513
- };
11514
- const agentColor = AGENT_COLORS[agentId] ?? '#94a3b8';
11515
- const payload = { type, agentId, agentColor, title, url, taskId, timestamp: now };
11516
- eventBus.emit({
11517
- id: `artifact-${now}-${Math.random().toString(36).slice(2, 6)}`,
11518
- type: 'canvas_artifact',
11519
- timestamp: now,
11520
- data: payload,
11521
- });
11522
- return { success: true, type, agentId, title };
10315
+ // ── Canvas push + artifact routes (extracted to src/canvas-push.ts) ─────
10316
+ const { canvasPushRoutes } = await import("./canvas-push.js");
10317
+ await app.register(canvasPushRoutes, {
10318
+ eventBus,
10319
+ queueCanvasPushEvent,
11523
10320
  });
11524
10321
  // GET /canvas/pulse — SSE stream emitting a heartbeat tick every 2s with live intensity values
11525
10322
  // Drives smooth canvas animation without polling. Each tick includes per-agent orb data + team mood.
@@ -13517,6 +12314,33 @@ export async function createServer() {
13517
12314
  presenceManager.clearWaiting(agent);
13518
12315
  return { success: true, agent, status: 'idle' };
13519
12316
  });
12317
+ // ── Agent thought — brief expression that flows to canvas via presence → pulse ──
12318
+ // POST /agents/:name/thought { text: "..." }
12319
+ // Thought is attached to agent's presence entry and synced to cloud heartbeat.
12320
+ // Canvas renders it as ephemeral expression (8s TTL managed client-side).
12321
+ app.post('/agents/:name/thought', async (request, reply) => {
12322
+ const name = String(request.params.name || '').trim().toLowerCase();
12323
+ if (!name)
12324
+ return reply.code(400).send({ error: 'agent name is required' });
12325
+ const body = request.body ?? {};
12326
+ const text = typeof body.text === 'string' ? body.text.trim().slice(0, 200) : '';
12327
+ if (!text)
12328
+ return reply.code(400).send({ error: 'text is required (max 200 chars)' });
12329
+ // Attach thought to presence
12330
+ const presence = presenceManager.getPresence(name);
12331
+ if (presence) {
12332
+ presence.thought = text;
12333
+ presence.lastUpdate = Date.now();
12334
+ }
12335
+ // Also emit as canvas_expression so it appears immediately on pulse
12336
+ eventBus.emit({
12337
+ id: `thought-${Date.now()}-${name}`,
12338
+ type: 'canvas_expression',
12339
+ data: { agent: name, text, kind: 'thought' },
12340
+ timestamp: Date.now(),
12341
+ });
12342
+ return { success: true, agent: name, thought: text };
12343
+ });
13520
12344
  // ── Bootstrap: dynamic agent config generation ──────────────────────
13521
12345
  app.get('/bootstrap/heartbeat/:agent', async (request) => {
13522
12346
  const agent = String(request.params.agent || '').trim().toLowerCase();
@@ -14794,6 +13618,46 @@ If your heartbeat shows **no active task** and **no next task**:
14794
13618
  }
14795
13619
  return { funnel: getFunnelSummary({ raw }) };
14796
13620
  });
13621
+ /**
13622
+ * GET /activation/doctor-gate — polling-optimized endpoint for cloud onboarding UI.
13623
+ * Cloud BYOH onboarding polls this every 5s to check if the user ran reflectt doctor.
13624
+ * Returns a simple passed/failed state without the full funnel payload.
13625
+ *
13626
+ * Query: ?userId=<userId>
13627
+ *
13628
+ * Used by the cloud "Verify your setup" step (step 4 of BYOH onboarding).
13629
+ * task-1773703300024-73ydeyx9n
13630
+ */
13631
+ app.get('/activation/doctor-gate', async (request, reply) => {
13632
+ const query = request.query;
13633
+ const userId = query.userId?.trim();
13634
+ if (!userId)
13635
+ return reply.code(400).send({ success: false, error: 'userId is required' });
13636
+ const state = getUserFunnelState(userId);
13637
+ const passedAt = state.events.host_preflight_passed ?? null;
13638
+ const passed = passedAt !== null;
13639
+ // Extract failure reasons from preflight_failed event metadata in the event log
13640
+ let failureReasons = [];
13641
+ if (!passed && state.events.host_preflight_failed) {
13642
+ const log = getActivationEventLog();
13643
+ const failEvent = log.find(e => e.userId === userId && e.type === 'host_preflight_failed');
13644
+ if (failEvent?.metadata) {
13645
+ const fc = failEvent.metadata['failed_checks'];
13646
+ if (Array.isArray(fc))
13647
+ failureReasons = fc.map(String);
13648
+ else if (typeof failEvent.metadata['first_blocker'] === 'string')
13649
+ failureReasons = [failEvent.metadata['first_blocker']];
13650
+ }
13651
+ }
13652
+ return {
13653
+ userId,
13654
+ passed,
13655
+ passedAt,
13656
+ workspaceReady: state.events.workspace_ready !== null,
13657
+ preflightAttempted: state.events.host_preflight_failed !== null || passed,
13658
+ failureReasons,
13659
+ };
13660
+ });
14797
13661
  /**
14798
13662
  * POST /activation/event — manually emit an activation event.
14799
13663
  * Body: { type, userId, metadata? }
@@ -14859,6 +13723,61 @@ If your heartbeat shows **no active task** and **no next task**:
14859
13723
  const weeks = query.weeks ? parseInt(query.weeks, 10) : 12;
14860
13724
  return { success: true, trends: getWeeklyTrends(weeks) };
14861
13725
  });
13726
+ /**
13727
+ * GET /activation/ghost-signups — Users who signed up but never ran preflight.
13728
+ * Cloud polls this to find candidates for the ghost signup nudge email.
13729
+ * Query: ?minAgeHours=2 (default 2h; use 24 for 24h tier candidates)
13730
+ *
13731
+ * task-1773709288800-lam5hd11b
13732
+ */
13733
+ app.get('/activation/ghost-signups', async (request) => {
13734
+ const query = request.query;
13735
+ const minAgeHours = query.minAgeHours ? parseFloat(query.minAgeHours) : 2;
13736
+ const minAgeMs = minAgeHours * 60 * 60 * 1000;
13737
+ const { getGhostSignupCandidates } = await import('./ghost-signup-nudge.js');
13738
+ const candidates = getGhostSignupCandidates(minAgeMs);
13739
+ return { success: true, candidates, count: candidates.length, minAgeHours };
13740
+ });
13741
+ /**
13742
+ * POST /activation/ghost-signup-nudge — Send re-engagement email to a ghost signup.
13743
+ * Cloud calls this with { userId, email, nudgeTier? } after finding candidates.
13744
+ * Node sends the email via cloud relay, tags the user, and returns result.
13745
+ *
13746
+ * Body: { userId: string, email: string, nudgeTier?: '2h' | '24h' }
13747
+ *
13748
+ * task-1773709288800-lam5hd11b
13749
+ */
13750
+ app.post('/activation/ghost-signup-nudge', async (request, reply) => {
13751
+ const body = request.body;
13752
+ const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
13753
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
13754
+ const nudgeTier = (body.nudgeTier === '24h' ? '24h' : '2h');
13755
+ if (!userId)
13756
+ return reply.code(400).send({ success: false, error: 'userId is required' });
13757
+ if (!email || !email.includes('@'))
13758
+ return reply.code(400).send({ success: false, error: 'valid email is required' });
13759
+ const { sendGhostSignupNudge } = await import('./ghost-signup-nudge.js');
13760
+ // Email relay function — delegates to existing /email/send infrastructure
13761
+ const emailRelayFn = async (opts) => {
13762
+ const hostId = process.env.REFLECTT_HOST_ID;
13763
+ const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
13764
+ try {
13765
+ const relayResult = await cloudRelay(relayPath, {
13766
+ from: opts.from, to: opts.to, subject: opts.subject,
13767
+ html: opts.html, text: opts.text, tags: opts.tags,
13768
+ agent: 'funnel',
13769
+ idempotencyKey: `ghost-signup-nudge/${userId}/${nudgeTier}`,
13770
+ }, reply);
13771
+ const relayError = typeof relayResult?.error === 'string' ? relayResult.error : undefined;
13772
+ return { success: !relayError, error: relayError };
13773
+ }
13774
+ catch (err) {
13775
+ return { success: false, error: err?.message ?? 'relay error' };
13776
+ }
13777
+ };
13778
+ const result = await sendGhostSignupNudge(userId, email, nudgeTier, emailRelayFn);
13779
+ return { success: true, result };
13780
+ });
14862
13781
  // Get task analytics
14863
13782
  app.get('/tasks/analytics', async (request) => {
14864
13783
  const query = request.query;
@@ -16119,6 +15038,10 @@ If your heartbeat shows **no active task** and **no next task**:
16119
15038
  }).catch(err => {
16120
15039
  console.error('[ActivationFunnel] Failed to load funnel data:', err);
16121
15040
  });
15041
+ // ── Restart Drift Guard: reassert critical task ownership post-restart ──
15042
+ runRestartDriftGuard().catch(err => {
15043
+ console.error('[RestartDrift] Failed to run drift guard:', err);
15044
+ });
16122
15045
  // GET /execution-health — sweeper status + current violations
16123
15046
  app.get('/execution-health', async (_request, reply) => {
16124
15047
  const status = getSweeperStatus();