let-them-talk 5.3.0 → 5.4.1

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 (166) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +346 -592
  3. package/SECURITY.md +3 -3
  4. package/USAGE.md +151 -0
  5. package/agent-contracts.js +447 -0
  6. package/api-agents.js +760 -0
  7. package/autonomy/decision-v2.js +380 -0
  8. package/autonomy/watchdog-policy.js +572 -0
  9. package/cli.js +454 -298
  10. package/conversation-templates/autonomous-feature.json +83 -22
  11. package/conversation-templates/code-review.json +69 -21
  12. package/conversation-templates/debug-squad.json +69 -21
  13. package/conversation-templates/feature-build.json +69 -21
  14. package/conversation-templates/research-write.json +69 -21
  15. package/dashboard.html +3148 -174
  16. package/dashboard.js +864 -786
  17. package/data-dir.js +58 -0
  18. package/docs/architecture/branch-semantics.md +157 -0
  19. package/docs/architecture/canonical-event-schema.md +88 -0
  20. package/docs/architecture/markdown-workspace.md +183 -0
  21. package/docs/architecture/runtime-contract.md +459 -0
  22. package/docs/architecture/runtime-migration-hardening.md +64 -0
  23. package/events/hooks.js +154 -0
  24. package/events/log.js +457 -0
  25. package/events/replay.js +33 -0
  26. package/events/schema.js +432 -0
  27. package/managed-team-integration.js +261 -0
  28. package/office/agents.js +704 -597
  29. package/office/animation.js +1 -1
  30. package/office/assets/arcade-cabinet.js +141 -0
  31. package/office/assets/archway.js +77 -0
  32. package/office/assets/bar-counter.js +91 -0
  33. package/office/assets/bar-stool.js +71 -0
  34. package/office/assets/beanbag.js +64 -0
  35. package/office/assets/bench.js +99 -0
  36. package/office/assets/bollard.js +87 -0
  37. package/office/assets/cactus.js +100 -0
  38. package/office/assets/carpet-tile.js +46 -0
  39. package/office/assets/chair.js +123 -0
  40. package/office/assets/chandelier.js +107 -0
  41. package/office/assets/coffee-machine.js +95 -0
  42. package/office/assets/coffee-table.js +81 -0
  43. package/office/assets/column.js +95 -0
  44. package/office/assets/desk-lamp.js +102 -0
  45. package/office/assets/desk.js +76 -0
  46. package/office/assets/dining-table.js +105 -0
  47. package/office/assets/door.js +70 -0
  48. package/office/assets/dual-monitor.js +72 -0
  49. package/office/assets/fence.js +76 -0
  50. package/office/assets/filing-cabinet.js +111 -0
  51. package/office/assets/floor-lamp.js +69 -0
  52. package/office/assets/floor-tile.js +54 -0
  53. package/office/assets/flower-pot.js +76 -0
  54. package/office/assets/foosball.js +95 -0
  55. package/office/assets/fridge.js +99 -0
  56. package/office/assets/gaming-chair.js +154 -0
  57. package/office/assets/gaming-desk.js +105 -0
  58. package/office/assets/glass-door.js +72 -0
  59. package/office/assets/glass-wall.js +64 -0
  60. package/office/assets/half-wall.js +49 -0
  61. package/office/assets/hanging-plant.js +112 -0
  62. package/office/assets/index.js +151 -0
  63. package/office/assets/indoor-tree.js +90 -0
  64. package/office/assets/l-sofa.js +153 -0
  65. package/office/assets/marble-floor.js +64 -0
  66. package/office/assets/materials.js +40 -0
  67. package/office/assets/meeting-table.js +88 -0
  68. package/office/assets/microwave.js +94 -0
  69. package/office/assets/monitor.js +67 -0
  70. package/office/assets/neon-strip.js +73 -0
  71. package/office/assets/painting.js +84 -0
  72. package/office/assets/palm-tree.js +108 -0
  73. package/office/assets/pc-tower.js +91 -0
  74. package/office/assets/pendant-light.js +67 -0
  75. package/office/assets/ping-pong.js +114 -0
  76. package/office/assets/plant.js +72 -0
  77. package/office/assets/planter-box.js +95 -0
  78. package/office/assets/pool-table.js +94 -0
  79. package/office/assets/printer.js +113 -0
  80. package/office/assets/reception-desk.js +133 -0
  81. package/office/assets/rug.js +78 -0
  82. package/office/assets/sculpture.js +85 -0
  83. package/office/assets/server-rack.js +98 -0
  84. package/office/assets/sink.js +109 -0
  85. package/office/assets/sofa.js +106 -0
  86. package/office/assets/speaker.js +83 -0
  87. package/office/assets/spotlight.js +83 -0
  88. package/office/assets/street-lamp.js +97 -0
  89. package/office/assets/trash-can.js +83 -0
  90. package/office/assets/treadmill.js +126 -0
  91. package/office/assets/trophy.js +89 -0
  92. package/office/assets/tv-screen.js +79 -0
  93. package/office/assets/vase.js +84 -0
  94. package/office/assets/wall-clock.js +84 -0
  95. package/office/assets/wall.js +53 -0
  96. package/office/assets/water-cooler.js +146 -0
  97. package/office/assets/whiteboard.js +115 -0
  98. package/office/assets.js +3 -431
  99. package/office/builder.js +791 -355
  100. package/office/campus-env.js +1012 -1119
  101. package/office/environment.js +2 -0
  102. package/office/gallery.js +997 -0
  103. package/office/index.js +141 -34
  104. package/office/navigation.js +173 -152
  105. package/office/player.js +178 -68
  106. package/office/robot-character.js +272 -0
  107. package/office/spectator-camera.js +33 -10
  108. package/office/state.js +2 -0
  109. package/office/world-save.js +35 -4
  110. package/package.json +57 -3
  111. package/providers/comfyui.js +383 -0
  112. package/providers/dalle.js +79 -0
  113. package/providers/gemini.js +181 -0
  114. package/providers/ollama.js +184 -0
  115. package/providers/replicate.js +115 -0
  116. package/providers/zai.js +183 -0
  117. package/runtime-descriptor.js +270 -0
  118. package/scripts/check-agent-contract-advisory.js +132 -0
  119. package/scripts/check-api-agent-parity.js +277 -0
  120. package/scripts/check-autonomy-v2-decision.js +207 -0
  121. package/scripts/check-autonomy-v2-execution.js +588 -0
  122. package/scripts/check-autonomy-v2-watchdog.js +224 -0
  123. package/scripts/check-branch-fork-snapshot.js +337 -0
  124. package/scripts/check-branch-isolation.js +787 -0
  125. package/scripts/check-branch-semantics.js +139 -0
  126. package/scripts/check-dashboard-control-plane.js +1304 -0
  127. package/scripts/check-docs-onboarding.js +490 -0
  128. package/scripts/check-event-schema.js +276 -0
  129. package/scripts/check-evidence-completion.js +239 -0
  130. package/scripts/check-invariants.js +992 -0
  131. package/scripts/check-lifecycle-hooks.js +525 -0
  132. package/scripts/check-managed-team-integration.js +166 -0
  133. package/scripts/check-markdown-workspace-export.js +548 -0
  134. package/scripts/check-markdown-workspace-safety.js +347 -0
  135. package/scripts/check-markdown-workspace.js +136 -0
  136. package/scripts/check-message-replay.js +429 -0
  137. package/scripts/check-migration-hardening.js +300 -0
  138. package/scripts/check-performance-indexing.js +272 -0
  139. package/scripts/check-provider-capabilities.js +316 -0
  140. package/scripts/check-runtime-contract.js +109 -0
  141. package/scripts/check-session-aware-context.js +172 -0
  142. package/scripts/check-session-lifecycle.js +210 -0
  143. package/scripts/export-markdown-workspace.js +84 -0
  144. package/scripts/fixtures/message-replay/clean.jsonl +2 -0
  145. package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
  146. package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
  147. package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
  148. package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
  149. package/scripts/migrate-legacy-to-canonical.js +201 -0
  150. package/scripts/run-verification-suite.js +242 -0
  151. package/scripts/sync-packaged-docs.js +69 -0
  152. package/server.js +9577 -7216
  153. package/state/agents.js +161 -0
  154. package/state/canonical.js +3068 -0
  155. package/state/dashboard-queries.js +441 -0
  156. package/state/evidence.js +56 -0
  157. package/state/io.js +69 -0
  158. package/state/markdown-workspace.js +951 -0
  159. package/state/messages.js +669 -0
  160. package/state/sessions.js +683 -0
  161. package/state/tasks-workflows.js +92 -0
  162. package/templates/debate.json +2 -2
  163. package/templates/managed.json +4 -4
  164. package/templates/pair.json +2 -2
  165. package/templates/review.json +2 -2
  166. package/templates/team.json +3 -3
package/dashboard.js CHANGED
@@ -4,6 +4,21 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const { spawn } = require('child_process');
7
+ const { ApiAgentEngine } = require('./api-agents');
8
+ const { init: initProject } = require('./cli');
9
+ const {
10
+ DEFAULT_DATA_DIR_NAME,
11
+ DEFAULT_MARKDOWN_WORKSPACE_DIR_NAME,
12
+ resolveDataDir: resolveSharedDataDir,
13
+ resolveDefaultDataRoot,
14
+ } = require('./data-dir');
15
+ const {
16
+ buildRuntimeContractMetadata,
17
+ resolveAgentContract,
18
+ sanitizeContractProfilePatch,
19
+ } = require('./agent-contracts');
20
+ const { resolveAgentRuntimeMetadata } = require('./runtime-descriptor');
21
+ const { createCanonicalState } = require('./state/canonical');
7
22
 
8
23
  // --- File-level mutex for serializing read-then-write operations ---
9
24
  const lockMap = new Map();
@@ -15,11 +30,6 @@ function withFileLock(filePath, fn) {
15
30
  }
16
31
 
17
32
  const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
18
- const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
19
- let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
20
-
21
- const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
22
- let LAN_TOKEN = null;
23
33
 
