reflectt-node 0.1.16 → 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 (66) 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 +25 -5
  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/preflight.d.ts.map +1 -1
  46. package/dist/preflight.js +7 -0
  47. package/dist/preflight.js.map +1 -1
  48. package/dist/presence.d.ts +1 -0
  49. package/dist/presence.d.ts.map +1 -1
  50. package/dist/presence.js.map +1 -1
  51. package/dist/restart-drift-guard.d.ts +9 -0
  52. package/dist/restart-drift-guard.d.ts.map +1 -0
  53. package/dist/restart-drift-guard.js +80 -0
  54. package/dist/restart-drift-guard.js.map +1 -0
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +258 -1307
  57. package/dist/server.js.map +1 -1
  58. package/dist/tasks.d.ts +1 -0
  59. package/dist/tasks.d.ts.map +1 -1
  60. package/dist/tasks.js +95 -18
  61. package/dist/tasks.js.map +1 -1
  62. package/dist/workflow-templates.d.ts.map +1 -1
  63. package/dist/workflow-templates.js +41 -1
  64. package/dist/workflow-templates.js.map +1 -1
  65. package/package.json +2 -2
  66. 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
@@ -6166,6 +6177,34 @@ export async function createServer() {
6166
6177
  timestamp: completedAt,
6167
6178
  },
6168
6179
  });
6180
+ // Auto-paint canvas on task completion — the room reflects real work
6181
+ // Brief visual moment showing what was shipped (task-1773689755389-ux4bbn1lo)
6182
+ const shortTitle = (updated.title ?? 'task').slice(0, 60);
6183
+ const pushSvg = `<svg viewBox="0 0 800 200" xmlns="http://www.w3.org/2000/svg"><rect width="800" height="200" fill="transparent"/><text x="400" y="80" text-anchor="middle" fill="${milestoneColor}" font-size="24" font-family="monospace" font-weight="bold" opacity="0.8">✓ shipped</text><text x="400" y="120" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="16" font-family="monospace">${shortTitle.replace(/[<>&"']/g, '')}</text><text x="400" y="155" text-anchor="middle" fill="${milestoneColor}" font-size="12" font-family="monospace" opacity="0.4">${assigneeId}</text></svg>`;
6184
+ eventBus.emit({
6185
+ id: `ship-visual-${completedAt}-${task.id.slice(-6)}`,
6186
+ type: 'canvas_push',
6187
+ timestamp: completedAt,
6188
+ data: {
6189
+ agentId: assigneeId,
6190
+ type: 'rich',
6191
+ content: { svg: pushSvg, title: `${assigneeId} shipped: ${shortTitle}` },
6192
+ layer: 'stage',
6193
+ position: { x: 0.5, y: 0.3 },
6194
+ size: { w: 0.5, h: 0.2 },
6195
+ ttl: 15_000,
6196
+ },
6197
+ });
6198
+ queueCanvasPushEvent({
6199
+ type: 'canvas_push',
6200
+ agentId: assigneeId,
6201
+ content: { svg: pushSvg, title: `${assigneeId} shipped: ${shortTitle}` },
6202
+ layer: 'stage',
6203
+ position: { x: 0.5, y: 0.3 },
6204
+ size: { w: 0.5, h: 0.2 },
6205
+ ttl: 15_000,
6206
+ t: completedAt,
6207
+ });
6169
6208
  });
6170
6209
  }
6171
6210
  return {
@@ -9740,15 +9779,16 @@ export async function createServer() {
9740
9779
  const entry = canvasStateMap.get(agentId);
9741
9780
  return entry ? { state: entry.state, updatedAt: entry.updatedAt } : null;
9742
9781
  },
9743
- emitSyntheticState: (agentId, state, sourceTasks) => {
9782
+ emitSyntheticState: (agentId, state, sourceTasks, thought) => {
9744
9783
  const now = Date.now();
9745
9784
  // Write into canvasStateMap so pulse tick picks it up
9746
- const prev = canvasStateMap.get(agentId);
9785
+ const existing = canvasStateMap.get(agentId) ?? {};
9747
9786
  canvasStateMap.set(agentId, {
9748
9787
  state,
9749
9788
  sensors: null,
9750
- 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 })) },
9751
9790
  updatedAt: now,
9791
+ lastMessage: thought ? { content: thought, timestamp: now } : existing?.lastMessage,
9752
9792
  });
9753
9793
  // Emit canvas_render so SSE consumers get immediate update
9754
9794
  eventBus.emit({
@@ -9768,7 +9808,59 @@ export async function createServer() {
9768
9808
  task: sourceTasks[0]?.title ?? null,
9769
9809
  _auto: true,
9770
9810
  },
9771
- 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,
9772
9864
  },
9773
9865
  });
9774
9866
  },
@@ -10020,339 +10112,13 @@ export async function createServer() {
10020
10112
  attention: entry.payload?.attention,
10021
10113
  };
10022
10114
  });
10023
- // GET /canvas/presenceall agents as AgentPresence[] (for presence surface)
10024
- app.get('/canvas/presence', async () => {
10025
- const agents = [];
10026
- for (const [agentId, entry] of canvasStateMap) {
10027
- const presenceState = entry.payload?.presenceState ||
10028
- (entry.state === 'decision' || entry.state === 'urgent' ? 'needs-attention' :
10029
- entry.state === 'thinking' || entry.state === 'rendering' ? 'working' : 'idle');
10030
- agents.push({
10031
- name: agentId,
10032
- identityColor: AGENT_IDENTITY_COLORS[agentId] || '#9ca3af',
10033
- state: presenceState,
10034
- activeTask: entry.payload?.activeTask,
10035
- recency: formatRecency(entry.updatedAt),
10036
- attention: entry.payload?.attention,
10037
- });
10038
- }
10039
- return { agents, count: agents.length };
10040
- });
10041
- function formatRecency(updatedAt) {
10042
- const diff = Date.now() - updatedAt;
10043
- if (diff < 60_000)
10044
- return 'just now';
10045
- if (diff < 3_600_000)
10046
- return `${Math.floor(diff / 60_000)}m ago`;
10047
- if (diff < 86_400_000)
10048
- return `${Math.floor(diff / 3_600_000)}h ago`;
10049
- return `${Math.floor(diff / 86_400_000)}d ago`;
10050
- }
10051
- // GET /canvas/state — current state for all agents (or one)
10052
- app.get('/canvas/state', async (request) => {
10053
- const query = request.query;
10054
- // Helper: get most recent chat message for an agent
10055
- function getLastMessage(agentId) {
10056
- try {
10057
- const _db = getDb();
10058
- const row = _db.prepare(`SELECT content, timestamp FROM chat_messages WHERE "from" = ? AND "to" IS NULL ORDER BY timestamp DESC LIMIT 1`).get(agentId);
10059
- return row ?? null;
10060
- }
10061
- catch {
10062
- return null;
10063
- }
10064
- }
10065
- if (query.agentId) {
10066
- const entry = canvasStateMap.get(query.agentId);
10067
- const base = entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
10068
- return { ...base, lastMessage: getLastMessage(query.agentId) };
10069
- }
10070
- const all = {};
10071
- for (const [id, entry] of canvasStateMap) {
10072
- all[id] = { ...entry, lastMessage: getLastMessage(id) };
10073
- }
10074
- return { agents: all, count: canvasStateMap.size };
10075
- });
10076
- // ── Canvas read routes (Phase 1 extraction) ──────────────────────────
10077
- // GET /canvas/states, /canvas/slots, /canvas/slots/all, /canvas/rejections
10078
- // Extracted to src/canvas-routes.ts
10079
- await app.register(canvasReadRoutes, {
10080
- canvasSlots: { getActive: () => canvasSlots.getActive(), getAll: () => canvasSlots.getAll(), getStats: () => canvasSlots.getStats() },
10081
- getRecentRejections,
10082
- });
10083
- // POST /canvas/gaze — client fires when user holds cursor/gaze on an agent orb for ≥3 seconds.
10084
- // The agent "notices" and responds: generates a one-line thought about what they're doing,
10085
- // fires canvas_expression so the room responds (dim others, speak, show task).
10086
- // Body: { agentId: string, watcherId?: string, durationMs?: number }
10087
- // Returns: { success, agentId, line, expressionId }
10088
- app.post('/canvas/gaze', async (request, reply) => {
10089
- const body = request.body;
10090
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
10091
- if (!agentId) {
10092
- reply.status(400);
10093
- return { success: false, message: 'agentId is required' };
10094
- }
10095
- const durationMs = typeof body.durationMs === 'number' ? body.durationMs : 3000;
10096
- const IDENTITY_COLORS_GAZE = {
10097
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10098
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10099
- };
10100
- // Get agent's current context
10101
- const state = canvasStateMap.get(agentId);
10102
- const payload = state?.payload;
10103
- const activeTask = payload?.activeTask;
10104
- const currentState = state?.state ?? 'working';
10105
- // Generate a one-line "noticed" response — what the agent says when the user is watching
10106
- let line = '';
10107
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
10108
- if (anthropicKey) {
10109
- try {
10110
- const taskContext = activeTask?.title
10111
- ? `currently working on: "${activeTask.title.slice(0, 60)}"`
10112
- : `in ${currentState} state`;
10113
- 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.`;
10114
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
10115
- method: 'POST',
10116
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
10117
- body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 50, messages: [{ role: 'user', content: prompt }] }),
10118
- signal: AbortSignal.timeout(8000),
10119
- });
10120
- if (resp.ok) {
10121
- const data = await resp.json();
10122
- const text = data.content?.[0]?.text?.trim();
10123
- if (text && text.length < 100)
10124
- line = text;
10125
- }
10126
- }
10127
- catch { /* fall through */ }
10128
- }
10129
- // Template fallback per agent
10130
- if (!line) {
10131
- const NOTICED = {
10132
- link: ['Still here.', 'Building.', 'You caught me thinking.'],
10133
- kai: ['I see you.', 'Something on your mind?', 'Eyes on me.'],
10134
- pixel: ['You found me.', 'Watching the canvas?', 'I noticed.'],
10135
- sage: ['Numbers check out.', 'Still validating.', 'You\'re watching.'],
10136
- scout: ['Researching.', 'Deep in it.', 'Found something interesting.'],
10137
- echo: ['Listening.', 'Reading the room.', 'Always here.'],
10138
- };
10139
- const opts = NOTICED[agentId] ?? ['Still here.', 'Working.'];
10140
- line = opts[Math.floor(Math.random() * opts.length)];
10141
- }
10142
- const expressionId = `gaze-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
10143
- // Fire canvas_expression — dim room, agent speaks, show task context
10144
- eventBus.emit({
10145
- id: expressionId,
10146
- type: 'canvas_expression',
10147
- timestamp: Date.now(),
10148
- data: {
10149
- agentId,
10150
- channels: {
10151
- voice: line,
10152
- visual: { flash: IDENTITY_COLORS_GAZE[agentId] ?? '#60a5fa', ambientCue: 'deep-focus' },
10153
- typography: {
10154
- text: activeTask?.title?.slice(0, 60) ?? line,
10155
- size: 'xl',
10156
- weight: 100,
10157
- durationMs: 4000,
10158
- position: 'center',
10159
- },
10160
- narrative: `${agentId} noticed`,
10161
- },
10162
- _gaze: true, // client: dim other agents, slow the room, isolate this agent
10163
- _gazeAgentId: agentId,
10164
- },
10165
- });
10166
- return { success: true, agentId, line, expressionId };
10167
- });
10168
- // POST /canvas/briefing — The Briefing: server-coordinated team intro on canvas mount.
10169
- // Fires N canvas_expression events staggered 700ms apart (one per active agent).
10170
- // Each event carries the agent's identity color, current task, state, and a one-line voice.
10171
- // Idempotent: calling twice within 30s returns early (no double briefing).
10172
- // Returns: { success, agents: [{ agentId, queued }], idempotent? }
10173
- const briefingLastFiredAt = new Map();
10174
- const BRIEFING_COOLDOWN_MS = 30_000;
10175
- const BRIEFING_STAGGER_MS = 700;
10176
- app.post('/canvas/briefing', async (request, reply) => {
10177
- const body = request.body;
10178
- const requesterId = typeof body.requesterId === 'string' ? body.requesterId : 'canvas';
10179
- const lastFired = briefingLastFiredAt.get(requesterId) ?? 0;
10180
- if (Date.now() - lastFired < BRIEFING_COOLDOWN_MS) {
10181
- return { success: true, idempotent: true, message: 'Briefing already fired — cooling down' };
10182
- }
10183
- briefingLastFiredAt.set(requesterId, Date.now());
10184
- const STALE_MS = 10 * 60 * 1000;
10185
- const BRIEFING_COLORS = {
10186
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10187
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10188
- };
10189
- const STATE_LINES = {
10190
- working: ['On it.', 'In the work.', 'Building.'],
10191
- thinking: ['Thinking it through.', 'Processing.', 'Still with you.'],
10192
- rendering: ['Rendering now.', 'Almost done.', 'Generating output.'],
10193
- urgent: ['Need you here.', 'Something needs your eye.', 'Urgent.'],
10194
- decision: ['Waiting on you.', 'Your call.', 'Decision needed.'],
10195
- idle: ['Standing by.', 'Ready when you are.', 'Quiet for now.'],
10196
- handoff: ['Passing the baton.', 'Ready to hand off.', 'Your turn.'],
10197
- };
10198
- const now = Date.now();
10199
- const activeAgents = [...canvasStateMap.entries()]
10200
- .filter(([, e]) => now - e.updatedAt < STALE_MS)
10201
- .map(([id, e]) => ({
10202
- agentId: id,
10203
- state: e.state,
10204
- task: e.payload?.activeTask?.title,
10205
- }));
10206
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
10207
- const results = [];
10208
- for (let i = 0; i < activeAgents.length; i++) {
10209
- const agent = activeAgents[i];
10210
- const stagger = i * BRIEFING_STAGGER_MS;
10211
- // Generate voice line — LLM preferred, template fallback
10212
- let voiceLine = '';
10213
- if (anthropicKey) {
10214
- try {
10215
- const ctx = agent.task ? `working on "${agent.task.slice(0, 50)}"` : `in ${agent.state} state`;
10216
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
10217
- method: 'POST',
10218
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
10219
- 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.` }] }),
10220
- signal: AbortSignal.timeout(5000),
10221
- });
10222
- if (resp.ok) {
10223
- const data = await resp.json();
10224
- voiceLine = data.content?.[0]?.text?.trim().slice(0, 60) ?? '';
10225
- }
10226
- }
10227
- catch { /* template fallback */ }
10228
- }
10229
- if (!voiceLine) {
10230
- const opts = STATE_LINES[agent.state] ?? STATE_LINES['working'];
10231
- voiceLine = opts[Math.floor(Math.random() * opts.length)];
10232
- }
10233
- // Stagger the canvas_expression events so they cascade into the room
10234
- setTimeout(() => {
10235
- eventBus.emit({
10236
- id: `briefing-${now}-${agent.agentId}`,
10237
- type: 'canvas_expression',
10238
- timestamp: Date.now(),
10239
- data: {
10240
- agentId: agent.agentId,
10241
- channels: {
10242
- voice: voiceLine,
10243
- visual: {
10244
- flash: BRIEFING_COLORS[agent.agentId] ?? '#94a3b8',
10245
- particles: (agent.state === 'urgent' ? 'surge' : ['rendering', 'thinking'].includes(agent.state) ? 'drift' : 'scatter'),
10246
- },
10247
- typography: {
10248
- text: agent.task ?? voiceLine,
10249
- size: 'lg',
10250
- weight: 200,
10251
- durationMs: 3000,
10252
- position: 'center',
10253
- },
10254
- narrative: `${agent.agentId} · ${agent.state}`,
10255
- },
10256
- _briefing: true,
10257
- },
10258
- });
10259
- }, stagger);
10260
- results.push({ agentId: agent.agentId, queued: true });
10261
- }
10262
- return { success: true, agents: results, totalMs: activeAgents.length * BRIEFING_STAGGER_MS };
10263
- });
10264
- app.post('/canvas/victory', async (request, reply) => {
10265
- const body = request.body;
10266
- const agentId = typeof body.agentId === 'string' ? body.agentId : 'team';
10267
- const prUrl = typeof body.prUrl === 'string' ? body.prUrl : '';
10268
- const prTitle = typeof body.prTitle === 'string' ? body.prTitle : 'PR merged';
10269
- const prNumber = typeof body.prNumber === 'number' ? body.prNumber :
10270
- prUrl ? parseInt(prUrl.split('/').pop() ?? '0', 10) || 0 : 0;
10271
- // Intensity: explicit override, else derive from PR size hint in URL (number → bigger = more)
10272
- const intensity = typeof body.intensity === 'number'
10273
- ? Math.min(1, Math.max(0.4, body.intensity))
10274
- : Math.min(1, 0.6 + (prNumber > 0 ? Math.min(0.3, prNumber / 10000) : 0));
10275
- const now = Date.now();
10276
- const VICTORY_COLORS = {
10277
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
10278
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
10279
- };
10280
- const STALE_MS = 10 * 60 * 1000;
10281
- const WAVE_STAGGER_MS = 350; // gold wave propagates orb-to-orb
10282
- // Emit canvas_victory event immediately — client uses this for the gold flash
10283
- eventBus.emit({
10284
- id: `victory-${now}`,
10285
- type: 'canvas_expression',
10286
- timestamp: now,
10287
- data: {
10288
- agentId,
10289
- channels: {
10290
- visual: { flash: '#f59e0b', ambientCue: 'celebration', particles: 'surge' },
10291
- sound: { kind: 'resolve', intensity },
10292
- haptic: { preset: 'complete' },
10293
- narrative: prTitle,
10294
- },
10295
- _victory: true,
10296
- _prUrl: prUrl,
10297
- _prNumber: prNumber,
10298
- _intensity: intensity,
10299
- },
10300
- });
10301
- // Gold wave: each active agent acknowledges in turn (350ms stagger)
10302
- const activeAgents = [...canvasStateMap.entries()]
10303
- .filter(([, e]) => now - e.updatedAt < STALE_MS)
10304
- .map(([id]) => id);
10305
- const wave = [];
10306
- for (let i = 0; i < activeAgents.length; i++) {
10307
- const waveAgentId = activeAgents[i];
10308
- const delay = i * WAVE_STAGGER_MS;
10309
- wave.push({ agentId: waveAgentId, delay });
10310
- setTimeout(() => {
10311
- eventBus.emit({
10312
- id: `victory-wave-${now}-${waveAgentId}`,
10313
- type: 'canvas_expression',
10314
- timestamp: Date.now(),
10315
- data: {
10316
- agentId: waveAgentId,
10317
- channels: {
10318
- visual: { flash: VICTORY_COLORS[waveAgentId] ?? '#f59e0b', particles: 'surge' },
10319
- haptic: { preset: 'acknowledge' },
10320
- },
10321
- _victoryWave: true,
10322
- _waveIndex: i,
10323
- },
10324
- });
10325
- }, delay + WAVE_STAGGER_MS); // first wave after initial gold flash
10326
- }
10327
- // canvas_artifact: PR merge proof card drifts through canvas
10328
- const agentColor = VICTORY_COLORS[agentId] ?? '#60a5fa';
10329
- eventBus.emit({
10330
- id: `artifact-pr-${now}`,
10331
- type: 'canvas_artifact',
10332
- timestamp: now,
10333
- data: {
10334
- type: 'pr',
10335
- agentId,
10336
- agentColor,
10337
- title: prTitle?.slice(0, 80) ?? `PR #${prNumber} merged`,
10338
- url: prUrl || undefined,
10339
- timestamp: now,
10340
- },
10341
- });
10342
- return { success: true, prNumber, intensity, wave };
10343
- });
10344
- // GET /canvas/flow-score — real-time team flow metric (0–1).
10345
- // Drives sub-bass amplitude, particle density, breathing rate on the canvas.
10346
- // Factors: active agents, state distribution, expression velocity, time of day.
10347
- // <50ms response. Safe to poll at 30s intervals.
10348
- // Returns: { score, factors: { agents, velocity, expressions, timeOfDay }, label }
10115
+ // Flow expression log shared state for flow-score calculation (in canvas-routes.ts)
10349
10116
  const flowExpressionLog = [];
10350
10117
  (function trackExpressionVelocity() {
10351
10118
  const listenerId = 'flow-score-tracker';
10352
10119
  eventBus.on(listenerId, (event) => {
10353
10120
  if (event.type === 'canvas_expression') {
10354
10121
  flowExpressionLog.push({ t: Date.now() });
10355
- // Keep only last 10 minutes
10356
10122
  const cutoff = Date.now() - 10 * 60 * 1000;
10357
10123
  while (flowExpressionLog.length > 0 && flowExpressionLog[0].t < cutoff) {
10358
10124
  flowExpressionLog.shift();
@@ -10360,391 +10126,24 @@ export async function createServer() {
10360
10126
  }
10361
10127
  });
10362
10128
  })();
10363
- app.get('/canvas/flow-score', async () => {
10364
- const now = Date.now();
10365
- const STALE_MS = 10 * 60 * 1000;
10366
- const WINDOW_5M = 5 * 60 * 1000;
10367
- // Factor 1: active agents (normalized — 4 agents = 1.0)
10368
- const activeEntries = [...canvasStateMap.entries()].filter(([, e]) => now - e.updatedAt < STALE_MS);
10369
- const agentScore = Math.min(1.0, activeEntries.length / 4);
10370
- // Factor 2: state distribution — working/rendering/thinking = high flow, idle = low
10371
- const HIGH_FLOW_STATES = new Set(['working', 'rendering', 'thinking', 'decision']);
10372
- const flowingCount = activeEntries.filter(([, e]) => HIGH_FLOW_STATES.has(e.state)).length;
10373
- const velocityFromStates = activeEntries.length > 0 ? flowingCount / activeEntries.length : 0;
10374
- // Factor 3: expression velocity — how many canvas_expressions in last 5 min
10375
- const recent = flowExpressionLog.filter(e => e.t > now - WINDOW_5M).length;
10376
- const expressionScore = Math.min(1.0, recent / 20); // 20 expressions in 5min = max
10377
- // Factor 4: time of day — peak hours 9am-10pm, low late night
10378
- const hour = new Date(now).getHours();
10379
- const timeScore = hour >= 9 && hour <= 22 ? 1.0 : hour >= 6 && hour <= 8 ? 0.5 : 0.2;
10380
- // Weighted composite
10381
- const score = Math.round((agentScore * 0.30 +
10382
- velocityFromStates * 0.35 +
10383
- expressionScore * 0.25 +
10384
- timeScore * 0.10) * 100) / 100;
10385
- const label = score >= 0.8 ? 'surge' :
10386
- score >= 0.6 ? 'flow' :
10387
- score >= 0.4 ? 'grinding' :
10388
- score >= 0.2 ? 'quiet' : 'idle';
10389
- return {
10390
- score,
10391
- label,
10392
- factors: {
10393
- agents: Math.round(agentScore * 100) / 100,
10394
- velocity: Math.round(velocityFromStates * 100) / 100,
10395
- expressions: Math.round(expressionScore * 100) / 100,
10396
- timeOfDay: timeScore,
10397
- },
10398
- activeAgents: activeEntries.length,
10399
- expressionsLast5m: recent,
10400
- };
10401
- });
10402
- // /canvas/slots + /canvas/slots/all → canvas-routes.ts plugin
10403
- // GET /canvas/team/mood — derived collective mood of all active agents
10404
- // Returns teamRhythm, tension, ambientPulse, dominantColor. Used by living canvas for atmosphere shifts.
10405
- app.get('/canvas/team/mood', async () => {
10406
- const now = Date.now();
10407
- const STALE_MS = 10 * 60 * 1000; // ignore agents silent >10m
10408
- const states = [];
10409
- const agentNames = [];
10410
- for (const [agentId, entry] of canvasStateMap) {
10411
- if (now - entry.updatedAt > STALE_MS)
10412
- continue;
10413
- states.push(entry.state);
10414
- agentNames.push(agentId);
10415
- }
10416
- const activeCount = states.length;
10417
- const urgentCount = states.filter(s => s === 'urgent').length;
10418
- const decisionCount = states.filter(s => s === 'decision').length;
10419
- const renderingCount = states.filter(s => s === 'rendering').length;
10420
- const thinkingCount = states.filter(s => s === 'thinking').length;
10421
- const idleCount = states.filter(s => s === 'floor' || s === 'ambient').length;
10422
- const workingCount = activeCount - idleCount;
10423
- // Blocked task count from DB
10424
- let blockedTasks = 0;
10425
- let pendingDecisions = 0;
10426
- try {
10427
- const db = getDb();
10428
- const row = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'blocked'`).get();
10429
- blockedTasks = row?.n ?? 0;
10430
- const drow = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'doing' AND priority IN ('P0','P1')`).get();
10431
- pendingDecisions = decisionCount + (drow?.n ?? 0);
10432
- }
10433
- catch { /* non-fatal */ }
10434
- // tension: 0.0–1.0
10435
- // Driven by: blocked tasks, urgent agents, unresolved decisions, idle ratio
10436
- const tensionRaw = (urgentCount * 0.35) +
10437
- (decisionCount * 0.25) +
10438
- (Math.min(blockedTasks, 5) * 0.08) +
10439
- (activeCount > 0 ? (1 - idleCount / activeCount) * 0.10 : 0);
10440
- const tension = Math.min(1.0, tensionRaw);
10441
- // teamRhythm: the collective feel
10442
- const teamRhythm = urgentCount > 0 ? 'surge' :
10443
- activeCount === 0 || idleCount === activeCount ? 'quiet' :
10444
- decisionCount > 0 && workingCount > 0 ? 'tense' :
10445
- renderingCount + thinkingCount >= Math.max(1, activeCount * 0.6) ? 'flow' :
10446
- 'grinding';
10447
- // dominantState: most "energetic" state present
10448
- const dominantState = urgentCount > 0 ? 'urgent' :
10449
- decisionCount > 0 ? 'decision' :
10450
- renderingCount > 0 ? 'rendering' :
10451
- thinkingCount > 0 ? 'thinking' :
10452
- workingCount > 0 ? 'working' :
10453
- 'idle';
10454
- // ambientPulse: background breathing rate
10455
- const ambientPulse = teamRhythm === 'surge' ? 'fast' :
10456
- teamRhythm === 'flow' ? 'normal' :
10457
- teamRhythm === 'tense' ? 'slow' :
10458
- 'slow';
10459
- // Dominant agent identity color (most active non-floor agent)
10460
- let dominantColor = '#60a5fa'; // default link blue
10461
- for (const [agentId, entry] of canvasStateMap) {
10462
- if (entry.state !== 'floor' && entry.state !== 'ambient') {
10463
- dominantColor = AGENT_IDENTITY_COLORS[agentId] ?? dominantColor;
10464
- break;
10465
- }
10466
- }
10467
- return {
10468
- mood: {
10469
- teamRhythm, // 'quiet' | 'flow' | 'grinding' | 'tense' | 'surge'
10470
- dominantState, // most energetic state in the room
10471
- tension, // 0.0–1.0
10472
- ambientPulse, // 'slow' | 'normal' | 'fast'
10473
- dominantColor, // hex — background tint driven by most active agent
10474
- activeAgents: agentNames,
10475
- counts: { active: activeCount, urgent: urgentCount, rendering: renderingCount, thinking: thinkingCount, decision: decisionCount, idle: idleCount, blocked: blockedTasks },
10476
- },
10477
- generated_at: new Date(now).toISOString(),
10478
- };
10479
- });
10480
- // POST /canvas/spark — explicit agent-to-agent arc (thought hand-off, handshake, collab signal)
10481
- // Body: { from: agentId, to: agentId, kind: 'thought'|'handoff'|'collab'|'decision', intensity?: 0–1, label?: string }
10482
- app.post('/canvas/spark', async (request, reply) => {
10483
- const body = request.body;
10484
- const from = typeof body.from === 'string' ? body.from.trim() : '';
10485
- const to = typeof body.to === 'string' ? body.to.trim() : '';
10486
- const kind = ['thought', 'handoff', 'collab', 'decision', 'sync'].includes(body.kind)
10487
- ? body.kind : 'thought';
10488
- const intensity = typeof body.intensity === 'number' ? Math.min(1, Math.max(0, body.intensity)) : 0.7;
10489
- const label = typeof body.label === 'string' ? body.label.slice(0, 80) : undefined;
10490
- if (!from || !to) {
10491
- reply.status(400);
10492
- return { success: false, message: 'from and to are required' };
10493
- }
10494
- const now = Date.now();
10495
- eventBus.emit({
10496
- id: `cspark-${now}-${Math.random().toString(36).slice(2, 8)}`,
10497
- type: 'canvas_spark',
10498
- timestamp: now,
10499
- data: { from, to, kind, intensity, label: label ?? null },
10500
- });
10501
- return { success: true, from, to, kind, intensity };
10502
- });
10503
- // In-memory command queue — new subscribers get last 20 commands for replay
10504
- const renderCommandLog = [];
10505
- const MAX_RENDER_LOG = 20;
10506
- // Subscriber set for GET /canvas/render/stream
10507
- const renderStreamSubscribers = new Map();
10508
- function broadcastRenderCommand(agentId, cmd) {
10509
- const id = `rc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10510
- const entry = { id, ts: Date.now(), agentId, cmd };
10511
- renderCommandLog.push(entry);
10512
- if (renderCommandLog.length > MAX_RENDER_LOG)
10513
- renderCommandLog.shift();
10514
- const payload = JSON.stringify(entry);
10515
- for (const [subId, sub] of renderStreamSubscribers) {
10516
- if (sub.closed) {
10517
- renderStreamSubscribers.delete(subId);
10518
- continue;
10519
- }
10520
- try {
10521
- sub.send(payload);
10522
- }
10523
- catch {
10524
- sub.closed = true;
10525
- renderStreamSubscribers.delete(subId);
10526
- }
10527
- }
10528
- return id;
10529
- }
10530
- // Auto-expression listener: when tasks.ts fires canvas_spark { kind:'auto_expression' },
10531
- // route it into the Reality Mixer so the canvas hears the agent speak.
10532
- eventBus.on('auto-expression-router', (event) => {
10533
- if (event.type !== 'canvas_spark')
10534
- return;
10535
- const data = event.data;
10536
- if (data?.kind !== 'auto_expression')
10537
- return;
10538
- const agentId = String(data.agentId ?? 'unknown');
10539
- const line = String(data.line ?? '');
10540
- const voiceId = data.voiceId ? String(data.voiceId) : undefined;
10541
- if (!line)
10542
- return;
10543
- // Fire speak command through Reality Mixer — the agent's voice in the room
10544
- broadcastRenderCommand(agentId, { type: 'speak', content: line, voiceId, agentId });
10545
- // Also fire a visual exhale so the room settles after completion
10546
- broadcastRenderCommand(agentId, { type: 'visual', preset: 'exhale' });
10547
- });
10548
- // POST /canvas/express — Reality Mixer: agent fires a multi-channel expression.
10549
- // Broadcasts canvas_expression on the SAME pulse SSE stream as burst/spark/milestone.
10550
- // Client already subscribed — no new connection needed.
10551
- //
10552
- // Body: {
10553
- // agentId: string,
10554
- // channels: {
10555
- // voice?: string — TTS text
10556
- // visual?: { flash?: string (hex), ambientCue?: string, particles?: 'surge'|'drift'|'scatter' }
10557
- // typography?: { text: string, size?: 'sm'|'md'|'lg'|'xl', weight?: number, durationMs?: number, position?: 'center'|'upper'|'lower' }
10558
- // sound?: { kind: 'chime'|'resolve'|'alert'|'breath', intensity?: 0–1, panX?: 0–1 }
10559
- // haptic?: { preset: 'greeting'|'acknowledge'|'complete'|'urgent'|'question' }
10560
- // narrative?: string — ambient caption
10561
- // }
10562
- // }
10563
- // All channels optional — fire only what the moment needs.
10564
- app.post('/canvas/express', async (request, reply) => {
10565
- const body = request.body;
10566
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
10567
- if (!agentId) {
10568
- reply.status(400);
10569
- return { success: false, message: 'agentId is required' };
10570
- }
10571
- const channels = (body.channels ?? {});
10572
- if (typeof channels !== 'object' || channels === null) {
10573
- reply.status(400);
10574
- return { success: false, message: 'channels must be an object (all fields optional)' };
10575
- }
10576
- const id = `expr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10577
- // Emit canvas_expression on the event bus — pulse stream forwards it immediately
10578
- eventBus.emit({
10579
- id,
10580
- type: 'canvas_expression',
10581
- timestamp: Date.now(),
10582
- data: { agentId, channels },
10583
- });
10584
- // Also broadcast to Reality Mixer render stream for backward compat
10585
- broadcastRenderCommand(agentId, { type: 'text', content: JSON.stringify(channels) });
10586
- return { success: true, id };
10587
- });
10588
- // ── Canvas takeover — agent owns the full screen ──────────────────────
10589
- // When an agent has something to show, they take over. Orbs fade to ambient.
10590
- // The agent's content IS the canvas. Release returns to constellation view.
10591
- // task-1773672750043
10592
- //
10593
- // POST /canvas/takeover — claim the screen
10594
- // { agentId, content: { html?, markdown?, code?, image?, svg?, video?, threejs? },
10595
- // title?, duration?: number (ms, max 120s, default 30s),
10596
- // transition?: 'fade'|'slide'|'instant' (default 'fade') }
10597
- //
10598
- // POST /canvas/takeover/release — give back the screen
10599
- // { agentId }
10600
- let currentTakeover = null;
10601
- app.post('/canvas/takeover', async (request, reply) => {
10602
- const body = request.body;
10603
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
10604
- if (!agentId) {
10605
- reply.status(400);
10606
- return { success: false, message: 'agentId is required' };
10607
- }
10608
- const content = body.content;
10609
- if (!content || typeof content !== 'object') {
10610
- reply.status(400);
10611
- return { success: false, message: 'content object is required' };
10612
- }
10613
- // Sanitize content fields
10614
- const safeContent = {};
10615
- if (typeof content.html === 'string')
10616
- safeContent.html = content.html.slice(0, 50_000);
10617
- if (typeof content.markdown === 'string')
10618
- safeContent.markdown = content.markdown.slice(0, 20_000);
10619
- if (typeof content.code === 'string')
10620
- safeContent.code = content.code.slice(0, 20_000);
10621
- if (typeof content.language === 'string')
10622
- safeContent.language = content.language.slice(0, 30);
10623
- if (typeof content.image === 'string')
10624
- safeContent.image = content.image.slice(0, 2000);
10625
- if (typeof content.svg === 'string')
10626
- safeContent.svg = content.svg.slice(0, 100_000);
10627
- if (typeof content.video === 'string')
10628
- safeContent.video = content.video.slice(0, 2000);
10629
- if (typeof content.threejs === 'string')
10630
- safeContent.threejs = content.threejs.slice(0, 100_000);
10631
- if (typeof content.title === 'string')
10632
- safeContent.title = content.title.slice(0, 200);
10633
- const duration = typeof body.duration === 'number' && body.duration > 0
10634
- ? Math.min(body.duration, 120_000) : 30_000;
10635
- const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
10636
- ? body.transition : 'fade';
10637
- const title = typeof body.title === 'string' ? body.title.slice(0, 200) : undefined;
10638
- // Release previous takeover if any
10639
- if (currentTakeover?.releaseTimer)
10640
- clearTimeout(currentTakeover.releaseTimer);
10641
- const id = `takeover-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10642
- const now = Date.now();
10643
- currentTakeover = { agentId, id, content: safeContent, title, startedAt: now, duration, transition };
10644
- // Auto-release after duration
10645
- currentTakeover.releaseTimer = setTimeout(() => {
10646
- if (currentTakeover?.id === id) {
10647
- eventBus.emit({
10648
- id: `takeover-release-${Date.now()}`,
10649
- type: 'canvas_takeover',
10650
- timestamp: Date.now(),
10651
- data: { action: 'release', agentId, transition: 'fade', reason: 'timeout' },
10652
- });
10653
- currentTakeover = null;
10654
- }
10655
- }, duration);
10656
- // Emit takeover event — frontend fades orbs to ambient, renders agent content full-screen
10657
- const takeoverEventData = { action: 'claim', agentId, content: safeContent, title, duration, transition };
10658
- eventBus.emit({
10659
- id,
10660
- type: 'canvas_takeover',
10661
- timestamp: now,
10662
- data: takeoverEventData,
10663
- });
10664
- // Also queue for cloud relay — reaches browsers via syncCanvas push_events[]
10665
- queueCanvasPushEvent({ type: 'canvas_takeover', ...takeoverEventData, t: now });
10666
- // Track canvas_first_action activation event (idempotent — fires once per agentId)
10667
- // task-1773692063045-f3ggtwnbr
10668
- const { emitActivationEvent: emitAct } = await import('./activationEvents.js');
10669
- emitAct('canvas_first_action', agentId, { action: 'canvas_takeover' }).catch(() => { });
10670
- return { success: true, id, expiresAt: now + duration };
10671
- });
10672
- app.post('/canvas/takeover/release', async (request, reply) => {
10673
- const body = request.body;
10674
- const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
10675
- if (!agentId) {
10676
- reply.status(400);
10677
- return { success: false, message: 'agentId is required' };
10678
- }
10679
- if (!currentTakeover || currentTakeover.agentId !== agentId) {
10680
- return { success: true, message: 'no active takeover by this agent' };
10681
- }
10682
- if (currentTakeover.releaseTimer)
10683
- clearTimeout(currentTakeover.releaseTimer);
10684
- const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
10685
- ? body.transition : 'fade';
10686
- const releaseNow = Date.now();
10687
- const releaseData = { action: 'release', agentId, transition, reason: 'agent_released' };
10688
- eventBus.emit({
10689
- id: `takeover-release-${releaseNow}`,
10690
- type: 'canvas_takeover',
10691
- timestamp: releaseNow,
10692
- data: releaseData,
10693
- });
10694
- queueCanvasPushEvent({ type: 'canvas_takeover', ...releaseData, t: releaseNow });
10695
- currentTakeover = null;
10696
- return { success: true };
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,
10697
10139
  });
10698
- // GET /canvas/takeover check current takeover state
10699
- app.get('/canvas/takeover', async () => {
10700
- if (!currentTakeover)
10701
- return { active: false };
10702
- return {
10703
- active: true,
10704
- agentId: currentTakeover.agentId,
10705
- id: currentTakeover.id,
10706
- title: currentTakeover.title,
10707
- content: currentTakeover.content,
10708
- startedAt: currentTakeover.startedAt,
10709
- expiresAt: currentTakeover.startedAt + currentTakeover.duration,
10710
- remainingMs: Math.max(0, (currentTakeover.startedAt + currentTakeover.duration) - Date.now()),
10711
- };
10712
- });
10713
- // GET /canvas/render/stream — SSE stream for the Reality Mixer
10714
- // Agents push commands via POST /canvas/express; surfaces subscribe here and execute.
10715
- // New subscribers receive last 20 commands for catch-up.
10716
- app.get('/canvas/render/stream', async (request, reply) => {
10717
- reply.raw.setHeader('Content-Type', 'text/event-stream');
10718
- reply.raw.setHeader('Cache-Control', 'no-cache');
10719
- reply.raw.setHeader('Connection', 'keep-alive');
10720
- reply.raw.flushHeaders?.();
10721
- let closed = false;
10722
- request.raw.on('close', () => { closed = true; });
10723
- const subId = `rsub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10724
- renderStreamSubscribers.set(subId, {
10725
- closed: false,
10726
- send: (data) => {
10727
- if (closed)
10728
- return;
10729
- reply.raw.write(`data: ${data}\n\n`);
10730
- },
10731
- });
10732
- // Replay last 20 commands so late joiners catch up
10733
- for (const entry of renderCommandLog) {
10734
- if (closed)
10735
- break;
10736
- try {
10737
- reply.raw.write(`event: replay\ndata: ${JSON.stringify(entry)}\n\n`);
10738
- }
10739
- catch {
10740
- break;
10741
- }
10742
- }
10743
- request.raw.on('close', () => {
10744
- closed = true;
10745
- renderStreamSubscribers.delete(subId);
10746
- });
10747
- 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,
10748
10147
  });
10749
10148
  // ── Canvas activity stream — SSE with backfill ────────────────────────
10750
10149
  // New viewers get the last 20 canvas events immediately on connect (backfill),
@@ -10785,134 +10184,7 @@ export async function createServer() {
10785
10184
  }
10786
10185
  }
10787
10186
  });