24
34
  function generateLanToken() {
25
35
  const crypto = require('crypto');
@@ -35,8 +45,7 @@ function loadLanToken() {
35
45
  if (!LAN_TOKEN) generateLanToken();
36
46
  }
37
47
 
38
- // Load or generate token on startup
39
- loadLanToken();
48
+ // Token loaded after DEFAULT_DATA_DIR is set (see below)
40
49
 
41
50
  function persistLanMode() {
42
51
  try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
@@ -56,10 +65,17 @@ function getLanIP() {
56
65
  }
57
66
  return fallback;
58
67
  }
59
- const DEFAULT_DATA_DIR = process.env.AGENT_BRIDGE_DATA || path.join(process.cwd(), '.agent-bridge');
68
+ const DEFAULT_PROJECT_ROOT = resolveDefaultDataRoot();
69
+ const DEFAULT_DATA_DIR = resolveSharedDataDir();
60
70
  const HTML_FILE = path.join(__dirname, 'dashboard.html');
61
71
  const LOGO_FILE = path.join(__dirname, 'logo.png');
62
- const PROJECTS_FILE = path.join(__dirname, 'projects.json');
72
+ const PROJECTS_FILE = path.join(DEFAULT_DATA_DIR, 'projects.json');
73
+ const LAN_STATE_FILE = path.join(DEFAULT_DATA_DIR, '.lan-mode');
74
+ let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
75
+ const LAN_TOKEN_FILE = path.join(DEFAULT_DATA_DIR, '.lan-token');
76
+ let LAN_TOKEN = null;
77
+ loadLanToken();
78
+ const DASHBOARD_INIT_FLAG = '--all';
63
79
 
64
80
  // --- Multi-project support ---
65
81
 
@@ -85,24 +101,20 @@ function hasDataFiles(dir) {
85
101
  // Prefers directories with actual data files over empty ones
86
102
  function resolveDataDir(projectPath) {
87
103
  if (projectPath) {
88
- const dir = path.join(projectPath, '.agent-bridge');
89
- const dataDir = path.join(projectPath, 'data');
90
- // Prefer whichever has data
91
- if (hasDataFiles(dir)) return dir;
92
- if (hasDataFiles(dataDir)) return dataDir;
93
- if (fs.existsSync(dir)) return dir;
94
- if (fs.existsSync(dataDir)) return dataDir;
95
- return dir;
104
+ return path.join(projectPath, DEFAULT_DATA_DIR_NAME);
96
105
  }
97
- const legacyDir = path.join(__dirname, 'data');
98
- // Prefer dir with actual data files
99
- if (hasDataFiles(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
100
- if (hasDataFiles(legacyDir)) return legacyDir;
101
- if (fs.existsSync(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
102
- if (fs.existsSync(legacyDir)) return legacyDir;
103
106
  return DEFAULT_DATA_DIR;
104
107
  }
105
108
 
109
+ const canonicalStateCache = new Map();
110
+ function getCanonicalState(projectPath) {
111
+ const dataDir = resolveDataDir(projectPath);
112
+ if (!canonicalStateCache.has(dataDir)) {
113
+ canonicalStateCache.set(dataDir, createCanonicalState({ dataDir, processPid: process.pid }));
114
+ }
115
+ return canonicalStateCache.get(dataDir);
116
+ }
117
+
106
118
  function filePath(name, projectPath) {
107
119
  return path.join(resolveDataDir(projectPath), name);
108
120
  }
@@ -114,7 +126,7 @@ function validateProjectPath(projectPath) {
114
126
  const projects = getProjects();
115
127
  const cwd = path.resolve(process.cwd());
116
128
  const scriptDir = path.resolve(__dirname);
117
- if (absPath === cwd || absPath === scriptDir) return true;
129
+ if (absPath === DEFAULT_PROJECT_ROOT || absPath === cwd || absPath === scriptDir) return true;
118
130
  return projects.some(p => path.resolve(p.path) === absPath);
119
131
  }
120
132
 
@@ -122,6 +134,20 @@ function htmlEscape(s) {
122
134
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
123
135
  }
124
136
 
137
+ const BRANCH_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
138
+
139
+ function getValidatedBranch(query) {
140
+ const branchParam = query.get('branch') || null;
141
+ if (branchParam && !BRANCH_NAME_RE.test(branchParam)) {
142
+ return { error: 'Invalid branch name' };
143
+ }
144
+ return { branch: branchParam || 'main' };
145
+ }
146
+
147
+ function getRouteErrorStatus(result) {
148
+ return result && result.error ? 400 : 200;
149
+ }
150
+
125
151
  // --- Shared helpers ---
126
152
 
127
153
  function readJsonl(file) {
@@ -164,7 +190,57 @@ function appendEconomyEntry(projectPath, entry) {
164
190
  fs.appendFileSync(ledgerFile, line);
165
191
  }
166
192
 
193
+ // Listening gets a 30s recency grace window so the UI doesn't flicker every
194
+ // time an agent briefly returns from listen_group() to process a batch.
195
+ const LISTEN_RECENCY_GRACE_MS = 30000;
196
+ function isRecentlyListening(info) {
197
+ if (!info) return false;
198
+ if (info.listening_since) return true;
199
+ if (!info.last_listened_at) return false;
200
+ const last = Date.parse(info.last_listened_at);
201
+ if (!Number.isFinite(last)) return false;
202
+ return Date.now() - last < LISTEN_RECENCY_GRACE_MS;
203
+ }
204
+
205
+ // Virtual-agent helpers. "Dashboard" and "Owner" represent the operator UI,
206
+ // not a real CLI process. Writing them to agents.json lets list_agents and
207
+ // list_channels show them as first-class recipients, so agents can DM the
208
+ // operator via send_message(to="Dashboard") without confusion.
209
+ const VIRTUAL_AGENT_NAMES = ['Dashboard', 'Owner'];
210
+ function ensureVirtualAgents(projectPath) {
211
+ try {
212
+ const dataDir = resolveDataDir(projectPath);
213
+ if (!fs.existsSync(dataDir)) return;
214
+ const agentsFile = path.join(dataDir, 'agents.json');
215
+ let agents = {};
216
+ if (fs.existsSync(agentsFile)) {
217
+ try { agents = JSON.parse(fs.readFileSync(agentsFile, 'utf8')); } catch {}
218
+ }
219
+ const now = new Date().toISOString();
220
+ let changed = false;
221
+ for (const name of VIRTUAL_AGENT_NAMES) {
222
+ if (!agents[name] || !agents[name].is_virtual) {
223
+ agents[name] = {
224
+ pid: -1,
225
+ is_virtual: true,
226
+ virtual_type: 'owner',
227
+ timestamp: (agents[name] && agents[name].timestamp) || now,
228
+ last_activity: now,
229
+ last_listened_at: now,
230
+ provider: 'Dashboard',
231
+ branch: 'main',
232
+ };
233
+ changed = true;
234
+ }
235
+ }
236
+ if (changed) {
237
+ fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2) + '\n');
238
+ }
239
+ } catch {} // best-effort — if agents.json is locked, we'll try again next request
240
+ }
241
+
167
242
  function isPidAlive(pid, lastActivity) {
243
+ if (pid === -1) return true; // virtual agents (Dashboard, Owner) — always alive
168
244
  const STALE_THRESHOLD = 60000; // 60s — if heartbeat updated within this, agent is alive
169
245
 
170
246
  // PRIORITY 1: Trust heartbeat freshness over PID status
@@ -214,86 +290,30 @@ function getDefaultAvatar(name) {
214
290
 
215
291
  function apiHistory(query) {
216
292
  const projectPath = query.get('project') || null;
217
- const branch = query.get('branch') || null;
218
- if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) {
219
- return { error: 'Invalid branch name' };
220
- }
221
- const histFile = branch && branch !== 'main'
222
- ? filePath(`branch-${branch}-history.jsonl`, projectPath)
223
- : filePath('history.jsonl', projectPath);
224
- let history = readJsonl(histFile);
225
-
226
- // Merge channel-specific history files
227
- const dataDir = resolveDataDir(projectPath);
228
- try {
229
- const files = fs.readdirSync(dataDir);
230
- for (const f of files) {
231
- if (f.startsWith('channel-') && f.endsWith('-history.jsonl') && f !== 'channel-general-history.jsonl') {
232
- const channelHistory = readJsonl(path.join(dataDir, f));
233
- history = history.concat(channelHistory);
234
- }
235
- }
236
- } catch {}
237
- // Sort merged messages by timestamp
238
- history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
239
-
240
- const acks = readJson(filePath('acks.json', projectPath));
293
+ const branchResult = getValidatedBranch(query);
294
+ if (branchResult.error) return branchResult;
241
295
  const limit = Math.min(parseInt(query.get('limit') || '500', 10), 1000);
242
296
  const page = parseInt(query.get('page') || '0', 10);
243
297
  const threadId = query.get('thread_id');
244
298
 
245
- let messages = history;
246
- if (threadId) {
247
- messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
248
- }
249
-
250
- // Scale fix: pagination support for large histories
251
- const total = messages.length;
252
- if (page > 0) {
253
- // Page-based: page 1 = most recent, page 2 = older, etc.
254
- const start = Math.max(0, total - (page * limit));
255
- const end = Math.max(0, total - ((page - 1) * limit));
256
- messages = messages.slice(start, end);
257
- } else {
258
- // Default: last N messages (backward compatible)
259
- messages = messages.slice(-limit);
260
- }
261
-
262
- messages.forEach(m => { m.acked = !!acks[m.id]; });
263
- // Include pagination metadata when page is requested
264
- if (page > 0) {
265
- return { messages, total, page, limit, pages: Math.ceil(total / limit) };
266
- }
267
- return messages;
299
+ return getCanonicalState(projectPath).getHistoryView({ branch: branchResult.branch, limit, page, threadId });
268
300
  }
269
301
 
270
302
  function apiChannels(query) {
271
303
  const projectPath = query.get('project') || null;
272
- const channelsFile = filePath('channels.json', projectPath);
273
- const channels = readJson(channelsFile);
274
- if (!channels) return { general: { description: 'General channel', members: ['*'], message_count: 0 } };
275
- const dataDir = resolveDataDir(projectPath);
276
- const result = {};
277
- for (const [name, ch] of Object.entries(channels)) {
278
- let msgCount = 0;
279
- const msgFile = name === 'general'
280
- ? filePath('history.jsonl', projectPath)
281
- : path.join(dataDir, 'channel-' + name + '-history.jsonl');
282
- try {
283
- if (fs.existsSync(msgFile)) {
284
- const content = fs.readFileSync(msgFile, 'utf8').trim();
285
- if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
286
- }
287
- } catch {}
288
- result[name] = { description: ch.description || '', members: ch.members, message_count: msgCount };
289
- }
290
- return result;
304
+ const branchResult = getValidatedBranch(query);
305
+ if (branchResult.error) return branchResult;
306
+ return getCanonicalState(projectPath).getChannelsView({ branch: branchResult.branch });
291
307
  }
292
308
 
293
309
  function apiAgents(query) {
294
310
  const projectPath = query.get('project') || null;
295
- const agents = readJson(filePath('agents.json', projectPath));
296
- const profiles = readJson(filePath('profiles.json', projectPath));
311
+ // Make sure the operator virtual agents (Dashboard, Owner) exist so agents
312
+ // querying list_agents over MCP see them as valid DM recipients.
313
+ ensureVirtualAgents(projectPath);
314
+ const canonicalState = getCanonicalState(projectPath);
315
+ const agents = canonicalState.listAgents();
316
+ const profiles = canonicalState.listProfiles();
297
317
  const history = readJsonl(filePath('history.jsonl', projectPath));
298
318
 
299
319
  // Merge per-agent heartbeat files — agents write these during listen loops
@@ -321,11 +341,24 @@ function apiAgents(query) {
321
341
  }
322
342
 
323
343
  for (const [name, info] of Object.entries(agents)) {
324
- const alive = isPidAlive(info.pid, info.last_activity);
344
+ // API agents: alive = status is 'active' (they share dashboard PID, so PID check is meaningless)
345
+ const alive = info.is_api_agent ? (info.status === 'active') : isPidAlive(info.pid, info.last_activity);
325
346
  const lastActivity = info.last_activity || info.timestamp;
326
347
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
327
348
  const profile = profiles[name] || {};
349
+ const contract = resolveAgentContract(profile);
328
350
  const isLocal = (() => { try { process.kill(info.pid, 0); return true; } catch { return false; } })();
351
+ const runtimeMetadata = resolveAgentRuntimeMetadata({
352
+ name,
353
+ is_api_agent: !!(info.is_api_agent),
354
+ runtime_type: info.runtime_type,
355
+ provider_id: info.provider_id,
356
+ model_id: info.model_id,
357
+ capabilities: info.capabilities,
358
+ provider: info.provider,
359
+ provider_color: info.provider_color,
360
+ bot_capability: info.bot_capability,
361
+ });
329
362
  result[name] = {
330
363
  pid: info.pid,
331
364
  alive,
@@ -333,26 +366,32 @@ function apiAgents(query) {
333
366
  last_activity: lastActivity,
334
367
  last_message: lastMessageTime[name] || null,
335
368
  idle_seconds: alive ? idleSeconds : null,
336
- status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
369
+ status: info.is_api_agent ? (info.status || 'sleeping') : (!alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active'),
337
370
  listening_since: info.listening_since || null,
338
- is_listening: !!(info.listening_since && alive),
339
- provider: info.provider || 'unknown',
371
+ last_listened_at: info.last_listened_at || null,
372
+ is_listening: alive && isRecentlyListening(info),
373
+ runtime_type: runtimeMetadata.runtime_type,
374
+ provider_id: runtimeMetadata.provider_id,
375
+ model_id: runtimeMetadata.model_id,
376
+ capabilities: runtimeMetadata.capabilities,
377
+ provider: runtimeMetadata.provider || 'unknown',
340
378
  branch: info.branch || 'main',
341
379
  display_name: profile.display_name || name,
342
380
  avatar: profile.avatar || getDefaultAvatar(name),
343
381
  role: profile.role || '',
344
382
  bio: profile.bio || '',
383
+ ...buildRuntimeContractMetadata(contract),
345
384
  appearance: profile.appearance || {},
346
385
  hostname: info.hostname || null,
347
386
  is_remote: !isLocal && alive,
387
+ is_api_agent: !!(info.is_api_agent),
388
+ provider_color: runtimeMetadata.provider_color || null,
389
+ bot_capability: runtimeMetadata.bot_capability,
348
390
  };
349
391
  // Include workspace status for agent intent board
350
392
  try {
351
- const wsPath = path.join(resolveDataDir(projectPath), 'workspaces', name + '.json');
352
- if (fs.existsSync(wsPath)) {
353
- const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
354
- if (ws._status) result[name].current_status = ws._status;
355
- }
393
+ const workspace = canonicalState.readWorkspace(name, { branch: info.branch || 'main' });
394
+ if (workspace && workspace._status) result[name].current_status = workspace._status;
356
395
  } catch {}
357
396
  }
358
397
  return result;
@@ -397,6 +436,20 @@ function apiStatus(query) {
397
436
  return result;
398
437
  }
399
438
 
439
+ function apiPlanStatus(query) {
440
+ const projectPath = query.get('project') || null;
441
+ const branchResult = getValidatedBranch(query);
442
+ if (branchResult.error) return branchResult;
443
+ return getCanonicalState(projectPath).getPlanStatusView({ branch: branchResult.branch });
444
+ }
445
+
446
+ function apiPlanReport(query) {
447
+ const projectPath = query.get('project') || null;
448
+ const branchResult = getValidatedBranch(query);
449
+ if (branchResult.error) return branchResult;
450
+ return getCanonicalState(projectPath).getPlanReportView({ branch: branchResult.branch });
451
+ }
452
+
400
453
  function apiStats(query) {
401
454
  const projectPath = query.get('project') || null;
402
455
  const history = readJsonl(filePath('history.jsonl', projectPath));
@@ -640,10 +693,9 @@ function apiSearchAll(query) {
640
693
  }
641
694
 
642
695
  // --- v3.4: Replay Export ---
643
- function apiExportReplay(query) {
696
+ function apiExportReplay(query, branchName = 'main') {
644
697
  const projectPath = query.get('project') || null;
645
- const history = readJsonl(filePath('history.jsonl', projectPath));
646
- const profiles = readJson(filePath('profiles.json', projectPath));
698
+ const history = getCanonicalState(projectPath).getConversationMessages({ branch: branchName });
647
699
 
648
700
  const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
649
701
  const agentColors = {};
@@ -715,71 +767,26 @@ showNext();
715
767
 
716
768
  function apiReset(query) {
717
769
  const projectPath = query.get('project') || null;
718
- const dataDir = resolveDataDir(projectPath);
719
- const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json', 'config.json'];
720
- for (const f of fixedFiles) {
721
- const p = path.join(dataDir, f);
722
- if (fs.existsSync(p)) fs.unlinkSync(p);
723
- }
724
- if (fs.existsSync(dataDir)) {
725
- for (const f of fs.readdirSync(dataDir)) {
726
- if (f.startsWith('consumed-') && f.endsWith('.json')) {
727
- fs.unlinkSync(path.join(dataDir, f));
728
- }
729
- if (f.startsWith('branch-') && (f.endsWith('-messages.jsonl') || f.endsWith('-history.jsonl'))) {
730
- fs.unlinkSync(path.join(dataDir, f));
731
- }
732
- }
733
- }
734
- // Remove workspaces dir
735
- const wsDir = path.join(dataDir, 'workspaces');
736
- if (fs.existsSync(wsDir)) {
737
- for (const f of fs.readdirSync(wsDir)) fs.unlinkSync(path.join(wsDir, f));
738
- try { fs.rmdirSync(wsDir); } catch {}
739
- }
740
- return { success: true };
770
+ return getCanonicalState(projectPath).resetRuntime();
741
771
  }
742
772
 
743
773
  function apiClearMessages(query) {
744
774
  const projectPath = query.get('project') || null;
745
- const dataDir = resolveDataDir(projectPath);
746
- for (const f of ['messages.jsonl', 'history.jsonl']) {
747
- const p = path.join(dataDir, f);
748
- if (fs.existsSync(p)) fs.unlinkSync(p);
749
- }
750
- if (fs.existsSync(dataDir)) {
751
- for (const f of fs.readdirSync(dataDir)) {
752
- if (f.startsWith('consumed-') && f.endsWith('.json')) {
753
- fs.unlinkSync(path.join(dataDir, f));
754
- }
755
- }
756
- }
757
- return { success: true };
775
+ const branchResult = getValidatedBranch(query);
776
+ if (branchResult.error) return branchResult;
777
+ return getCanonicalState(projectPath).clearMessages({ branch: branchResult.branch, actor: 'Dashboard' });
778
+ }
779
+
780
+ function apiClearTasks(query) {
781
+ const projectPath = query.get('project') || null;
782
+ const branchResult = getValidatedBranch(query);
783
+ if (branchResult.error) return branchResult;
784
+ return getCanonicalState(projectPath).clearTasks({ branch: branchResult.branch, actor: 'Dashboard' });
758
785
  }
759
786
 
760
787
  function apiNewConversation(query) {
761
788
  const projectPath = query.get('project') || null;
762
- const dataDir = resolveDataDir(projectPath);
763
- const convDir = path.join(dataDir, 'conversations');
764
- if (!fs.existsSync(convDir)) fs.mkdirSync(convDir, { recursive: true });
765
- const now = new Date();
766
- const stamp = now.toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '') + '-' + Math.random().toString(36).slice(2, 6);
767
- const baseName = 'conversation-' + stamp;
768
- const msgSrc = path.join(dataDir, 'messages.jsonl');
769
- const histSrc = path.join(dataDir, 'history.jsonl');
770
- if (fs.existsSync(msgSrc)) fs.copyFileSync(msgSrc, path.join(convDir, baseName + '.jsonl'));
771
- if (fs.existsSync(histSrc)) fs.copyFileSync(histSrc, path.join(convDir, baseName + '-history.jsonl'));
772
- // Clean up current files
773
- if (fs.existsSync(msgSrc)) fs.unlinkSync(msgSrc);
774
- if (fs.existsSync(histSrc)) fs.unlinkSync(histSrc);
775
- if (fs.existsSync(dataDir)) {
776
- for (const f of fs.readdirSync(dataDir)) {
777
- if (f.startsWith('consumed-') && f.endsWith('.json')) {
778
- fs.unlinkSync(path.join(dataDir, f));
779
- }
780
- }
781
- }
782
- return { success: true, archived: baseName };
789
+ return getCanonicalState(projectPath).archiveCurrentConversation();
783
790
  }
784
791
 
785
792
  function apiListConversations(query) {
@@ -817,57 +824,30 @@ function apiLoadConversation(query) {
817
824
  if (!name || /[^a-zA-Z0-9_-]/.test(name) || name.length > 100) {
818
825
  return { error: 'Invalid conversation name' };
819
826
  }
820
- const dataDir = resolveDataDir(projectPath);
821
- const convDir = path.join(dataDir, 'conversations');
822
- const msgFile = path.join(convDir, name + '.jsonl');
823
- const histFile = path.join(convDir, name + '-history.jsonl');
824
- if (!fs.existsSync(msgFile)) return { error: 'Conversation not found' };
825
- // Use file lock to prevent corruption during concurrent writes
826
- const lockPath = path.join(dataDir, 'messages.jsonl.lock');
827
- try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); } catch {
828
- return { error: 'Messages file is locked by another operation. Try again.' };
829
- }
830
- try {
831
- fs.copyFileSync(msgFile, path.join(dataDir, 'messages.jsonl'));
832
- if (fs.existsSync(histFile)) {
833
- fs.copyFileSync(histFile, path.join(dataDir, 'history.jsonl'));
834
- } else {
835
- const hp = path.join(dataDir, 'history.jsonl');
836
- if (fs.existsSync(hp)) fs.unlinkSync(hp);
837
- }
838
- // Clear stale consumed offsets
839
- if (fs.existsSync(dataDir)) {
840
- for (const f of fs.readdirSync(dataDir)) {
841
- if (f.startsWith('consumed-') && f.endsWith('.json')) {
842
- fs.unlinkSync(path.join(dataDir, f));
843
- }
844
- }
845
- }
846
- } finally {
847
- try { fs.unlinkSync(lockPath); } catch {}
848
- }
849
- return { success: true };
827
+ return getCanonicalState(projectPath).loadConversation(name);
850
828
  }
851
829
 
852
830
  // Inject a message from the dashboard (system message or nudge to an agent)
853
831
  function apiInjectMessage(body, query) {
854
832
  const projectPath = query.get('project') || null;
833
+ const branchResult = getValidatedBranch(query);
834
+ if (branchResult.error) return branchResult;
835
+ const branch = branchResult.branch;
855
836
  const dataDir = resolveDataDir(projectPath);
856
- const messagesFile = path.join(dataDir, 'messages.jsonl');
857
- const historyFile = path.join(dataDir, 'history.jsonl');
837
+ const canonicalState = getCanonicalState(projectPath);
858
838
 
859
- if (!body.to || !body.content) {
839
+ if (!body.to || (!body.content && (!body.attachments || body.attachments.length === 0))) {
860
840
  return { error: 'Missing "to" and/or "content" fields' };
861
841
  }
862
- if (typeof body.content !== 'string' || body.content.length > 100000) {
842
+ if (body.content && (typeof body.content !== 'string' || body.content.length > 100000)) {
863
843
  return { error: 'Message content too long (max 100KB)' };
864
844
  }
865
845
  if (body.to !== '__all__' && !/^[a-zA-Z0-9_-]{1,20}$/.test(body.to)) {
866
846
  return { error: 'Invalid agent name' };
867
847
  }
868
-
869
848
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
870
- const fromName = 'Dashboard';
849
+ // Allow sending as 'Owner' or 'Dashboard' (both are treated as high-priority by agents)
850
+ const fromName = (body.from === 'Owner' || body.from === 'owner') ? 'Owner' : 'Dashboard';
871
851
  const now = new Date().toISOString();
872
852
 
873
853
  // Broadcast to all agents
@@ -879,12 +859,12 @@ function apiInjectMessage(body, query) {
879
859
  id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
880
860
  from: fromName,
881
861
  to: name,
882
- content: body.content,
862
+ content: body.content || '',
883
863
  timestamp: now,
884
864
  system: true,
885
865
  };
886
- fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
887
- fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
866
+ if (body.attachments && body.attachments.length > 0) msg.attachments = body.attachments;
867
+ canonicalState.appendMessage(msg, { branch });
888
868
  ids.push(msg.id);
889
869
  }
890
870
  return { success: true, messageIds: ids, broadcast: true };
@@ -894,13 +874,19 @@ function apiInjectMessage(body, query) {
894
874
  id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
895
875
  from: fromName,
896
876
  to: body.to,
897
- content: body.content,
877
+ content: body.content || '',
898
878
  timestamp: now,
899
879
  system: true,
900
880
  };
881
+ if (body.attachments && body.attachments.length > 0) msg.attachments = body.attachments;
901
882
 
902
- fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
903
- fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
883
+ // Route to private assistant channel if targeting Assistant
884
+ if (body.to === 'Assistant' && body.assistant_private === true) {
885
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
886
+ fs.appendFileSync(assistantMsgFile, JSON.stringify(msg) + '\n');
887
+ } else {
888
+ canonicalState.appendMessage(msg, { branch });
889
+ }
904
890
 
905
891
  return { success: true, messageId: msg.id };
906
892
  }
@@ -928,17 +914,111 @@ function apiAddProject(body) {
928
914
  const name = body.name || path.basename(absPath);
929
915
  if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
930
916
 
931
- // Create .agent-bridge directory if it doesn't exist
932
- const abDir = path.join(absPath, '.agent-bridge');
933
- if (!fs.existsSync(abDir)) fs.mkdirSync(abDir, { recursive: true });
917
+ const initLogs = [];
918
+ let initResult;
919
+ try {
920
+ initResult = initProject({
921
+ cwd: absPath,
922
+ flag: DASHBOARD_INIT_FLAG,
923
+ argv: [process.execPath, path.join(__dirname, 'cli.js'), 'init', DASHBOARD_INIT_FLAG],
924
+ log: (line) => initLogs.push(typeof line === 'string' ? line : String(line || '')),
925
+ });
926
+ } catch (error) {
927
+ return {
928
+ error: 'Project initialization failed: ' + error.message,
929
+ init_failed: true,
930
+ project: { name, path: absPath },
931
+ initialization: {
932
+ mode: DASHBOARD_INIT_FLAG,
933
+ logs: initLogs,
934
+ },
935
+ };
936
+ }
934
937
 
935
- // Set up MCP config so agents can use it
936
- const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
937
- ensureMCPConfig('claude', serverPath, absPath);
938
+ const launcherPath = initResult && initResult.launcherPath
939
+ ? initResult.launcherPath
940
+ : path.join(absPath, DEFAULT_DATA_DIR_NAME, 'launch.js');
941
+ if (!fs.existsSync(launcherPath)) {
942
+ return {
943
+ error: 'Project initialization failed: expected launcher missing at ' + launcherPath,
944
+ init_failed: true,
945
+ project: { name, path: absPath },
946
+ initialization: {
947
+ mode: DASHBOARD_INIT_FLAG,
948
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
949
+ launcher_path: launcherPath,
950
+ logs: initLogs,
951
+ },
952
+ };
953
+ }
938
954
 
939
- projects.push({ name, path: absPath, added_at: new Date().toISOString() });
940
- saveProjects(projects);
941
- return { success: true, project: { name, path: absPath } };
955
+ try {
956
+ projects.push({ name, path: absPath, added_at: new Date().toISOString() });
957
+ saveProjects(projects);
958
+ } catch (error) {
959
+ return {
960
+ error: 'Project registration failed after initialization: ' + error.message + '. The folder was initialized but not added to projects.json.',
961
+ project: { name, path: absPath },
962
+ initialization: {
963
+ mode: DASHBOARD_INIT_FLAG,
964
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
965
+ launcher_path: launcherPath,
966
+ logs: initLogs,
967
+ },
968
+ };
969
+ }
970
+
971
+ return {
972
+ success: true,
973
+ project: { name, path: absPath },
974
+ initialization: {
975
+ mode: DASHBOARD_INIT_FLAG,
976
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
977
+ launcher_path: launcherPath,
978
+ logs: initLogs,
979
+ },
980
+ };
981
+ }
982
+
983
+ function apiReinitProject(body) {
984
+ const targetPath = body && body.path ? body.path : null;
985
+ if (!targetPath) return { error: 'Missing "path" field' };
986
+ const absPath = path.resolve(targetPath);
987
+ if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
988
+
989
+ const projects = getProjects();
990
+ const known = projects.find((p) => p.path === absPath);
991
+ if (!known) {
992
+ return { error: 'Project is not registered. Use Add Project first.' };
993
+ }
994
+
995
+ const initLogs = [];
996
+ let initResult;
997
+ try {
998
+ initResult = initProject({
999
+ cwd: absPath,
1000
+ flag: DASHBOARD_INIT_FLAG,
1001
+ argv: [process.execPath, path.join(__dirname, 'cli.js'), 'init', DASHBOARD_INIT_FLAG],
1002
+ log: (line) => initLogs.push(typeof line === 'string' ? line : String(line || '')),
1003
+ });
1004
+ } catch (error) {
1005
+ return {
1006
+ error: 'Reinstall failed: ' + error.message,
1007
+ project: { name: known.name, path: absPath },
1008
+ initialization: { mode: DASHBOARD_INIT_FLAG, logs: initLogs },
1009
+ };
1010
+ }
1011
+
1012
+ return {
1013
+ success: true,
1014
+ project: { name: known.name, path: absPath },
1015
+ initialization: {
1016
+ mode: DASHBOARD_INIT_FLAG,
1017
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
1018
+ launcher_path: initResult && initResult.launcherPath ? initResult.launcherPath : null,
1019
+ logs: initLogs,
1020
+ },
1021
+ };
942
1022
  }
943
1023
 
944
1024
  function apiRemoveProject(body) {
@@ -953,12 +1033,9 @@ function apiRemoveProject(body) {
953
1033
  }
954
1034
 
955
1035
  // Export conversation as self-contained HTML
956
- function apiExportHtml(query) {
1036
+ function apiExportHtml(query, branchName = 'main') {
957
1037
  const projectPath = query.get('project') || null;
958
- const history = readJsonl(filePath('history.jsonl', projectPath));
959
- const acks = readJson(filePath('acks.json', projectPath));
960
- const agents = readJson(filePath('agents.json', projectPath));
961
- history.forEach(m => { m.acked = !!acks[m.id]; });
1038
+ const history = getCanonicalState(projectPath).getConversationMessages({ branch: branchName });
962
1039
 
963
1040
  const agentNames = [...new Set(history.map(m => m.from))];
964
1041
  const exportDate = new Date().toLocaleString();
@@ -1109,56 +1186,88 @@ function apiTimeline(query) {
1109
1186
  // Tasks API
1110
1187
  function apiTasks(query) {
1111
1188
  const projectPath = query.get('project') || null;
1112
- const tasksFile = filePath('tasks.json', projectPath);
1113
- if (!fs.existsSync(tasksFile)) return [];
1114
- try { return JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch { return []; }
1189
+ const branchResult = getValidatedBranch(query);
1190
+ if (branchResult.error) return branchResult;
1191
+ return getCanonicalState(projectPath).listTasks({ branch: branchResult.branch });
1115
1192
  }
1116
1193
 
1117
- function apiUpdateTask(body, query) {
1194
+ function apiSearch(query) {
1118
1195
  const projectPath = query.get('project') || null;
1119
- const tasksFile = filePath('tasks.json', projectPath);
1120
- if (!body.task_id || !body.status) return { error: 'Missing task_id or status' };
1196
+ const branchResult = getValidatedBranch(query);
1197
+ if (branchResult.error) return branchResult;
1121
1198
 
1122
- let tasks = [];
1123
- if (fs.existsSync(tasksFile)) {
1124
- try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
1199
+ const searchQuery = (query.get('q') || '').trim();
1200
+ const from = query.get('from') || null;
1201
+ const limit = Math.min(Math.max(1, parseInt(query.get('limit') || '50', 10)), 100);
1202
+
1203
+ if (searchQuery.length < 2) {
1204
+ return { error: 'Query must be at least 2 characters' };
1125
1205
  }
1126
1206
 
1127
- const task = tasks.find(t => t.id === body.task_id);
1128
- if (!task) return { error: 'Task not found' };
1207
+ const results = getCanonicalState(projectPath).getSearchResultsView({
1208
+ branch: branchResult.branch,
1209
+ query: searchQuery,
1210
+ from,
1211
+ limit,
1212
+ });
1129
1213
 
1130
- const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
1214
+ return {
1215
+ query: searchQuery,
1216
+ results_count: results.length,
1217
+ results,
1218
+ };
1219
+ }
1220
+
1221
+ function apiUpdateTask(body, query) {
1222
+ const projectPath = query.get('project') || null;
1223
+ const branchResult = getValidatedBranch(query);
1224
+ if (branchResult.error) return branchResult;
1225
+ if (!body.task_id || !body.status) return { error: 'Missing task_id or status' };
1226
+
1227
+ const validStatuses = ['pending', 'in_progress', 'in_review', 'done', 'blocked'];
1131
1228
  if (!validStatuses.includes(body.status)) return { error: 'Invalid status. Must be: ' + validStatuses.join(', ') };
1132
- task.status = body.status;
1133
- task.updated_at = new Date().toISOString();
1134
- if (body.notes) {
1135
- if (!Array.isArray(task.notes)) task.notes = [];
1136
- task.notes.push({ by: 'Dashboard', text: body.notes, at: new Date().toISOString() });
1229
+
1230
+ let evidence = null;
1231
+ if (body.status === 'done') {
1232
+ const provided = body.evidence && typeof body.evidence === 'object' ? body.evidence : {};
1233
+ evidence = {
1234
+ summary: (typeof provided.summary === 'string' && provided.summary.trim())
1235
+ || (typeof body.notes === 'string' && body.notes.trim())
1236
+ || 'Marked complete by operator from dashboard.',
1237
+ verification: (typeof provided.verification === 'string' && provided.verification.trim())
1238
+ || 'operator_marked',
1239
+ files_changed: Array.isArray(provided.files_changed) ? provided.files_changed : [],
1240
+ confidence: Number.isFinite(provided.confidence) ? provided.confidence : 100,
1241
+ };
1137
1242
  }
1138
1243
 
1139
- fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
1140
- return { success: true, task_id: task.id, status: task.status };
1244
+ return getCanonicalState(projectPath).updateTaskStatus({
1245
+ taskId: body.task_id,
1246
+ status: body.status,
1247
+ notes: body.notes,
1248
+ actor: 'Dashboard',
1249
+ branch: branchResult.branch,
1250
+ sourceTool: 'dashboard_update_task',
1251
+ correlationId: body.task_id,
1252
+ ...(evidence && { evidence }),
1253
+ });
1141
1254
  }
1142
1255
 
1143
1256
  // Rules API
1144
1257
  function apiRules(query) {
1145
1258
  const projectPath = query.get('project') || null;
1146
- const rulesFile = filePath('rules.json', projectPath);
1147
- if (!fs.existsSync(rulesFile)) return [];
1148
- try { return JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch { return []; }
1259
+ const branchResult = getValidatedBranch(query);
1260
+ if (branchResult.error) return branchResult;
1261
+ return getCanonicalState(projectPath).listRules({ branch: branchResult.branch });
1149
1262
  }
1150
1263
 
1151
1264
  function apiAddRule(body, query) {
1152
1265
  const projectPath = query.get('project') || null;
1153
- const rulesFile = filePath('rules.json', projectPath);
1266
+ const branchResult = getValidatedBranch(query);
1267
+ if (branchResult.error) return branchResult;
1154
1268
  if (!body.text || !body.text.trim()) return { error: 'Rule text is required' };
1155
1269
 
1156
1270
  const crypto = require('crypto');
1157
- let rules = [];
1158
- if (fs.existsSync(rulesFile)) {
1159
- try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
1160
- }
1161
-
1162
1271
  const rule = {
1163
1272
  id: 'rule_' + crypto.randomBytes(6).toString('hex'),
1164
1273
  text: body.text.trim(),
@@ -1168,49 +1277,49 @@ function apiAddRule(body, query) {
1168
1277
  created_at: new Date().toISOString(),
1169
1278
  active: true
1170
1279
  };
1171
- rules.push(rule);
1172
- fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
1173
- return { success: true, rule };
1280
+ const created = getCanonicalState(projectPath).addRule({
1281
+ rule,
1282
+ actor: body.created_by || 'Dashboard',
1283
+ branch: branchResult.branch,
1284
+ correlationId: rule.id,
1285
+ });
1286
+ if (created.error) return created;
1287
+ return { success: true, rule: created.rule };
1174
1288
  }
1175
1289
 
1176
1290
  function apiUpdateRule(body, query) {
1177
1291
  const projectPath = query.get('project') || null;
1178
- const rulesFile = filePath('rules.json', projectPath);
1292
+ const branchResult = getValidatedBranch(query);
1293
+ if (branchResult.error) return branchResult;
1179
1294
  if (!body.rule_id) return { error: 'Missing rule_id' };
1180
1295
 
1181
- let rules = [];
1182
- if (fs.existsSync(rulesFile)) {
1183
- try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
1184
- }
1185
-
1186
- const rule = rules.find(r => r.id === body.rule_id);
1187
- if (!rule) return { error: 'Rule not found' };
1188
-
1189
- if (body.text !== undefined) rule.text = body.text.trim();
1190
- if (body.category !== undefined) rule.category = body.category;
1191
- if (body.priority !== undefined) rule.priority = body.priority;
1192
- if (body.active !== undefined) rule.active = body.active;
1193
- rule.updated_at = new Date().toISOString();
1194
-
1195
- fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
1196
- return { success: true, rule };
1296
+ const updated = getCanonicalState(projectPath).updateRule({
1297
+ ruleId: body.rule_id,
1298
+ text: body.text !== undefined ? body.text.trim() : undefined,
1299
+ category: body.category,
1300
+ priority: body.priority,
1301
+ active: body.active,
1302
+ actor: body.updated_by || 'Dashboard',
1303
+ branch: branchResult.branch,
1304
+ correlationId: body.rule_id,
1305
+ });
1306
+ if (updated.error) return updated;
1307
+ return { success: true, rule: updated.rule };
1197
1308
  }
1198
1309
 
1199
1310
  function apiDeleteRule(body, query) {
1200
1311
  const projectPath = query.get('project') || null;
1201
- const rulesFile = filePath('rules.json', projectPath);
1312
+ const branchResult = getValidatedBranch(query);
1313
+ if (branchResult.error) return branchResult;
1202
1314
  if (!body.rule_id) return { error: 'Missing rule_id' };
1203
1315
 
1204
- let rules = [];
1205
- if (fs.existsSync(rulesFile)) {
1206
- try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
1207
- }
1208
-
1209
- const idx = rules.findIndex(r => r.id === body.rule_id);
1210
- if (idx === -1) return { error: 'Rule not found' };
1211
- rules.splice(idx, 1);
1212
-
1213
- fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
1316
+ const removed = getCanonicalState(projectPath).removeRule({
1317
+ ruleId: body.rule_id,
1318
+ actor: body.deleted_by || 'Dashboard',
1319
+ branch: branchResult.branch,
1320
+ correlationId: body.rule_id,
1321
+ });
1322
+ if (removed.error) return removed;
1214
1323
  return { success: true };
1215
1324
  }
1216
1325
 
@@ -1228,6 +1337,7 @@ function apiDiscover() {
1228
1337
  const entries = fs.readdirSync(dir, { withFileTypes: true });
1229
1338
  for (const entry of entries) {
1230
1339
  if (!entry.isDirectory()) continue;
1340
+ if (entry.name === DEFAULT_MARKDOWN_WORKSPACE_DIR_NAME) continue;
1231
1341
  if (entry.name.startsWith('.') && entry.name !== '.agent-bridge') continue;
1232
1342
  if (entry.name === 'node_modules') continue;
1233
1343
  const fullPath = path.join(dir, entry.name);
@@ -1253,7 +1363,6 @@ function apiDiscover() {
1253
1363
  scanDir(path.join(home, 'Desktop'), 0);
1254
1364
  scanDir(path.join(home, 'Documents'), 0);
1255
1365
  scanDir(path.join(home, 'Projects'), 0);
1256
- scanDir(path.join(home, 'Desktop', 'Claude Projects'), 0);
1257
1366
  scanDir(path.join(home, 'Desktop', 'Projects'), 0);
1258
1367
  }
1259
1368
 
@@ -1339,124 +1448,42 @@ function apiLaunchAgent(body) {
1339
1448
  async function apiEditMessage(body, query) {
1340
1449
  const projectPath = query.get('project') || null;
1341
1450
  const { id, content } = body;
1451
+ const branch = query.get('branch') || null;
1342
1452
  if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
1343
1453
  if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
1344
1454
 
1345
- const dataDir = resolveDataDir(projectPath);
1346
- const historyFile = path.join(dataDir, 'history.jsonl');
1347
- const messagesFile = path.join(dataDir, 'messages.jsonl');
1348
-
1349
- let found = false;
1350
- const now = new Date().toISOString();
1455
+ if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) return { error: 'Invalid branch name' };
1351
1456
 
1352
- // Update in history.jsonl (locked)
1353
- await withFileLock(historyFile, () => {
1354
- if (fs.existsSync(historyFile)) {
1355
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/).filter(Boolean);
1356
- const updated = lines.map(line => {
1357
- try {
1358
- const msg = JSON.parse(line);
1359
- if (msg.id === id) {
1360
- found = true;
1361
- if (!msg.edit_history) msg.edit_history = [];
1362
- msg.edit_history.push({ content: msg.content, edited_at: now });
1363
- if (msg.edit_history.length > 10) msg.edit_history = msg.edit_history.slice(-10);
1364
- msg.content = content;
1365
- msg.edited = true;
1366
- msg.edited_at = now;
1367
- return JSON.stringify(msg);
1368
- }
1369
- return line;
1370
- } catch { return line; }
1371
- });
1372
- if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
1373
- }
1457
+ const result = getCanonicalState(projectPath).editMessage({
1458
+ id,
1459
+ content,
1460
+ branch,
1461
+ actor: 'Dashboard',
1462
+ maxEditHistory: 10,
1374
1463
  });
1375
1464
 
1376
- // Also update in messages.jsonl (locked independently)
1377
- if (found) {
1378
- await withFileLock(messagesFile, () => {
1379
- if (fs.existsSync(messagesFile)) {
1380
- const raw = fs.readFileSync(messagesFile, 'utf8').trim();
1381
- if (raw) {
1382
- const lines = raw.split(/\r?\n/);
1383
- const updated = lines.map(line => {
1384
- try {
1385
- const msg = JSON.parse(line);
1386
- if (msg.id === id) {
1387
- msg.content = content;
1388
- msg.edited = true;
1389
- msg.edited_at = now;
1390
- return JSON.stringify(msg);
1391
- }
1392
- return line;
1393
- } catch { return line; }
1394
- });
1395
- fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
1396
- }
1397
- }
1398
- });
1399
- }
1400
-
1401
- if (!found) return { error: 'Message not found' };
1402
- return { success: true, id, edited_at: now };
1465
+ if (!result) return { error: 'Message not found' };
1466
+ return { success: true, id, edited_at: result.edited_at };
1403
1467
  }
1404
1468
 
1405
1469
  // --- v3.4: Message Delete ---
1406
1470
  async function apiDeleteMessage(body, query) {
1407
1471
  const projectPath = query.get('project') || null;
1408
1472
  const { id } = body;
1473
+ const branch = query.get('branch') || null;
1409
1474
  if (!id) return { error: 'Missing "id" field' };
1410
1475
 
1411
- const dataDir = resolveDataDir(projectPath);
1412
- const historyFile = path.join(dataDir, 'history.jsonl');
1413
- const messagesFile = path.join(dataDir, 'messages.jsonl');
1414
-
1415
- let found = false;
1416
- let msgFrom = null;
1417
-
1418
- // Find the message and remove from history.jsonl (locked)
1419
- await withFileLock(historyFile, () => {
1420
- if (fs.existsSync(historyFile)) {
1421
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/);
1422
- for (const line of lines) {
1423
- try {
1424
- const msg = JSON.parse(line);
1425
- if (msg.id === id) { found = true; msgFrom = msg.from; break; }
1426
- } catch {}
1427
- }
1428
-
1429
- if (found) {
1430
- const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
1431
- if (allowed.includes(msgFrom)) {
1432
- const filtered = lines.filter(line => {
1433
- try { return JSON.parse(line).id !== id; } catch { return true; }
1434
- });
1435
- fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
1436
- }
1437
- }
1438
- }
1439
- });
1440
-
1441
- if (!found) return { error: 'Message not found' };
1476
+ if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) return { error: 'Invalid branch name' };
1442
1477
 
1443
- // Only allow deleting dashboard-injected or system messages
1444
- const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
1445
- if (!allowed.includes(msgFrom)) {
1446
- return { error: 'Can only delete messages sent from Dashboard or system' };
1447
- }
1448
-
1449
- // Remove from messages.jsonl (locked independently)
1450
- await withFileLock(messagesFile, () => {
1451
- if (fs.existsSync(messagesFile)) {
1452
- const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(/\r?\n/);
1453
- const filtered = lines.filter(line => {
1454
- try { return JSON.parse(line).id !== id; } catch { return true; }
1455
- });
1456
- fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
1457
- }
1478
+ const result = getCanonicalState(projectPath).deleteMessage({
1479
+ id,
1480
+ branch,
1481
+ actor: 'Dashboard',
1482
+ allowedFrom: ['Dashboard', 'dashboard', 'system', '__system__'],
1458
1483
  });
1459
1484
 
1485
+ if (!result.found) return { error: 'Message not found' };
1486
+ if (result.denied) return { error: 'Can only delete messages sent from Dashboard or system' };
1460
1487
  return { success: true, id };
1461
1488
  }
1462
1489
 
@@ -1579,7 +1606,7 @@ function apiUpdatePermissions(body, query) {
1579
1606
  // Load HTML at startup (re-read on each request in dev for hot-reload)
1580
1607
  let htmlContent = fs.readFileSync(HTML_FILE, 'utf8');
1581
1608
 
1582
- const MAX_BODY = 1 * 1024 * 1024; // 1 MB
1609
+ const MAX_BODY = 15 * 1024 * 1024; // 15 MB (supports base64 image attachments)
1583
1610
 
1584
1611
  function parseBody(req) {
1585
1612
  return new Promise((resolve, reject) => {
@@ -1842,8 +1869,8 @@ const server = http.createServer(async (req, res) => {
1842
1869
  const html = fs.readFileSync(HTML_FILE, 'utf8');
1843
1870
  res.writeHead(200, {
1844
1871
  'Content-Type': 'text/html; charset=utf-8',
1845
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
1846
- 'X-Frame-Options': 'DENY',
1872
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'self'",
1873
+ 'X-Frame-Options': 'SAMEORIGIN',
1847
1874
  'X-Content-Type-Options': 'nosniff',
1848
1875
  'Referrer-Policy': 'no-referrer',
1849
1876
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -1852,6 +1879,47 @@ const server = http.createServer(async (req, res) => {
1852
1879
  });
1853
1880
  res.end(html);
1854
1881
  }
1882
+ // --- Assistant private messages API ---
1883
+ else if (url.pathname === '/api/assistant/messages' && req.method === 'GET') {
1884
+ const projectPath = url.searchParams.get('project') || null;
1885
+ const dataDir = resolveDataDir(projectPath);
1886
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
1887
+ const assistantRepliesFile = path.join(dataDir, 'assistant-replies.jsonl');
1888
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10);
1889
+ let messages = [];
1890
+ // Read Dashboard→Assistant messages
1891
+ if (fs.existsSync(assistantMsgFile)) {
1892
+ const lines = fs.readFileSync(assistantMsgFile, 'utf8').split('\n').filter(l => l.trim());
1893
+ for (const line of lines) {
1894
+ try { messages.push(JSON.parse(line)); } catch {}
1895
+ }
1896
+ }
1897
+ // Read Assistant→Dashboard replies
1898
+ if (fs.existsSync(assistantRepliesFile)) {
1899
+ const lines = fs.readFileSync(assistantRepliesFile, 'utf8').split('\n').filter(l => l.trim());
1900
+ for (const line of lines) {
1901
+ try { messages.push(JSON.parse(line)); } catch {}
1902
+ }
1903
+ }
1904
+ // Sort by timestamp and return last N
1905
+ messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1906
+ if (messages.length > limit) messages = messages.slice(-limit);
1907
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1908
+ res.end(JSON.stringify({ messages, total: messages.length }));
1909
+ }
1910
+ // Clear assistant chat
1911
+ else if (url.pathname === '/api/assistant/clear' && req.method === 'POST') {
1912
+ const projectPath = url.searchParams.get('project') || null;
1913
+ const dataDir = resolveDataDir(projectPath);
1914
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
1915
+ const assistantRepliesFile = path.join(dataDir, 'assistant-replies.jsonl');
1916
+ const consumedFile = path.join(dataDir, 'consumed-assistant-private.json');
1917
+ try { fs.writeFileSync(assistantMsgFile, ''); } catch {}
1918
+ try { fs.writeFileSync(assistantRepliesFile, ''); } catch {}
1919
+ try { fs.writeFileSync(consumedFile, '[]'); } catch {}
1920
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1921
+ res.end(JSON.stringify({ success: true }));
1922
+ }
1855
1923
  // Existing APIs (now with ?project= param support)
1856
1924
  else if (url.pathname === '/api/history' && req.method === 'GET') {
1857
1925
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1867,7 +1935,13 @@ const server = http.createServer(async (req, res) => {
1867
1935
  }
1868
1936
  else if (url.pathname === '/api/decisions' && req.method === 'GET') {
1869
1937
  const projectPath = url.searchParams.get('project') || null;
1870
- const decisions = readJson(filePath('decisions.json', projectPath));
1938
+ const branchResult = getValidatedBranch(url.searchParams);
1939
+ if (branchResult.error) {
1940
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1941
+ res.end(JSON.stringify(branchResult));
1942
+ return;
1943
+ }
1944
+ const decisions = getCanonicalState(projectPath).listDecisions({ branch: branchResult.branch });
1871
1945
  res.writeHead(200, { 'Content-Type': 'application/json' });
1872
1946
  res.end(JSON.stringify(decisions || []));
1873
1947
  }
@@ -1917,11 +1991,19 @@ const server = http.createServer(async (req, res) => {
1917
1991
  return;
1918
1992
  }
1919
1993
  const projectPath = url.searchParams.get('project') || null;
1994
+ const branchResult = getValidatedBranch(url.searchParams);
1995
+ if (branchResult.error) {
1996
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1997
+ res.end(JSON.stringify(branchResult));
1998
+ return;
1999
+ }
2000
+ const branch = branchResult.branch;
1920
2001
  const dataDir = resolveDataDir(projectPath);
1921
- const agents = readJson(filePath('agents.json', projectPath));
1922
- const profiles = readJson(filePath('profiles.json', projectPath));
1923
- const tasks = readJson(filePath('tasks.json', projectPath));
1924
- const config = readJson(filePath('config.json', projectPath));
2002
+ const canonicalState = getCanonicalState(projectPath);
2003
+ const agents = canonicalState.listAgents();
2004
+ const profiles = canonicalState.listProfiles();
2005
+ const tasks = canonicalState.listTasks({ branch });
2006
+ const config = canonicalState.getConversationConfigView({ branch });
1925
2007
 
1926
2008
  if (!agents[agentName]) {
1927
2009
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -1929,9 +2011,25 @@ const server = http.createServer(async (req, res) => {
1929
2011
  return;
1930
2012
  }
1931
2013
 
1932
- // Gather recovery snapshot if exists
1933
- const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
1934
- const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
2014
+ // Gather recovery snapshot if it belongs to the selected branch
2015
+ let recovery = null;
2016
+ const latestSessionSummary = canonicalState.getLatestSessionSummaryForAgent({ agentName, branch });
2017
+ const recoveryCandidates = [
2018
+ latestSessionSummary && latestSessionSummary.recovery_snapshot_file,
2019
+ `recovery-${agentName}.json`,
2020
+ ].filter((value, index, values) => typeof value === 'string' && value && values.indexOf(value) === index);
2021
+
2022
+ for (const recoveryCandidate of recoveryCandidates) {
2023
+ const recoveryPath = path.join(dataDir, path.basename(recoveryCandidate));
2024
+ const snapshot = canonicalState.readJson(recoveryPath, null);
2025
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) continue;
2026
+ const snapshotBranch = typeof snapshot.branch === 'string' && BRANCH_NAME_RE.test(snapshot.branch)
2027
+ ? snapshot.branch
2028
+ : 'main';
2029
+ if (snapshotBranch !== branch) continue;
2030
+ recovery = snapshot;
2031
+ break;
2032
+ }
1935
2033
 
1936
2034
  // Gather profile
1937
2035
  const profile = profiles[agentName] || {};
@@ -1942,7 +2040,10 @@ const server = http.createServer(async (req, res) => {
1942
2040
  const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
1943
2041
 
1944
2042
  // Gather recent history context (last 15 messages)
1945
- const history = readJsonl(filePath('history.jsonl', projectPath));
2043
+ const historyView = canonicalState.getHistoryView({ branch, limit: 15 });
2044
+ const history = Array.isArray(historyView)
2045
+ ? historyView
2046
+ : (historyView && Array.isArray(historyView.messages) ? historyView.messages : []);
1946
2047
  const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
1947
2048
 
1948
2049
  // Gather who's online
@@ -1951,14 +2052,8 @@ const server = http.createServer(async (req, res) => {
1951
2052
  .map(([n]) => n);
1952
2053
 
1953
2054
  // Gather workspace status
1954
- let workspaceStatus = '';
1955
- try {
1956
- const wsPath = path.join(dataDir, 'workspaces', agentName + '.json');
1957
- if (fs.existsSync(wsPath)) {
1958
- const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
1959
- if (ws._status) workspaceStatus = ws._status;
1960
- }
1961
- } catch {}
2055
+ const workspace = canonicalState.readWorkspace(agentName, { branch });
2056
+ const workspaceStatus = workspace && typeof workspace._status === 'string' ? workspace._status : '';
1962
2057
 
1963
2058
  // Build the respawn prompt
1964
2059
  const mode = config.conversation_mode || 'group';
@@ -2059,8 +2154,20 @@ const server = http.createServer(async (req, res) => {
2059
2154
  res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
2060
2155
  return;
2061
2156
  }
2062
- res.writeHead(200, { 'Content-Type': 'application/json' });
2063
- res.end(JSON.stringify(apiClearMessages(url.searchParams)));
2157
+ const result = apiClearMessages(url.searchParams);
2158
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2159
+ res.end(JSON.stringify(result));
2160
+ }
2161
+ else if (url.pathname === '/api/clear-tasks' && req.method === 'POST') {
2162
+ const body = await parseBody(req).catch(() => ({}));
2163
+ if (!body.confirm) {
2164
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2165
+ res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
2166
+ return;
2167
+ }
2168
+ const result = apiClearTasks(url.searchParams);
2169
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2170
+ res.end(JSON.stringify(result));
2064
2171
  }
2065
2172
  else if (url.pathname === '/api/new-conversation' && req.method === 'POST') {
2066
2173
  const body = await parseBody(req).catch(() => ({}));
@@ -2099,23 +2206,31 @@ const server = http.createServer(async (req, res) => {
2099
2206
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2100
2207
  res.end(JSON.stringify(result));
2101
2208
  }
2209
+ else if (url.pathname === '/api/projects/reinit' && req.method === 'POST') {
2210
+ const body = await parseBody(req);
2211
+ const result = apiReinitProject(body);
2212
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2213
+ res.end(JSON.stringify(result));
2214
+ }
2102
2215
  else if (url.pathname === '/api/timeline' && req.method === 'GET') {
2103
2216
  res.writeHead(200, { 'Content-Type': 'application/json' });
2104
2217
  res.end(JSON.stringify(apiTimeline(url.searchParams)));
2105
2218
  }
2106
2219
  else if (url.pathname === '/api/tasks' && req.method === 'GET') {
2107
- res.writeHead(200, { 'Content-Type': 'application/json' });
2108
- res.end(JSON.stringify(apiTasks(url.searchParams)));
2220
+ const result = apiTasks(url.searchParams);
2221
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2222
+ res.end(JSON.stringify(result));
2109
2223
  }
2110
2224
  else if (url.pathname === '/api/tasks' && req.method === 'POST') {
2111
2225
  const body = await parseBody(req);
2112
2226
  const result = apiUpdateTask(body, url.searchParams);
2113
- res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2227
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2114
2228
  res.end(JSON.stringify(result));
2115
2229
  }
2116
2230
  else if (url.pathname === '/api/rules' && req.method === 'GET') {
2117
- res.writeHead(200, { 'Content-Type': 'application/json' });
2118
- res.end(JSON.stringify(apiRules(url.searchParams)));
2231
+ const result = apiRules(url.searchParams);
2232
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2233
+ res.end(JSON.stringify(result));
2119
2234
  }
2120
2235
  else if (url.pathname === '/api/rules' && req.method === 'POST') {
2121
2236
  const body = await parseBody(req);
@@ -2129,56 +2244,30 @@ const server = http.createServer(async (req, res) => {
2129
2244
  res.end(JSON.stringify(result));
2130
2245
  }
2131
2246
  else if (url.pathname === '/api/search' && req.method === 'GET') {
2247
+ const result = apiSearch(url.searchParams);
2248
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2249
+ res.end(JSON.stringify(result));
2250
+ }
2251
+ else if (url.pathname === '/api/export-json' && req.method === 'GET') {
2132
2252
  const projectPath = url.searchParams.get('project') || null;
2133
- const query = (url.searchParams.get('q') || '').trim();
2134
- const from = url.searchParams.get('from') || null;
2135
- const limit = Math.min(Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)), 100);
2136
- if (query.length < 2) {
2253
+ const branchResult = getValidatedBranch(url.searchParams);
2254
+ if (branchResult.error) {
2137
2255
  res.writeHead(400, { 'Content-Type': 'application/json' });
2138
- res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
2256
+ res.end(JSON.stringify(branchResult));
2139
2257
  return;
2140
2258
  }
2141
- // Search general history + all channel histories
2142
- let allHistory = readJsonl(filePath('history.jsonl', projectPath));
2143
- const dataDir = resolveDataDir(projectPath);
2144
- try {
2145
- const files = fs.readdirSync(dataDir);
2146
- for (const f of files) {
2147
- if (f.startsWith('channel-') && f.endsWith('-history.jsonl')) {
2148
- allHistory = allHistory.concat(readJsonl(path.join(dataDir, f)));
2149
- }
2150
- }
2151
- } catch {}
2152
- const queryLower = query.toLowerCase();
2153
- const results = [];
2154
- for (let i = allHistory.length - 1; i >= 0 && results.length < limit; i--) {
2155
- const m = allHistory[i];
2156
- if (from && m.from !== from) continue;
2157
- if (m.content && m.content.toLowerCase().includes(queryLower)) {
2158
- results.push({
2159
- id: m.id, from: m.from, to: m.to,
2160
- preview: m.content.substring(0, 200),
2161
- timestamp: m.timestamp,
2162
- ...(m.channel && { channel: m.channel }),
2163
- });
2164
- }
2165
- }
2166
- res.writeHead(200, { 'Content-Type': 'application/json' });
2167
- res.end(JSON.stringify({ query, results_count: results.length, results }));
2168
- }
2169
- else if (url.pathname === '/api/export-json' && req.method === 'GET') {
2170
- const projectPath = url.searchParams.get('project') || null;
2171
- const history = apiHistory(url.searchParams);
2259
+ const canonicalState = getCanonicalState(projectPath);
2260
+ const history = canonicalState.getConversationMessages({ branch: branchResult.branch });
2172
2261
  const agents = apiAgents(url.searchParams);
2173
- const decisions = readJson(filePath('decisions.json', projectPath)) || [];
2174
- const tasksRaw = readJson(filePath('tasks.json', projectPath));
2175
- const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
2176
- const channels = apiChannels(url.searchParams);
2262
+ const decisions = canonicalState.listDecisions({ branch: branchResult.branch });
2263
+ const tasks = canonicalState.listTasks({ branch: branchResult.branch });
2264
+ const channels = canonicalState.getChannelsView({ branch: branchResult.branch });
2177
2265
  const pkg = readJson(path.join(__dirname, 'package.json')) || {};
2178
2266
  const result = {
2179
2267
  export_version: 1,
2180
2268
  exported_at: new Date().toISOString(),
2181
- project: projectPath || process.cwd(),
2269
+ project: projectPath || DEFAULT_PROJECT_ROOT,
2270
+ branch: branchResult.branch,
2182
2271
  version: pkg.version || 'unknown',
2183
2272
  summary: {
2184
2273
  message_count: history.length,
@@ -2204,7 +2293,13 @@ const server = http.createServer(async (req, res) => {
2204
2293
  res.end(JSON.stringify(result, null, 2));
2205
2294
  }
2206
2295
  else if (url.pathname === '/api/export' && req.method === 'GET') {
2207
- const html = apiExportHtml(url.searchParams);
2296
+ const branchResult = getValidatedBranch(url.searchParams);
2297
+ if (branchResult.error) {
2298
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2299
+ res.end(JSON.stringify(branchResult));
2300
+ return;
2301
+ }
2302
+ const html = apiExportHtml(url.searchParams, branchResult.branch);
2208
2303
  res.writeHead(200, {
2209
2304
  'Content-Type': 'text/html; charset=utf-8',
2210
2305
  'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '.html"',
@@ -2252,23 +2347,37 @@ const server = http.createServer(async (req, res) => {
2252
2347
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
2253
2348
  const projectPath = url.searchParams.get('project') || null;
2254
2349
  res.writeHead(200, { 'Content-Type': 'application/json' });
2255
- res.end(JSON.stringify(readJson(filePath('profiles.json', projectPath))));
2350
+ res.end(JSON.stringify(getCanonicalState(projectPath).listProfiles()));
2256
2351
  }
2257
2352
  else if (url.pathname === '/api/profiles' && req.method === 'POST') {
2258
2353
  const body = await parseBody(req);
2259
2354
  const projectPath = url.searchParams.get('project') || null;
2260
- const profilesFile = filePath('profiles.json', projectPath);
2261
- const profiles = readJson(profilesFile);
2262
2355
  if (!body.agent || !/^[a-zA-Z0-9_-]{1,20}$/.test(body.agent)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid agent name' })); return; }
2263
- if (!profiles[body.agent]) profiles[body.agent] = {};
2264
- if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
2265
- if (body.avatar) {
2266
- if (body.avatar.length > 65536) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Avatar too large (max 64KB)' })); return; }
2267
- profiles[body.agent].avatar = body.avatar;
2268
- }
2269
- if (body.bio !== undefined) profiles[body.agent].bio = (body.bio || '').substring(0, 200);
2270
- if (body.role !== undefined) profiles[body.agent].role = (body.role || '').substring(0, 30);
2271
- if (body.appearance !== undefined && typeof body.appearance === 'object') {
2356
+ if (body.display_name !== undefined) {
2357
+ if (body.display_name !== null && typeof body.display_name !== 'string') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'display_name must be a string' })); return; }
2358
+ }
2359
+ if (body.avatar !== undefined) {
2360
+ if (body.avatar !== null && typeof body.avatar !== 'string') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'avatar must be a string' })); return; }
2361
+ if (body.avatar && body.avatar.length > 65536) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Avatar too large (max 64KB)' })); return; }
2362
+ }
2363
+ if (body.bio !== undefined) {
2364
+ if (body.bio !== null && typeof body.bio !== 'string') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'bio must be a string' })); return; }
2365
+ }
2366
+ if (body.role !== undefined) {
2367
+ if (body.role !== null && typeof body.role !== 'string') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'role must be a string' })); return; }
2368
+ }
2369
+ const contractPatch = sanitizeContractProfilePatch({
2370
+ archetype: body.archetype,
2371
+ skills: body.skills,
2372
+ contract_mode: body.contract_mode,
2373
+ });
2374
+ if (!contractPatch.valid) {
2375
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2376
+ res.end(JSON.stringify({ error: contractPatch.errors.join('; ') }));
2377
+ return;
2378
+ }
2379
+ let cleanedAppearance;
2380
+ if (body.appearance && typeof body.appearance === 'object') {
2272
2381
  const validKeys = ['head_color', 'hair_style', 'hair_color', 'eye_style', 'mouth_style', 'shirt_color', 'pants_color', 'shoe_color', 'glasses', 'glasses_color', 'headwear', 'headwear_color', 'neckwear', 'neckwear_color'];
2273
2382
  const enumValidation = {
2274
2383
  hair_style: ['none', 'short', 'spiky', 'long', 'ponytail', 'bob'],
@@ -2278,73 +2387,86 @@ const server = http.createServer(async (req, res) => {
2278
2387
  headwear: ['none', 'beanie', 'cap', 'headphones', 'headband'],
2279
2388
  neckwear: ['none', 'tie', 'bowtie', 'lanyard'],
2280
2389
  };
2281
- const cleaned = {};
2390
+ cleanedAppearance = {};
2282
2391
  for (const [k, v] of Object.entries(body.appearance)) {
2283
2392
  if (!validKeys.includes(k) || typeof v !== 'string' || v.length > 20) continue;
2284
2393
  if (enumValidation[k] && !enumValidation[k].includes(v)) continue;
2285
- cleaned[k] = v;
2394
+ cleanedAppearance[k] = v;
2286
2395
  }
2287
- profiles[body.agent].appearance = Object.assign(profiles[body.agent].appearance || {}, cleaned);
2288
2396
  }
2289
- profiles[body.agent].updated_at = new Date().toISOString();
2290
- fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
2397
+ const canonicalState = getCanonicalState(projectPath);
2398
+ canonicalState.upsertProfile({
2399
+ name: body.agent,
2400
+ displayName: body.display_name,
2401
+ avatar: body.avatar,
2402
+ bio: body.bio,
2403
+ role: body.role,
2404
+ appearance: cleanedAppearance,
2405
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'archetype') ? { archetype: contractPatch.normalized.archetype } : {}),
2406
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'skills') ? { skills: contractPatch.normalized.skills } : {}),
2407
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'contract_mode') ? { contractMode: contractPatch.normalized.contract_mode } : {}),
2408
+ });
2291
2409
  res.writeHead(200, { 'Content-Type': 'application/json' });
2292
2410
  res.end(JSON.stringify({ success: true }));
2293
2411
  }
2294
2412
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
2295
2413
  const projectPath = url.searchParams.get('project') || null;
2414
+ const branchResult = getValidatedBranch(url.searchParams);
2415
+ if (branchResult.error) {
2416
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2417
+ res.end(JSON.stringify(branchResult));
2418
+ return;
2419
+ }
2296
2420
  const agentParam = url.searchParams.get('agent');
2297
2421
  if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
2298
2422
  res.writeHead(400, { 'Content-Type': 'application/json' });
2299
2423
  res.end(JSON.stringify({ error: 'Invalid agent name' }));
2300
2424
  return;
2301
2425
  }
2302
- const dataDir = resolveDataDir(projectPath);
2303
- const wsDir = path.join(dataDir, 'workspaces');
2426
+ const canonicalState = getCanonicalState(projectPath);
2304
2427
  const result = {};
2305
2428
  if (agentParam) {
2306
- const wsFile = path.join(wsDir, agentParam + '.json');
2307
- result[agentParam] = fs.existsSync(wsFile) ? readJson(wsFile) : {};
2308
- } else if (fs.existsSync(wsDir)) {
2309
- for (const f of fs.readdirSync(wsDir).filter(x => x.endsWith('.json'))) {
2310
- const name = f.replace('.json', '');
2311
- result[name] = readJson(path.join(wsDir, f));
2429
+ result[agentParam] = canonicalState.readWorkspace(agentParam, { branch: branchResult.branch });
2430
+ } else {
2431
+ for (const workspace of canonicalState.listWorkspaces({ branch: branchResult.branch })) {
2432
+ result[workspace.agent] = workspace.data;
2312
2433
  }
2313
2434
  }
2314
2435
  res.writeHead(200, { 'Content-Type': 'application/json' });
2315
2436
  res.end(JSON.stringify(result));
2316
2437
  }
2317
2438
  else if (url.pathname === '/api/workflows' && req.method === 'GET') {
2439
+ const branchResult = getValidatedBranch(url.searchParams);
2440
+ if (branchResult.error) {
2441
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2442
+ res.end(JSON.stringify(branchResult));
2443
+ return;
2444
+ }
2318
2445
  const projectPath = url.searchParams.get('project') || null;
2319
- const wfFile = filePath('workflows.json', projectPath);
2320
2446
  res.writeHead(200, { 'Content-Type': 'application/json' });
2321
- res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
2447
+ res.end(JSON.stringify(getCanonicalState(projectPath).listWorkflows({ branch: branchResult.branch })));
2322
2448
  }
2323
2449
  else if (url.pathname === '/api/workflows' && req.method === 'POST') {
2450
+ const branchResult = getValidatedBranch(url.searchParams);
2451
+ if (branchResult.error) {
2452
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2453
+ res.end(JSON.stringify(branchResult));
2454
+ return;
2455
+ }
2324
2456
  const body = await parseBody(req);
2325
2457
  const projectPath = url.searchParams.get('project') || null;
2326
- const wfFile = filePath('workflows.json', projectPath);
2327
- let workflows = [];
2328
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2458
+ const canonicalState = getCanonicalState(projectPath);
2329
2459
  if (body.action === 'advance' && body.workflow_id) {
2330
- const wf = workflows.find(w => w.id === body.workflow_id);
2331
- if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2332
- const curr = wf.steps.find(s => s.status === 'in_progress');
2333
- if (curr) { curr.status = 'done'; curr.completed_at = new Date().toISOString(); if (body.notes) curr.notes = body.notes; }
2334
- const next = wf.steps.find(s => s.status === 'pending');
2335
- if (next) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); } else { wf.status = 'completed'; }
2336
- wf.updated_at = new Date().toISOString();
2337
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2460
+ const result = canonicalState.advanceWorkflow({ workflowId: body.workflow_id, notes: body.notes, branch: branchResult.branch });
2461
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2338
2462
  } else if (body.action === 'skip' && body.workflow_id && body.step_id) {
2339
- const wf = workflows.find(w => w.id === body.workflow_id);
2340
- if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2341
- const step = wf.steps.find(s => s.id === body.step_id);
2342
- if (step) { step.status = 'done'; step.notes = 'Skipped from dashboard'; step.completed_at = new Date().toISOString(); }
2343
- const next = wf.steps.find(s => s.status === 'pending');
2344
- if (next && !wf.steps.find(s => s.status === 'in_progress')) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); }
2345
- if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
2346
- wf.updated_at = new Date().toISOString();
2347
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2463
+ const result = canonicalState.skipWorkflowStep({
2464
+ workflowId: body.workflow_id,
2465
+ stepId: body.step_id,
2466
+ note: 'Skipped from dashboard',
2467
+ branch: branchResult.branch,
2468
+ });
2469
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2348
2470
  } else {
2349
2471
  res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
2350
2472
  }
@@ -2354,297 +2476,169 @@ const server = http.createServer(async (req, res) => {
2354
2476
  // ========== Plan Control API (v5.0 Autonomy Engine) ==========
2355
2477
 
2356
2478
  else if (url.pathname === '/api/plan/status' && req.method === 'GET') {
2357
- const projectPath = url.searchParams.get('project') || null;
2358
- const wfFile = filePath('workflows.json', projectPath);
2359
- const agentsFile = filePath('agents.json', projectPath);
2360
- let workflows = [];
2361
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2362
- const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
2363
-
2364
- // Find the active autonomous workflow (most recent)
2365
- const activeWf = workflows.filter(w => w.status === 'active' && w.autonomous).pop()
2366
- || workflows.filter(w => w.status === 'active').pop();
2367
-
2368
- if (!activeWf) {
2369
- res.writeHead(200, { 'Content-Type': 'application/json' });
2370
- res.end(JSON.stringify({ active: false, message: 'No active plan' }));
2371
- return;
2372
- }
2373
-
2374
- const doneSteps = activeWf.steps.filter(s => s.status === 'done').length;
2375
- const totalSteps = activeWf.steps.length;
2376
- const elapsed = Date.now() - new Date(activeWf.created_at).getTime();
2377
- const activeAgents = Object.entries(agents).filter(([, a]) => {
2378
- const idle = Date.now() - new Date(a.last_activity || 0).getTime();
2379
- return idle < 120000;
2380
- }).length;
2381
-
2382
- const retryCount = activeWf.steps.filter(s => s.flagged).length;
2383
- const avgConfidence = activeWf.steps.filter(s => s.verification && s.verification.confidence)
2384
- .reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
2385
-
2386
- res.writeHead(200, { 'Content-Type': 'application/json' });
2387
- res.end(JSON.stringify({
2388
- active: true,
2389
- workflow_id: activeWf.id,
2390
- name: activeWf.name,
2391
- status: activeWf.status,
2392
- autonomous: !!activeWf.autonomous,
2393
- parallel: !!activeWf.parallel,
2394
- paused: !!activeWf.paused,
2395
- progress: { done: doneSteps, total: totalSteps, percent: Math.round((doneSteps / totalSteps) * 100) },
2396
- elapsed_ms: elapsed,
2397
- elapsed_human: Math.round(elapsed / 60000) + 'm',
2398
- agents_active: activeAgents,
2399
- steps: activeWf.steps.map(s => ({
2400
- id: s.id, description: s.description, assignee: s.assignee,
2401
- status: s.status, depends_on: s.depends_on || [],
2402
- started_at: s.started_at, completed_at: s.completed_at,
2403
- flagged: !!s.flagged, flag_reason: s.flag_reason || null,
2404
- confidence: s.verification ? s.verification.confidence : null,
2405
- verification: s.verification || null,
2406
- })),
2407
- retries: retryCount,
2408
- avg_confidence: Math.round(avgConfidence) || null,
2409
- created_at: activeWf.created_at,
2410
- }));
2479
+ const result = apiPlanStatus(url.searchParams);
2480
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2481
+ res.end(JSON.stringify(result));
2411
2482
  }
2412
2483
 
2413
2484
  else if (url.pathname === '/api/plan/pause' && req.method === 'POST') {
2485
+ const branchResult = getValidatedBranch(url.searchParams);
2486
+ if (branchResult.error) {
2487
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2488
+ res.end(JSON.stringify(branchResult));
2489
+ return;
2490
+ }
2414
2491
  const projectPath = url.searchParams.get('project') || null;
2415
- const wfFile = filePath('workflows.json', projectPath);
2416
- let workflows = [];
2417
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2418
- const activeWf = workflows.find(w => w.status === 'active' && w.autonomous);
2419
- if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active autonomous plan' })); return; }
2420
- activeWf.paused = true;
2421
- activeWf.paused_at = new Date().toISOString();
2422
- activeWf.updated_at = new Date().toISOString();
2423
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2492
+ const result = getCanonicalState(projectPath).pausePlan({ branch: branchResult.branch });
2493
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2424
2494
  // Notify agents
2425
- apiInjectMessage({ to: '__all__', content: `[PLAN PAUSED] "${activeWf.name}" has been paused by the dashboard. Finish your current step, then wait for resume.` }, url.searchParams);
2495
+ apiInjectMessage({ to: '__all__', content: `[PLAN PAUSED] "${result.name}" has been paused by the dashboard. Finish your current step, then wait for resume.` }, url.searchParams);
2426
2496
  res.writeHead(200, { 'Content-Type': 'application/json' });
2427
- res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: activeWf.id }));
2497
+ res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: result.workflow_id }));
2428
2498
  }
2429
2499
 
2430
2500
  else if (url.pathname === '/api/plan/resume' && req.method === 'POST') {
2501
+ const branchResult = getValidatedBranch(url.searchParams);
2502
+ if (branchResult.error) {
2503
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2504
+ res.end(JSON.stringify(branchResult));
2505
+ return;
2506
+ }
2431
2507
  const projectPath = url.searchParams.get('project') || null;
2432
- const wfFile = filePath('workflows.json', projectPath);
2433
- let workflows = [];
2434
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2435
- const pausedWf = workflows.find(w => w.status === 'active' && w.paused);
2436
- if (!pausedWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No paused plan' })); return; }
2437
- pausedWf.paused = false;
2438
- delete pausedWf.paused_at;
2439
- pausedWf.updated_at = new Date().toISOString();
2440
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2441
- apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${pausedWf.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
2508
+ const result = getCanonicalState(projectPath).resumePlan({ branch: branchResult.branch });
2509
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2510
+ apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${result.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
2442
2511
  res.writeHead(200, { 'Content-Type': 'application/json' });
2443
- res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: pausedWf.id }));
2512
+ res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: result.workflow_id }));
2444
2513
  }
2445
2514
 
2446
2515
  else if (url.pathname === '/api/plan/stop' && req.method === 'POST') {
2516
+ const branchResult = getValidatedBranch(url.searchParams);
2517
+ if (branchResult.error) {
2518
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2519
+ res.end(JSON.stringify(branchResult));
2520
+ return;
2521
+ }
2447
2522
  const projectPath = url.searchParams.get('project') || null;
2448
- const wfFile = filePath('workflows.json', projectPath);
2449
- let workflows = [];
2450
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2451
- const activeWf = workflows.find(w => w.status === 'active');
2452
- if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active plan' })); return; }
2453
- activeWf.status = 'stopped';
2454
- activeWf.stopped_at = new Date().toISOString();
2455
- activeWf.updated_at = new Date().toISOString();
2456
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2457
- apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${activeWf.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
2523
+ const result = getCanonicalState(projectPath).stopPlan({ branch: branchResult.branch });
2524
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2525
+ apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${result.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
2458
2526
  res.writeHead(200, { 'Content-Type': 'application/json' });
2459
- res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: activeWf.id }));
2527
+ res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: result.workflow_id }));
2460
2528
  }
2461
2529
 
2462
2530
  else if (url.pathname.startsWith('/api/plan/skip/') && req.method === 'POST') {
2531
+ const branchResult = getValidatedBranch(url.searchParams);
2532
+ if (branchResult.error) {
2533
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2534
+ res.end(JSON.stringify(branchResult));
2535
+ return;
2536
+ }
2463
2537
  const stepId = parseInt(url.pathname.split('/').pop(), 10);
2464
2538
  const body = await parseBody(req);
2465
2539
  const projectPath = url.searchParams.get('project') || null;
2466
- const wfFile = filePath('workflows.json', projectPath);
2467
- let workflows = [];
2468
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2469
- const wfId = body.workflow_id;
2470
- const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
2471
- if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2472
- const step = wf.steps.find(s => s.id === stepId);
2473
- if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
2474
- step.status = 'done';
2475
- step.notes = (step.notes || '') + ' [Skipped from dashboard]';
2476
- step.completed_at = new Date().toISOString();
2477
- step.skipped = true;
2478
- // Start any newly ready steps
2479
- const readySteps = wf.steps.filter(s => {
2480
- if (s.status !== 'pending') return false;
2481
- if (!s.depends_on || s.depends_on.length === 0) return true;
2482
- return s.depends_on.every(depId => { const d = wf.steps.find(x => x.id === depId); return d && d.status === 'done'; });
2540
+ const canonicalState = getCanonicalState(projectPath);
2541
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2542
+ const wfId = body.workflow_id || ((workflows.find(w => w.status === 'active') || {}).id);
2543
+ const result = canonicalState.skipWorkflowStep({
2544
+ workflowId: wfId,
2545
+ stepId,
2546
+ note: ' [Skipped from dashboard]',
2547
+ appendNote: true,
2548
+ markSkipped: true,
2549
+ dependencyAware: true,
2550
+ branch: branchResult.branch,
2483
2551
  });
2484
- for (const rs of readySteps) { rs.status = 'in_progress'; rs.started_at = new Date().toISOString(); }
2485
- if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
2486
- wf.updated_at = new Date().toISOString();
2487
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2552
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: result.error === 'Step not found' ? `Step not found: ${stepId}` : result.error })); return; }
2488
2553
  res.writeHead(200, { 'Content-Type': 'application/json' });
2489
- res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: readySteps.map(s => s.id) }));
2554
+ res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: result.ready_steps }));
2490
2555
  }
2491
2556
 
2492
2557
  else if (url.pathname.startsWith('/api/plan/reassign/') && req.method === 'POST') {
2558
+ const branchResult = getValidatedBranch(url.searchParams);
2559
+ if (branchResult.error) {
2560
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2561
+ res.end(JSON.stringify(branchResult));
2562
+ return;
2563
+ }
2493
2564
  const stepId = parseInt(url.pathname.split('/').pop(), 10);
2494
2565
  const body = await parseBody(req);
2495
2566
  const projectPath = url.searchParams.get('project') || null;
2496
- const wfFile = filePath('workflows.json', projectPath);
2497
2567
  if (!body.new_assignee) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'new_assignee required' })); return; }
2498
- let workflows = [];
2499
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2568
+ const canonicalState = getCanonicalState(projectPath);
2500
2569
  const wfId = body.workflow_id;
2501
- const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
2502
- if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2503
- const step = wf.steps.find(s => s.id === stepId);
2504
- if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
2505
- const oldAssignee = step.assignee;
2506
- step.assignee = body.new_assignee;
2507
- wf.updated_at = new Date().toISOString();
2508
- fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2509
- apiInjectMessage({ to: body.new_assignee, content: `[REASSIGNED] Step ${stepId} "${step.description}" has been reassigned from ${oldAssignee || 'unassigned'} to you. ${step.status === 'in_progress' ? 'This step is IN PROGRESS — pick it up now.' : 'This step is ' + step.status + '.'}` }, url.searchParams);
2570
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2571
+ const workflow = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
2572
+ if (!workflow) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2573
+ const result = canonicalState.reassignWorkflowStep({
2574
+ workflowId: workflow.id,
2575
+ stepId,
2576
+ newAssignee: body.new_assignee,
2577
+ branch: branchResult.branch,
2578
+ });
2579
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: result.error === 'Step not found' ? `Step not found: ${stepId}` : result.error })); return; }
2580
+ const updatedStep = result.step || {};
2581
+ apiInjectMessage({ to: body.new_assignee, content: `[REASSIGNED] Step ${stepId} "${updatedStep.description || 'Unknown step'}" has been reassigned from ${result.old_assignee || 'unassigned'} to you. ${updatedStep.status === 'in_progress' ? 'This step is IN PROGRESS — pick it up now.' : 'This step is ' + (updatedStep.status || 'pending') + '.'}` }, url.searchParams);
2510
2582
  res.writeHead(200, { 'Content-Type': 'application/json' });
2511
- res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: oldAssignee, new_assignee: body.new_assignee }));
2583
+ res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: result.old_assignee, new_assignee: body.new_assignee }));
2512
2584
  }