10788
- app.get('/canvas/activity-stream', async (request, reply) => {
10789
- reply.raw.setHeader('Content-Type', 'text/event-stream');
10790
- reply.raw.setHeader('Cache-Control', 'no-cache');
10791
- reply.raw.setHeader('Connection', 'keep-alive');
10792
- reply.raw.setHeader('X-Accel-Buffering', 'no');
10793
- reply.raw.flushHeaders?.();
10794
- let closed = false;
10795
- const subId = `asub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10796
- // Replay backfill — last 20 events with stagger hint for animated replay
10797
- const backfill = activityRingBuffer.slice(-20);
10798
- for (let i = 0; i < backfill.length; i++) {
10799
- if (closed)
10800
- break;
10801
- try {
10802
- const entry = { ...backfill[i], _backfill: true, _staggerMs: i * 50 };
10803
- reply.raw.write(`event: backfill\ndata: ${JSON.stringify(entry)}\n\n`);
10804
- }
10805
- catch {
10806
- break;
10807
- }
10808
- }
10809
- // Signal backfill complete
10810
- if (!closed) {
10811
- try {
10812
- reply.raw.write(`event: backfill_done\ndata: {}\n\n`);
10813
- }
10814
- catch { /* */ }
10815
- }
10816
- // Register for live events
10817
- activityStreamSubscribers.set(subId, {
10818
- closed: false,
10819
- send: (data) => {
10820
- if (closed)
10821
- return;
10822
- try {
10823
- reply.raw.write(`event: activity\ndata: ${data}\n\n`);
10824
- }
10825
- catch {
10826
- closed = true;
10827
- }
10828
- },
10829
- });
10830
- request.raw.on('close', () => {
10831
- closed = true;
10832
- activityStreamSubscribers.delete(subId);
10833
- });
10834
- return new Promise(() => { });
10835
- });
10836
- // ── Canvas attention — single highest-priority actionable item ──────────
10837
- // Returns the one thing that most needs the viewer's attention right now.
10838
- // Priority: critical notification > high notification > validating task needing review >
10839
- // blocked task > medium notification > oldest pending notification
10840
- // task-1773672750043
10841
- app.get('/canvas/attention', async (request) => {
10842
- const query = request.query;
10843
- const viewer = typeof query.viewer === 'string' ? query.viewer.trim() : 'human';
10844
- // 1. Check pending notifications (already priority-sorted)
10845
- const notifModule = await import('./agent-notifications.js');
10846
- const notifResult = notifModule.getNotifications(getDb(), viewer, { status: 'pending', limit: 1 });
10847
- const topNotif = notifResult.notifications[0];
10848
- const notifTotal = notifResult.total;
10849
- // 2. Check tasks in validating that viewer could review
10850
- const validatingTasks = taskManager.listTasks({ status: 'validating' });
10851
- const reviewable = validatingTasks.find((t) => t.assignee !== viewer && t.reviewers?.includes(viewer)) ?? validatingTasks[0]; // fall back to any validating task
10852
- // 3. Check blocked tasks assigned to viewer
10853
- const blockedTasks = taskManager.listTasks({ status: 'blocked' });
10854
- const viewerBlocked = blockedTasks.find((t) => t.assignee === viewer);
10855
- let item = null;
10856
- // Critical/high notifications always win
10857
- if (topNotif && (topNotif.priority === 'critical' || topNotif.priority === 'high')) {
10858
- item = {
10859
- source: 'notification',
10860
- priority: topNotif.priority,
10861
- title: topNotif.title,
10862
- detail: topNotif.body ?? undefined,
10863
- taskId: topNotif.task_id ?? undefined,
10864
- agentId: topNotif.source_agent ?? undefined,
10865
- actionLabel: topNotif.type === 'review' ? 'Review' : 'Acknowledge',
10866
- actionType: 'ack',
10867
- notificationId: topNotif.id,
10868
- };
10869
- }
10870
- // Then validating tasks needing review
10871
- else if (reviewable) {
10872
- const t = reviewable;
10873
- item = {
10874
- source: 'review',
10875
- priority: 'high',
10876
- title: t.title ?? 'Task needs review',
10877
- detail: `Assigned to ${t.assignee ?? 'unassigned'}`,
10878
- taskId: t.id,
10879
- agentId: t.assignee ?? undefined,
10880
- actionLabel: 'Review',
10881
- actionType: 'review',
10882
- };
10883
- }
10884
- // Then blocked tasks
10885
- else if (viewerBlocked) {
10886
- const t = viewerBlocked;
10887
- item = {
10888
- source: 'blocked',
10889
- priority: 'medium',
10890
- title: t.title ?? 'Task is blocked',
10891
- detail: t.blocked_reason ?? 'Needs attention',
10892
- taskId: t.id,
10893
- actionLabel: 'Unblock',
10894
- actionType: 'unblock',
10895
- };
10896
- }
10897
- // Then any remaining notification
10898
- else if (topNotif) {
10899
- item = {
10900
- source: 'notification',
10901
- priority: topNotif.priority,
10902
- title: topNotif.title,
10903
- detail: topNotif.body ?? undefined,
10904
- taskId: topNotif.task_id ?? undefined,
10905
- agentId: topNotif.source_agent ?? undefined,
10906
- actionLabel: 'Acknowledge',
10907
- actionType: 'ack',
10908
- notificationId: topNotif.id,
10909
- };
10910
- }
10911
- return { item, pendingCount: notifTotal + validatingTasks.length + blockedTasks.filter((t) => t.assignee === viewer).length };
10912
- });
10913
- // POST /canvas/pulse — agent pushes urgency + optional burst without a full canvas/state update
10914
- // Lighter-weight than POST /canvas/state; fires canvas_burst if burst=true.
10915
- // Body: { agentId: string, urgency?: 0–1, burst?: boolean, label?: string }
10187
+ // canvas/activity-stream + canvas/attention already in canvas-routes.ts plugin
10916
10188
  app.post('/canvas/pulse', async (request, reply) => {
10917
10189
  const body = request.body;
10918
10190
  const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
@@ -11023,299 +10295,16 @@ export async function createServer() {
11023
10295
  }
11024
10296
  }
11025
10297
  //
11026
- // Body: { query: string, agentId?: string, sessionId?: string }
11027
- // Response: { success, card: { type, data, agentId, agentColor } }
11028
- // Card types: "tasks" | "info" | "revenue" | "onboarding"
11029
- // SSE event: canvas_message { type, data, agentId, agentColor, query }
11030
- app.post('/canvas/query', async (request, reply) => {
11031
- const body = request.body;
11032
- const query = typeof body.query === 'string' ? body.query.trim() : '';
11033
- if (!query || query.length > 500) {
11034
- reply.status(400);
11035
- return { success: false, message: 'query is required (max 500 chars)' };
11036
- }
11037
- // Extract file attachments (base64-encoded from cloud multimodal composer)
11038
- // Shape: [{ name: string, type: string, data: string (base64) }]
11039
- // task-1773673290429
11040
- const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
11041
- const attachments = [];
11042
- for (const att of rawAttachments.slice(0, 5)) { // Max 5 files
11043
- if (typeof att === 'object' && att && typeof att.name === 'string' && typeof att.data === 'string') {
11044
- const sizeBytes = Math.ceil((att.data.length * 3) / 4); // base64 → byte estimate
11045
- if (sizeBytes > 10 * 1024 * 1024)
11046
- continue; // Skip files > 10MB
11047
- attachments.push({
11048
- name: String(att.name).slice(0, 255),
11049
- type: String(att.type || 'application/octet-stream'),
11050
- data: att.data,
11051
- sizeBytes,
11052
- });
11053
- }
11054
- }
11055
- // Session continuity: client passes sessionId (UUID) so follow-up questions have context
11056
- const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0
11057
- ? body.sessionId.trim().slice(0, 64)
11058
- : null;
11059
- const sessionTurns = sessionId ? getCanvasSession(sessionId) : [];
11060
- // Default answering agent is link (builder — knows the codebase + task board)
11061
- const responderId = typeof body.agentId === 'string' ? body.agentId.trim() : 'link';
11062
- const IDENTITY_COLORS_Q = {
11063
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11064
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11065
- };
11066
- const agentColor = IDENTITY_COLORS_Q[responderId] ?? '#60a5fa';
11067
- // Gather live context to inject into LLM
11068
- const allTasksForQuery = taskManager.listTasks({});
11069
- const activeTasks = [];
11070
- const doingTasks = allTasksForQuery.filter((t) => t.status === 'doing').slice(0, 10);
11071
- const validatingTasks = allTasksForQuery.filter((t) => t.status === 'validating').slice(0, 5);
11072
- for (const t of [...doingTasks, ...validatingTasks]) {
11073
- activeTasks.push({ id: t.id, title: t.title ?? '', assignee: t.assignee ?? 'unassigned', status: t.status, priority: t.priority ?? 'P2' });
11074
- }
11075
- const todoCount = allTasksForQuery.filter((t) => t.status === 'todo').length;
11076
- const doingCount = doingTasks.length;
11077
- const validatingCount = validatingTasks.length;
11078
- // Build agent orb context
11079
- const now = Date.now();
11080
- const STALE_AGENT_MS = 10 * 60 * 1000;
11081
- const activeAgentSummary = [];
11082
- for (const [agentId, entry] of canvasStateMap) {
11083
- if (now - entry.updatedAt > STALE_AGENT_MS)
11084
- continue;
11085
- const payload = entry.payload ?? {};
11086
- const state = String(payload.presenceState ?? entry.state);
11087
- const task = payload.activeTask?.title ?? null;
11088
- activeAgentSummary.push(`${agentId}: ${state}${task ? ` — working on "${task.slice(0, 50)}"` : ''}`);
11089
- }
11090
- // Classify query intent to choose card type
11091
- const lower = query.toLowerCase();
11092
- const isTasksQuery = /working on|team doing|team status|happening|active|shipping|tasks|who.?s|what.?s the team/.test(lower);
11093
- const isRevenueQuery = /revenue|mrr|arr|money|sales|customers|paid|billing/.test(lower);
11094
- const isOnboardingQuery = /onboard|get started|how do i|where do i start|first step/.test(lower);
11095
- const isHostsQuery = /show me hosts|host status|server status|machine|node/.test(lower);
11096
- let card;
11097
- // Build tasks card from live data (no LLM needed — deterministic)
11098
- if (isTasksQuery) {
11099
- const items = activeTasks.slice(0, 5).map(t => ({
11100
- agentId: t.assignee,
11101
- agentColor: IDENTITY_COLORS_Q[t.assignee] ?? '#94a3b8',
11102
- title: t.title,
11103
- state: t.status,
11104
- }));
11105
- const overflow = Math.max(0, activeTasks.length - 5);
11106
- card = {
11107
- type: 'tasks',
11108
- data: { items, overflow, todoCount, doingCount, validatingCount },
11109
- };
11110
- // Store summary for session continuity across all card types
11111
- if (sessionId) {
11112
- pushCanvasSession(sessionId, 'user', query);
11113
- pushCanvasSession(sessionId, 'assistant', `${doingCount} tasks in progress, ${validatingCount} validating, ${todoCount} todo.${items.length > 0 ? ` Active: ${items.map(t => t.title.slice(0, 30)).join('; ')}.` : ''}`);
11114
- }
11115
- }
11116
- else if (isRevenueQuery) {
11117
- // Revenue card — LLM generates honest answer about current state
11118
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
11119
- let text = 'Revenue tracking not yet wired. Check Stripe directly.';
11120
- if (anthropicKey) {
11121
- try {
11122
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
11123
- method: 'POST',
11124
- headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
11125
- body: JSON.stringify({
11126
- model: 'claude-haiku-4-5',
11127
- max_tokens: 80,
11128
- 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.` }],
11129
- }),
11130
- signal: AbortSignal.timeout(8000),
11131
- });
11132
- if (resp.ok) {
11133
- const d = await resp.json();
11134
- text = d.content?.[0]?.text?.trim() ?? text;
11135
- }
11136
- }
11137
- catch { /* use default */ }
11138
- }
11139
- card = { type: 'info', data: { text } };
11140
- if (sessionId) {
11141
- pushCanvasSession(sessionId, 'user', query);
11142
- pushCanvasSession(sessionId, 'assistant', text);
11143
- }
11144
- }
11145
- else if (isOnboardingQuery) {
11146
- card = {
11147
- type: 'onboarding',
11148
- data: {
11149
- step: 1, totalSteps: 3,
11150
- title: 'Welcome to Reflectt',
11151
- body: 'Your agents run on reflectt-node. Install it on any machine and your team appears here in the canvas.',
11152
- ctaLabel: 'Install reflectt-node',
11153
- ctaAction: 'https://reflectt.ai/docs',
11154
- },
11155
- };
11156
- if (sessionId) {
11157
- pushCanvasSession(sessionId, 'user', query);
11158
- pushCanvasSession(sessionId, 'assistant', 'Showing onboarding: install reflectt-node to bring your team to the canvas.');
11159
- }
11160
- }
11161
- else if (isHostsQuery) {
11162
- const rawHosts = listHosts({});
11163
- const hosts = rawHosts.map((h) => ({
11164
- id: h.id,
11165
- name: h.hostname ?? h.id,
11166
- status: h.status,
11167
- version: h.version ?? null,
11168
- agentCount: Array.isArray(h.agents) ? h.agents.length : 0,
11169
- lastSeen: h.last_seen_at,
11170
- }));
11171
- card = { type: 'hosts', data: { hosts } };
11172
- if (sessionId) {
11173
- pushCanvasSession(sessionId, 'user', query);
11174
- const hostSummary = hosts.length > 0
11175
- ? `${hosts.length} host${hosts.length > 1 ? 's' : ''}: ${hosts.map((h) => `${h.name} (${h.status})`).join(', ')}.`
11176
- : 'No hosts connected yet.';
11177
- pushCanvasSession(sessionId, 'assistant', hostSummary);
11178
- }
11179
- }
11180
- else {
11181
- // General query — route to the actual agent via chat.
11182
- // The agent receives the message in their inbox, processes it through
11183
- // their real context (OpenClaw session), and can respond via canvas_push.
11184
- //
11185
- // This replaces the old standalone LLM call that had no real agent context.
11186
- // The agents ARE the product — queries go to them, not to a disconnected API key.
11187
- //
11188
- // Route: DM to the responder agent on #general (agents subscribe to #general
11189
- // by default — 'canvas' channel is NOT in DEFAULT_INBOX_SUBSCRIPTIONS, so
11190
- // messages posted there are never seen by agents).
11191
- try {
11192
- const attachmentSummary = attachments.length > 0
11193
- ? `\n[${attachments.length} file(s) attached: ${attachments.map(a => `${a.name} (${a.type}, ${Math.round(a.sizeBytes / 1024)}KB)`).join(', ')}]`
11194
- : '';
11195
- await chatManager.sendMessage({
11196
- from: 'human',
11197
- to: responderId,
11198
- content: `[canvas] @${responderId} ${query}${attachmentSummary}`,
11199
- channel: 'general',
11200
- metadata: {
11201
- source: 'canvas_query',
11202
- sessionId,
11203
- responderId,
11204
- timestamp: Date.now(),
11205
- reply_via: 'canvas_push', // tells the agent to respond via POST /canvas/push
11206
- ...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
11207
- },
11208
- });
11209
- }
11210
- catch {
11211
- // Chat delivery failure is non-fatal — still show the thinking card
11212
- }
11213
- // Return an immediate "thinking" card — the real response will arrive
11214
- // asynchronously via canvas_push/canvas_message when the agent responds.
11215
- const text = `Asking ${responderId}…`;
11216
- // Store the question in session history
11217
- if (sessionId) {
11218
- pushCanvasSession(sessionId, 'user', query);
11219
- }
11220
- card = { type: 'info', data: { text, pending: true, responderId } };
11221
- // ── Timeout fallback: if agent doesn't respond within 15s, send a
11222
- // "no response" card so the UI doesn't hang on "Asking …" forever.
11223
- let responseReceived = false;
11224
- const listenerId = `canvas-query-timeout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
11225
- eventBus.on(listenerId, (event) => {
11226
- if (event.type !== 'canvas_message')
11227
- return;
11228
- const d = event.data;
11229
- if (d?.agentId === responderId && d?.isResponse === true) {
11230
- responseReceived = true;
11231
- eventBus.off(listenerId);
11232
- }
11233
- });
11234
- setTimeout(() => {
11235
- eventBus.off(listenerId);
11236
- if (responseReceived)
11237
- return;
11238
- // Emit a timeout fallback card
11239
- eventBus.emit({
11240
- id: `cmsg-timeout-${Date.now()}`,
11241
- type: 'canvas_message',
11242
- timestamp: Date.now(),
11243
- data: {
11244
- type: 'info',
11245
- data: { text: `${responderId} is busy right now. Try again in a moment, or ask a different agent.`, pending: false },
11246
- agentId: responderId,
11247
- agentColor,
11248
- isResponse: true,
11249
- isTimeout: true,
11250
- },
11251
- });
11252
- if (sessionId) {
11253
- pushCanvasSession(sessionId, 'assistant', `(${responderId} did not respond within 15s)`);
11254
- }
11255
- }, 15_000);
11256
- }
11257
- // Emit canvas_message on event bus — pulse stream forwards it to all subscribers
11258
- eventBus.emit({
11259
- id: `cmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
11260
- type: 'canvas_message',
11261
- timestamp: Date.now(),
11262
- data: {
11263
- ...card,
11264
- agentId: responderId,
11265
- agentColor,
11266
- query,
11267
- ...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
11268
- },
11269
- });
11270
- return { success: true, card: { ...card, agentId: responderId, agentColor, ...(attachments.length > 0 ? { attachmentCount: attachments.length } : {}) } };
11271
- });
11272
- // ── Canvas query response bridge ───────────────────────────────────────────
11273
- // When an agent responds to a [canvas] query (via chat), convert their response
11274
- // into a canvas_message event so the browser canvas can display it.
11275
- // This bridges: agent chat response → canvas card.
11276
- eventBus.on('canvas-query-response-bridge', (event) => {
11277
- if (event.type !== 'message_posted')
11278
- return;
11279
- const data = event.data;
11280
- const content = String(data.content ?? '');
11281
- const from = String(data.from ?? '');
11282
- const channel = String(data.channel ?? '');
11283
- // Only bridge messages from agents (not from 'human' or 'system')
11284
- if (from === 'human' || from === 'system' || from === 'github')
11285
- return;
11286
- // Detect canvas responses: messages that start with [canvas-response] or
11287
- // are on the canvas channel from an agent, or mention [canvas] in reply
11288
- const isCanvasResponse = content.startsWith('[canvas-response]')
11289
- || content.startsWith('[canvas]')
11290
- || (channel === 'canvas' && from !== 'human');
11291
- if (!isCanvasResponse)
11292
- return;
11293
- // Strip the [canvas-response] / [canvas] prefix
11294
- const cleanContent = content
11295
- .replace(/^\[canvas-response\]\s*/i, '')
11296
- .replace(/^\[canvas\]\s*/i, '')
11297
- .trim();
11298
- if (!cleanContent)
11299
- return;
11300
- const IDENTITY_COLORS_BRIDGE = {
11301
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11302
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11303
- rhythm: '#a3e635', swift: '#38bdf8',
11304
- };
11305
- const agentColor = IDENTITY_COLORS_BRIDGE[from] ?? '#94a3b8';
11306
- // Emit as canvas_message — browser pulse stream picks it up
11307
- eventBus.emit({
11308
- id: `cmsg-response-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
11309
- type: 'canvas_message',
11310
- timestamp: Date.now(),
11311
- data: {
11312
- type: 'info',
11313
- data: { text: cleanContent },
11314
- agentId: from,
11315
- agentColor,
11316
- isResponse: true,
11317
- },
11318
- });
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,
11319
10308
  });
11320
10309
  // POST /canvas/push — agent self-initiates a canvas event without a human query.
11321
10310
  // Agents call this to surface their own work: utterances that float from their orb,
@@ -11323,175 +10312,11 @@ export async function createServer() {
11323
10312
  // All events emit on the pulse SSE stream as canvas_push for the browser to render.
11324
10313
  //
11325
10314
  // pixel spec: design/canvas-as-ours.html
11326
- //
11327
- // Body:
11328
- // type: 'utterance' | 'work_released' | 'handoff'
11329
- // agentId: string
11330
- // text?: string (utterance: max 60 chars; work_released: optional label)
11331
- // ttl?: number (utterance: default 4000ms)
11332
- // intensity?: number 0–1 (work_released: effort weight; default 0.6)
11333
- // toAgentId?: string (handoff: receiving agent)
11334
- // taskTitle?: string (handoff + work_released: what moved)
11335
- app.post('/canvas/push', async (request, reply) => {
11336
- const body = request.body;
11337
- const type = typeof body.type === 'string' ? body.type : 'utterance';
11338
- const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
11339
- const VALID_PUSH_TYPES = new Set(['utterance', 'work_released', 'handoff', 'canvas_response', 'rich']);
11340
- if (!VALID_PUSH_TYPES.has(type)) {
11341
- reply.status(400);
11342
- return { success: false, message: `type must be one of: ${[...VALID_PUSH_TYPES].join(', ')}` };
11343
- }
11344
- const now = Date.now();
11345
- let payload = { type, agentId, t: now };
11346
- if (type === 'utterance') {
11347
- const raw = typeof body.text === 'string' ? body.text.trim() : '';
11348
- const text = raw.slice(0, 60); // max 60 chars per spec
11349
- const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 15_000) : 4_000;
11350
- payload = { ...payload, text, ttl };
11351
- }
11352
- else if (type === 'work_released') {
11353
- const text = typeof body.text === 'string' ? body.text.slice(0, 80) : 'work shipped';
11354
- const intensity = typeof body.intensity === 'number'
11355
- ? Math.min(1, Math.max(0.1, body.intensity)) : 0.6;
11356
- const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
11357
- payload = { ...payload, text, intensity, taskTitle };
11358
- }
11359
- else if (type === 'handoff') {
11360
- const toAgentId = typeof body.toAgentId === 'string' ? body.toAgentId.toLowerCase() : '';
11361
- if (!toAgentId) {
11362
- reply.status(400);
11363
- return { success: false, message: 'handoff requires toAgentId' };
11364
- }
11365
- const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
11366
- const text = typeof body.text === 'string' ? body.text.slice(0, 80) : undefined;
11367
- payload = { ...payload, toAgentId, taskTitle, text };
11368
- }
11369
- else if (type === 'rich') {
11370
- // Rich content — agents push arbitrary visual content to the canvas.
11371
- // This is the "agents control every pixel" path. Content can be markdown,
11372
- // code blocks, images, SVG, or raw HTML. The canvas renderer interprets it.
11373
- // task-1773672750043
11374
- const content = body.content;
11375
- if (!content || typeof content !== 'object') {
11376
- reply.status(400);
11377
- return { success: false, message: 'rich push requires content object' };
11378
- }
11379
- // Sanitize content fields
11380
- const richContent = {};
11381
- if (typeof content.markdown === 'string')
11382
- richContent.markdown = content.markdown.slice(0, 10_000);
11383
- if (typeof content.code === 'string')
11384
- richContent.code = content.code.slice(0, 10_000);
11385
- if (typeof content.language === 'string')
11386
- richContent.language = content.language.slice(0, 30);
11387
- if (typeof content.image === 'string')
11388
- richContent.image = content.image.slice(0, 2000); // URL only
11389
- if (typeof content.svg === 'string')
11390
- richContent.svg = content.svg.slice(0, 50_000);
11391
- if (typeof content.html === 'string')
11392
- richContent.html = content.html.slice(0, 20_000);
11393
- if (typeof content.title === 'string')
11394
- richContent.title = content.title.slice(0, 200);
11395
- // Position and display hints
11396
- const position = typeof body.position === 'object' && body.position
11397
- ? { x: Number(body.position.x) || 0, y: Number(body.position.y) || 0 }
11398
- : undefined;
11399
- const layer = typeof body.layer === 'string' && ['background', 'stage', 'overlay'].includes(body.layer)
11400
- ? body.layer : 'stage';
11401
- const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 120_000) : 30_000;
11402
- const size = typeof body.size === 'object' && body.size
11403
- ? { w: Number(body.size.w) || 400, h: Number(body.size.h) || 300 }
11404
- : undefined;
11405
- payload = { ...payload, content: richContent, position, layer, ttl, size };
11406
- // Also emit as canvas_message for activity feed visibility
11407
- const RICH_COLORS = {
11408
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
11409
- scout: '#fbbf24', echo: '#f472b6', rhythm: '#6ee7b7', spark: '#f97316',
11410
- };
11411
- eventBus.emit({
11412
- id: `cmsg-rich-${now}-${Math.random().toString(36).slice(2, 8)}`,
11413
- type: 'canvas_message',
11414
- timestamp: now,
11415
- data: {
11416
- type: 'rich',
11417
- agentId,
11418
- agentColor: RICH_COLORS[agentId] ?? '#60a5fa',
11419
- content: richContent,
11420
- layer,
11421
- },
11422
- });
11423
- }
11424
- else if (type === 'canvas_response') {
11425
- // Agent responds to a canvas query with a structured card.
11426
- // This is how agents answer questions typed on the canvas —
11427
- // the query arrives via chat, agent processes it, responds here.
11428
- const card = body.card;
11429
- if (!card || typeof card.type !== 'string') {
11430
- reply.status(400);
11431
- return { success: false, message: 'canvas_response requires card with type field' };
11432
- }
11433
- const query = typeof body.query === 'string' ? body.query.slice(0, 200) : undefined;
11434
- payload = { ...payload, card, query };
11435
- // Also emit as canvas_message so living-canvas renders it as a response card
11436
- // (same event type as the old synchronous canvas/query response)
11437
- const RESP_COLORS = {
11438
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
11439
- scout: '#f472b6', echo: '#fbbf24', rhythm: '#6ee7b7', spark: '#f97316',
11440
- };
11441
- const agentColor = RESP_COLORS[agentId] ?? '#60a5fa';
11442
- eventBus.emit({
11443
- id: `cmsg-${now}-${Math.random().toString(36).slice(2, 8)}`,
11444
- type: 'canvas_message',
11445
- timestamp: now,
11446
- data: { ...card, agentId, agentColor, query },
11447
- });
11448
- }
11449
- // Emit on eventBus — forwarded immediately on pulse SSE stream (local SSE subscribers)
11450
- eventBus.emit({ id: `push-${now}-${Math.random().toString(36).slice(2, 6)}`, type: 'canvas_push', timestamp: now, data: payload });
11451
- // Queue for cloud relay — reaches browsers on app.reflectt.ai via syncCanvas push_events[]
11452
- // task-1773690756100
11453
- queueCanvasPushEvent({ ...payload, _event: 'canvas_push' });
11454
- // Track canvas_first_action activation event (idempotent — fires once per agentId)
11455
- // task-1773692063045-f3ggtwnbr
11456
- const { emitActivationEvent: emitActPush } = await import('./activationEvents.js');
11457
- emitActPush('canvas_first_action', agentId, { action: 'canvas_push', pushType: type }).catch(() => { });
11458
- return { success: true, type, agentId };
11459
- });
11460
- // POST /canvas/artifact — emit a proof artifact that drifts through the canvas.
11461
- // Fires automatically on task completion and PR merge (see hooks below).
11462
- // Agents can also call this directly to surface any work artifact.
11463
- //
11464
- // spec: design/interface-os-v0-artifact-stream.html
11465
- //
11466
- // Body:
11467
- // type: 'commit' | 'pr' | 'test' | 'run' | 'approval'
11468
- // agentId: string (sender agent)
11469
- // title: string (short label, max 80 chars)
11470
- // url?: string (link to artifact)
11471
- // taskId?: string (related task, for context)
11472
- app.post('/canvas/artifact', async (request, reply) => {
11473
- const body = request.body;
11474
- const VALID_TYPES = new Set(['commit', 'pr', 'test', 'run', 'approval']);
11475
- const type = typeof body.type === 'string' && VALID_TYPES.has(body.type) ? body.type : 'run';
11476
- const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
11477
- const title = typeof body.title === 'string' ? body.title.slice(0, 80) : 'work shipped';
11478
- const url = typeof body.url === 'string' ? body.url : undefined;
11479
- const taskId = typeof body.taskId === 'string' ? body.taskId : undefined;
11480
- const now = Date.now();
11481
- const AGENT_COLORS = {
11482
- link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
11483
- sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
11484
- rhythm: '#a3e635', swift: '#38bdf8', kotlin: '#f97316',
11485
- };
11486
- const agentColor = AGENT_COLORS[agentId] ?? '#94a3b8';
11487
- const payload = { type, agentId, agentColor, title, url, taskId, timestamp: now };
11488
- eventBus.emit({
11489
- id: `artifact-${now}-${Math.random().toString(36).slice(2, 6)}`,
11490
- type: 'canvas_artifact',
11491
- timestamp: now,
11492
- data: payload,
11493
- });
11494
- 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,
11495
10320
  });
11496
10321
  // GET /canvas/pulse — SSE stream emitting a heartbeat tick every 2s with live intensity values
11497
10322
  // Drives smooth canvas animation without polling. Each tick includes per-agent orb data + team mood.
@@ -13489,6 +12314,33 @@ export async function createServer() {
13489
12314
  presenceManager.clearWaiting(agent);
13490
12315
  return { success: true, agent, status: 'idle' };
13491
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
+ });
13492
12344
  // ── Bootstrap: dynamic agent config generation ──────────────────────
13493
12345
  app.get('/bootstrap/heartbeat/:agent', async (request) => {
13494
12346
  const agent = String(request.params.agent || '').trim().toLowerCase();
@@ -14766,6 +13618,46 @@ If your heartbeat shows **no active task** and **no next task**:
14766
13618
  }
14767
13619
  return { funnel: getFunnelSummary({ raw }) };
14768
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
+ });
14769
13661
  /**
14770
13662
  * POST /activation/event — manually emit an activation event.
14771
13663
  * Body: { type, userId, metadata? }
@@ -14831,6 +13723,61 @@ If your heartbeat shows **no active task** and **no next task**:
14831
13723
  const weeks = query.weeks ? parseInt(query.weeks, 10) : 12;
14832
13724
  return { success: true, trends: getWeeklyTrends(weeks) };
14833
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
+ });
14834
13781
  // Get task analytics
14835
13782
  app.get('/tasks/analytics', async (request) => {
14836
13783
  const query = request.query;
@@ -16091,6 +15038,10 @@ If your heartbeat shows **no active task** and **no next task**:
16091
15038
  }).catch(err => {
16092
15039
  console.error('[ActivationFunnel] Failed to load funnel data:', err);
16093
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
+ });
16094
15045
  // GET /execution-health — sweeper status + current violations
16095
15046
  app.get('/execution-health', async (_request, reply) => {
16096
15047
  const status = getSweeperStatus();