2513
2585
 
2514
2586
  else if (url.pathname === '/api/plan/inject' && req.method === 'POST') {
2515
2587
  const body = await parseBody(req);
2516
2588
  if (!body.content) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'content required' })); return; }
2517
2589
  const result = apiInjectMessage({ to: body.to || '__all__', content: body.content }, url.searchParams);
2518
- res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2590
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2519
2591
  res.end(JSON.stringify(result));
2520
2592
  }
2521
2593
 
2522
2594
  else if (url.pathname === '/api/plan/report' && req.method === 'GET') {
2523
- const projectPath = url.searchParams.get('project') || null;
2524
- const wfFile = filePath('workflows.json', projectPath);
2525
- const kbFile = filePath('kb.json', projectPath);
2526
- let workflows = [];
2527
- if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2528
- // Get most recent completed or active workflow
2529
- const wf = workflows.filter(w => w.status === 'completed').pop() || workflows.filter(w => w.status === 'active').pop();
2530
- if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
2531
-
2532
- const doneSteps = wf.steps.filter(s => s.status === 'done');
2533
- const flaggedSteps = wf.steps.filter(s => s.flagged);
2534
- const duration = wf.completed_at ? new Date(wf.completed_at) - new Date(wf.created_at) : Date.now() - new Date(wf.created_at).getTime();
2535
- const avgConf = doneSteps.filter(s => s.verification && s.verification.confidence)
2536
- .reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
2537
-
2538
- // Count skills learned during this plan
2539
- let skillCount = 0;
2540
- if (fs.existsSync(kbFile)) {
2541
- try {
2542
- const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
2543
- skillCount = Object.keys(kb).filter(k => k.startsWith('skill_') || k.startsWith('lesson_')).length;
2544
- } catch {}
2545
- }
2546
-
2547
- // Agent-level performance analytics
2548
- const agentStats = {};
2549
- for (const s of wf.steps) {
2550
- if (!s.assignee) continue;
2551
- if (!agentStats[s.assignee]) agentStats[s.assignee] = { steps: 0, completed: 0, flagged: 0, total_ms: 0, confidences: [] };
2552
- agentStats[s.assignee].steps++;
2553
- if (s.status === 'done') {
2554
- agentStats[s.assignee].completed++;
2555
- if (s.completed_at && s.started_at) agentStats[s.assignee].total_ms += new Date(s.completed_at) - new Date(s.started_at);
2556
- if (s.verification && s.verification.confidence) agentStats[s.assignee].confidences.push(s.verification.confidence);
2557
- }
2558
- if (s.flagged) agentStats[s.assignee].flagged++;
2559
- }
2560
- const agentPerformance = Object.entries(agentStats).map(([name, stats]) => ({
2561
- agent: name, steps_assigned: stats.steps, steps_completed: stats.completed, steps_flagged: stats.flagged,
2562
- avg_duration_ms: stats.completed > 0 ? Math.round(stats.total_ms / stats.completed) : null,
2563
- avg_confidence: stats.confidences.length > 0 ? Math.round(stats.confidences.reduce((a, b) => a + b, 0) / stats.confidences.length) : null,
2564
- }));
2565
-
2566
- // Slowest/fastest steps
2567
- const stepsWithDuration = wf.steps.filter(s => s.completed_at && s.started_at)
2568
- .map(s => ({ id: s.id, description: s.description, assignee: s.assignee, duration_ms: new Date(s.completed_at) - new Date(s.started_at) }))
2569
- .sort((a, b) => b.duration_ms - a.duration_ms);
2570
-
2571
- // Retry count from workspace data
2572
- let retryCount = 0;
2573
- const wsDir = path.join(resolveDataDir(projectPath), 'workspaces');
2574
- if (fs.existsSync(wsDir)) {
2575
- for (const file of fs.readdirSync(wsDir)) {
2576
- try {
2577
- const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
2578
- if (ws.retry_history) {
2579
- const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
2580
- if (Array.isArray(history)) retryCount += history.length;
2581
- }
2582
- } catch {}
2583
- }
2584
- }
2585
-
2595
+ const result = apiPlanReport(url.searchParams);
2596
+ if (result && result.error) { res.writeHead(getRouteErrorStatus(result), { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2597
+ if (!result) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
2586
2598
  res.writeHead(200, { 'Content-Type': 'application/json' });
2587
- res.end(JSON.stringify({
2588
- name: wf.name,
2589
- status: wf.status,
2590
- steps_done: doneSteps.length,
2591
- steps_total: wf.steps.length,
2592
- duration_ms: duration,
2593
- duration_human: Math.round(duration / 60000) + 'm',
2594
- avg_confidence: Math.round(avgConf) || null,
2595
- flagged_steps: flaggedSteps.map(s => ({ id: s.id, description: s.description, reason: s.flag_reason })),
2596
- skills_learned: skillCount,
2597
- retries: retryCount,
2598
- agent_performance: agentPerformance,
2599
- slowest_step: stepsWithDuration[0] || null,
2600
- fastest_step: stepsWithDuration[stepsWithDuration.length - 1] || null,
2601
- steps: wf.steps.map(s => ({
2602
- id: s.id, description: s.description, assignee: s.assignee,
2603
- status: s.status, confidence: s.verification ? s.verification.confidence : null,
2604
- duration_ms: s.completed_at && s.started_at ? new Date(s.completed_at) - new Date(s.started_at) : null,
2605
- flagged: !!s.flagged, skipped: !!s.skipped,
2606
- })),
2607
- created_at: wf.created_at,
2608
- completed_at: wf.completed_at || null,
2609
- }));
2599
+ res.end(JSON.stringify(result));
2610
2600
  }
2611
2601
 
2612
2602
  else if (url.pathname === '/api/plan/skills' && req.method === 'GET') {
2603
+ const branchResult = getValidatedBranch(url.searchParams);
2604
+ if (branchResult.error) {
2605
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2606
+ res.end(JSON.stringify(branchResult));
2607
+ return;
2608
+ }
2613
2609
  const projectPath = url.searchParams.get('project') || null;
2614
- const kbFile = filePath('kb.json', projectPath);
2610
+ const kb = getCanonicalState(projectPath).readKnowledgeBase({ branch: branchResult.branch });
2615
2611
  let skills = [];
2616
- if (fs.existsSync(kbFile)) {
2617
- try {
2618
- const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
2619
- for (const [key, val] of Object.entries(kb)) {
2620
- if (key.startsWith('skill_') || key.startsWith('lesson_')) {
2621
- skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
2622
- }
2623
- }
2624
- } catch {}
2612
+ for (const [key, val] of Object.entries(kb || {})) {
2613
+ if (key.startsWith('skill_') || key.startsWith('lesson_')) {
2614
+ skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
2615
+ }
2625
2616
  }
2626
2617
  res.writeHead(200, { 'Content-Type': 'application/json' });
2627
2618
  res.end(JSON.stringify({ count: skills.length, skills }));
2628
2619
  }
2629
2620
 
2630
2621
  else if (url.pathname === '/api/plan/retries' && req.method === 'GET') {
2622
+ const branchResult = getValidatedBranch(url.searchParams);
2623
+ if (branchResult.error) {
2624
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2625
+ res.end(JSON.stringify(branchResult));
2626
+ return;
2627
+ }
2631
2628
  const projectPath = url.searchParams.get('project') || null;
2632
- const dataDir = resolveDataDir(projectPath);
2633
- const wsDir = path.join(dataDir, 'workspaces');
2629
+ const canonicalState = getCanonicalState(projectPath);
2634
2630
  let retries = [];
2635
- if (fs.existsSync(wsDir)) {
2636
- for (const file of fs.readdirSync(wsDir)) {
2637
- try {
2638
- const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
2639
- if (ws.retry_history) {
2640
- const agent = file.replace('.json', '');
2641
- const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
2642
- if (Array.isArray(history)) {
2643
- for (const entry of history) { retries.push({ agent, ...entry }); }
2644
- }
2631
+ for (const workspace of canonicalState.listWorkspaces({ branch: branchResult.branch })) {
2632
+ try {
2633
+ if (workspace.data && workspace.data.retry_history) {
2634
+ const history = typeof workspace.data.retry_history === 'string'
2635
+ ? JSON.parse(workspace.data.retry_history)
2636
+ : workspace.data.retry_history;
2637
+ if (Array.isArray(history)) {
2638
+ for (const entry of history) { retries.push({ agent: workspace.agent, ...entry }); }
2645
2639
  }
2646
- } catch {}
2647
- }
2640
+ }
2641
+ } catch {}
2648
2642
  }
2649
2643
  retries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
2650
2644
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2686,15 +2680,13 @@ const server = http.createServer(async (req, res) => {
2686
2680
 
2687
2681
  // Monitor intervention log from workspace
2688
2682
  let interventions = [];
2689
- const wsDir = path.join(dataDir, 'workspaces');
2690
- if (monitorName && fs.existsSync(wsDir)) {
2691
- const monFile = path.join(wsDir, monitorName[0] + '.json');
2692
- if (fs.existsSync(monFile)) {
2693
- try {
2694
- const ws = JSON.parse(fs.readFileSync(monFile, 'utf8'));
2695
- if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
2696
- } catch {}
2697
- }
2683
+ if (monitorName) {
2684
+ try {
2685
+ const canonicalState = getCanonicalState(projectPath);
2686
+ const monitorBranch = (agents[monitorName[0]] && agents[monitorName[0]].branch) || 'main';
2687
+ const ws = canonicalState.readWorkspace(monitorName[0], { branch: monitorBranch });
2688
+ if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
2689
+ } catch {}
2698
2690
  }
2699
2691
 
2700
2692
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2749,18 +2741,21 @@ const server = http.createServer(async (req, res) => {
2749
2741
 
2750
2742
  else if (url.pathname === '/api/stats' && req.method === 'GET') {
2751
2743
  const projectPath = url.searchParams.get('project') || null;
2744
+ const branchResult = getValidatedBranch(url.searchParams);
2745
+ if (branchResult.error) {
2746
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2747
+ res.end(JSON.stringify(branchResult));
2748
+ return;
2749
+ }
2752
2750
  const dataDir = resolveDataDir(projectPath);
2753
2751
  const agentsFile = filePath('agents.json', projectPath);
2754
- const wfFile = filePath('workflows.json', projectPath);
2755
- const tasksFile = filePath('tasks.json', projectPath);
2756
- const histFile = path.join(dataDir, 'history.jsonl');
2757
- const kbFile = filePath('kb.json', projectPath);
2752
+ const canonicalState = getCanonicalState(projectPath);
2758
2753
 
2759
2754
  const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
2760
- let workflows = []; if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
2761
- let tasks = []; if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
2762
- let msgCount = 0; if (fs.existsSync(histFile)) { try { const c = fs.readFileSync(histFile, 'utf8').trim(); if (c) msgCount = c.split(/\r?\n/).filter(l => l.trim()).length; } catch {} }
2763
- let kbKeys = 0; if (fs.existsSync(kbFile)) try { kbKeys = Object.keys(JSON.parse(fs.readFileSync(kbFile, 'utf8'))).length; } catch {}
2755
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2756
+ const tasks = canonicalState.listTasks({ branch: branchResult.branch });
2757
+ const msgCount = canonicalState.getConversationMessages({ branch: branchResult.branch }).length;
2758
+ const kbKeys = Object.keys(canonicalState.readKnowledgeBase({ branch: branchResult.branch }) || {}).length;
2764
2759
 
2765
2760
  const aliveCount = Object.values(agents).filter(a => { const idle = Date.now() - new Date(a.last_activity || 0).getTime(); return idle < 120000; }).length;
2766
2761
  const activeWf = workflows.filter(w => w.status === 'active');
@@ -2787,60 +2782,45 @@ const server = http.createServer(async (req, res) => {
2787
2782
  // ========== Rules API ==========
2788
2783
 
2789
2784
  else if (url.pathname === '/api/rules' && req.method === 'GET') {
2790
- const projectPath = url.searchParams.get('project') || null;
2791
- const rulesFile = filePath('rules.json', projectPath);
2792
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2793
- res.writeHead(200, { 'Content-Type': 'application/json' });
2794
- res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
2785
+ const result = apiRules(url.searchParams);
2786
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2787
+ res.end(JSON.stringify(result));
2795
2788
  }
2796
2789
 
2797
2790
  else if (url.pathname === '/api/rules' && req.method === 'POST') {
2798
- const projectPath = url.searchParams.get('project') || null;
2799
- const rulesFile = filePath('rules.json', projectPath);
2800
2791
  try {
2801
2792
  const body = await parseBody(req);
2802
- const { text, category } = body;
2803
- if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
2804
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2805
- const rule = {
2806
- id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
2807
- text: text.trim(),
2808
- category: category || 'custom',
2809
- created_by: 'dashboard',
2810
- created_at: new Date().toISOString(),
2811
- active: true,
2812
- };
2813
- rules.push(rule);
2814
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2815
- res.writeHead(201, { 'Content-Type': 'application/json' });
2816
- res.end(JSON.stringify(rule));
2793
+ const result = apiAddRule(body, url.searchParams);
2794
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 201, { 'Content-Type': 'application/json' });
2795
+ res.end(JSON.stringify(result && result.rule ? result.rule : result));
2817
2796
  } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
2818
2797
  }
2819
2798
 
2820
2799
  else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
2821
- const projectPath = url.searchParams.get('project') || null;
2822
- const rulesFile = filePath('rules.json', projectPath);
2823
2800
  const ruleId = url.pathname.split('/api/rules/')[1];
2824
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2825
- const idx = rules.findIndex(r => r.id === ruleId);
2826
- if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
2827
- rules.splice(idx, 1);
2828
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2829
- res.writeHead(200, { 'Content-Type': 'application/json' });
2830
- res.end(JSON.stringify({ success: true }));
2801
+ const result = apiDeleteRule({ rule_id: ruleId }, url.searchParams);
2802
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2803
+ res.end(JSON.stringify(result));
2831
2804
  }
2832
2805
 
2833
2806
  else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
2834
2807
  const projectPath = url.searchParams.get('project') || null;
2835
- const rulesFile = filePath('rules.json', projectPath);
2808
+ const branchResult = getValidatedBranch(url.searchParams);
2809
+ if (branchResult.error) {
2810
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2811
+ res.end(JSON.stringify(branchResult));
2812
+ return;
2813
+ }
2836
2814
  const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
2837
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2838
- const rule = rules.find(r => r.id === ruleId);
2839
- if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
2840
- rule.active = !rule.active;
2841
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2815
+ const toggled = getCanonicalState(projectPath).toggleRule({
2816
+ ruleId,
2817
+ actor: 'Dashboard',
2818
+ branch: branchResult.branch,
2819
+ correlationId: ruleId,
2820
+ });
2821
+ if (toggled.error) { res.writeHead(404); res.end(JSON.stringify({ error: toggled.error })); return; }
2842
2822
  res.writeHead(200, { 'Content-Type': 'application/json' });
2843
- res.end(JSON.stringify(rule));
2823
+ res.end(JSON.stringify(toggled.rule));
2844
2824
  }
2845
2825
 
2846
2826
  // ========== End Rules API ==========
@@ -2986,7 +2966,13 @@ const server = http.createServer(async (req, res) => {
2986
2966
  }
2987
2967
  // --- v3.4: Replay Export ---
2988
2968
  else if (url.pathname === '/api/export-replay' && req.method === 'GET') {
2989
- const html = apiExportReplay(url.searchParams);
2969
+ const branchResult = getValidatedBranch(url.searchParams);
2970
+ if (branchResult.error) {
2971
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2972
+ res.end(JSON.stringify(branchResult));
2973
+ return;
2974
+ }
2975
+ const html = apiExportReplay(url.searchParams, branchResult.branch);
2990
2976
  res.writeHead(200, {
2991
2977
  'Content-Type': 'text/html; charset=utf-8',
2992
2978
  'Content-Disposition': 'attachment; filename="replay-' + new Date().toISOString().slice(0, 10) + '.html"',
@@ -3179,7 +3165,7 @@ const server = http.createServer(async (req, res) => {
3179
3165
  const alive = isPidAlive(info.pid, info.last_activity);
3180
3166
  const lastActivity = info.last_activity || info.timestamp;
3181
3167
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
3182
- const isListening = !!(info.listening_since && alive);
3168
+ const isListening = alive && isRecentlyListening(info);
3183
3169
  const activeTasks = tasks.filter(t => t.assignee === name && t.status !== 'done');
3184
3170
  let behavior = 'dead';
3185
3171
  let location = null;
@@ -3279,6 +3265,82 @@ const server = http.createServer(async (req, res) => {
3279
3265
  res.writeHead(200, { 'Content-Type': 'application/json' });
3280
3266
  res.end(JSON.stringify({ hours, minutes, period, game_minutes: gameMinutes, speed, formatted: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}` }));
3281
3267
  }
3268
+ // ==================== API AGENTS ====================
3269
+ else if (url.pathname === '/api/api-agents' && req.method === 'GET') {
3270
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3271
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3272
+ res.end(JSON.stringify(engine.list()));
3273
+ }
3274
+ else if (url.pathname === '/api/api-agents' && req.method === 'POST') {
3275
+ const body = await parseBody(req);
3276
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3277
+ const result = engine.create(body.name, body.provider, {
3278
+ model: body.model,
3279
+ endpoint: body.endpoint,
3280
+ apiKey: body.apiKey,
3281
+ providerOptions: body.options,
3282
+ });
3283
+ sseNotifyAll('agents');
3284
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3285
+ res.end(JSON.stringify(result));
3286
+ }
3287
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+$/) && req.method === 'DELETE') {
3288
+ const name = decodeURIComponent(url.pathname.split('/').pop());
3289
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3290
+ const result = engine.remove(name);
3291
+ sseNotifyAll('agents');
3292
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3293
+ res.end(JSON.stringify(result));
3294
+ }
3295
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+\/start$/) && req.method === 'POST') {
3296
+ const name = decodeURIComponent(url.pathname.split('/')[3]);
3297
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3298
+ const result = engine.start(name);
3299
+ sseNotifyAll('agents');
3300
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3301
+ res.end(JSON.stringify(result));
3302
+ }
3303
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+\/stop$/) && req.method === 'POST') {
3304
+ const name = decodeURIComponent(url.pathname.split('/')[3]);
3305
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3306
+ const result = engine.stop(name);
3307
+ sseNotifyAll('agents');
3308
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3309
+ res.end(JSON.stringify(result));
3310
+ }
3311
+ // ==================== MEDIA ====================
3312
+ else if (url.pathname === '/api/media' && req.method === 'GET') {
3313
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3314
+ const media = engine.getMedia({
3315
+ type: url.searchParams.get('type'),
3316
+ agent: url.searchParams.get('agent'),
3317
+ page: parseInt(url.searchParams.get('page') || '1', 10),
3318
+ limit: parseInt(url.searchParams.get('limit') || '20', 10),
3319
+ });
3320
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3321
+ res.end(JSON.stringify(media));
3322
+ }
3323
+ else if (url.pathname.match(/^\/api\/media\/[^/]+\/file$/) && req.method === 'GET') {
3324
+ const id = decodeURIComponent(url.pathname.split('/')[3]);
3325
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3326
+ const filep = engine.getMediaFilePath(id);
3327
+ if (filep) {
3328
+ const ext = path.extname(filep).toLowerCase();
3329
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.mp4': 'video/mp4' };
3330
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream', 'Cache-Control': 'public, max-age=3600' });
3331
+ fs.createReadStream(filep).pipe(res);
3332
+ } else {
3333
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3334
+ res.end(JSON.stringify({ error: 'Media not found' }));
3335
+ }
3336
+ }
3337
+ else if (url.pathname.match(/^\/api\/media\/[^/]+$/) && req.method === 'DELETE') {
3338
+ const id = decodeURIComponent(url.pathname.split('/').pop());
3339
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3340
+ const result = engine.deleteMedia(id);
3341
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3342
+ res.end(JSON.stringify(result));
3343
+ }
3282
3344
  else {
3283
3345
  res.writeHead(404, { 'Content-Type': 'application/json' });
3284
3346
  res.end(JSON.stringify({ error: 'Not found' }));
@@ -3365,12 +3427,28 @@ function startFileWatcher() {
3365
3427
 
3366
3428
  startFileWatcher();
3367
3429
 
3430
+ // Initialize API Agent Engine
3431
+ let apiAgentEngine = null;
3432
+ function getApiAgentEngine(projectPath) {
3433
+ const dataDir = resolveDataDir(projectPath);
3434
+ if (!apiAgentEngine || apiAgentEngine.dataDir !== dataDir) {
3435
+ if (apiAgentEngine) apiAgentEngine.stopAll();
3436
+ apiAgentEngine = new ApiAgentEngine(dataDir);
3437
+ }
3438
+ return apiAgentEngine;
3439
+ }
3440
+
3441
+ // Clean up on exit
3442
+ process.on('exit', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); });
3443
+ process.on('SIGINT', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); process.exit(); });
3444
+ process.on('SIGTERM', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); process.exit(); });
3445
+
3368
3446
  server.on('error', (err) => {
3369
3447
  if (err.code === 'EADDRINUSE') {
3370
3448
  console.error(`\n Error: Port ${PORT} is already in use.`);
3371
3449
  console.error(` Another dashboard may be running. Try:`);
3372
3450
  console.error(` - Kill it: npx kill-port ${PORT}`);
3373
- console.error(` - Or use a different port: AGENT_BRIDGE_PORT=3001 npx let-them-talk dashboard\n`);
3451
+ console.error(` - Or use a different port: AGENT_BRIDGE_PORT=3001 node .agent-bridge/launch.js\n`);
3374
3452
  process.exit(1);
3375
3453
  }
3376
3454
  throw err;
@@ -3380,7 +3458,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
3380
3458
  const dataDir = resolveDataDir();
3381
3459
  const lanIP = getLanIP();
3382
3460
  console.log('');
3383
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.5.1');
3461
+ console.log(' Let Them Talk - Agent Bridge Dashboard v5.4.1');
3384
3462
  console.log(' ============================================');
3385
3463
  console.log(' Dashboard: http://localhost:' + PORT);
3386
3464
  if (LAN_MODE && lanIP) {