let-them-talk 5.2.5 → 5.4.0

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 +158 -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 +823 -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 +165 -61
  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 +9546 -7214
  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,6 +190,18 @@ 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
+
167
205
  function isPidAlive(pid, lastActivity) {
168
206
  const STALE_THRESHOLD = 60000; // 60s — if heartbeat updated within this, agent is alive
169
207
 
@@ -214,86 +252,27 @@ function getDefaultAvatar(name) {
214
252
 
215
253
  function apiHistory(query) {
216
254
  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));
255
+ const branchResult = getValidatedBranch(query);
256
+ if (branchResult.error) return branchResult;
241
257
  const limit = Math.min(parseInt(query.get('limit') || '500', 10), 1000);
242
258
  const page = parseInt(query.get('page') || '0', 10);
243
259
  const threadId = query.get('thread_id');
244
260
 
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;
261
+ return getCanonicalState(projectPath).getHistoryView({ branch: branchResult.branch, limit, page, threadId });
268
262
  }
269
263
 
270
264
  function apiChannels(query) {
271
265
  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;
266
+ const branchResult = getValidatedBranch(query);
267
+ if (branchResult.error) return branchResult;
268
+ return getCanonicalState(projectPath).getChannelsView({ branch: branchResult.branch });
291
269
  }
292
270
 
293
271
  function apiAgents(query) {
294
272
  const projectPath = query.get('project') || null;
295
- const agents = readJson(filePath('agents.json', projectPath));
296
- const profiles = readJson(filePath('profiles.json', projectPath));
273
+ const canonicalState = getCanonicalState(projectPath);
274
+ const agents = canonicalState.listAgents();
275
+ const profiles = canonicalState.listProfiles();
297
276
  const history = readJsonl(filePath('history.jsonl', projectPath));
298
277
 
299
278
  // Merge per-agent heartbeat files — agents write these during listen loops
@@ -321,11 +300,24 @@ function apiAgents(query) {
321
300
  }
322
301
 
323
302
  for (const [name, info] of Object.entries(agents)) {
324
- const alive = isPidAlive(info.pid, info.last_activity);
303
+ // API agents: alive = status is 'active' (they share dashboard PID, so PID check is meaningless)
304
+ const alive = info.is_api_agent ? (info.status === 'active') : isPidAlive(info.pid, info.last_activity);
325
305
  const lastActivity = info.last_activity || info.timestamp;
326
306
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
327
307
  const profile = profiles[name] || {};
308
+ const contract = resolveAgentContract(profile);
328
309
  const isLocal = (() => { try { process.kill(info.pid, 0); return true; } catch { return false; } })();
310
+ const runtimeMetadata = resolveAgentRuntimeMetadata({
311
+ name,
312
+ is_api_agent: !!(info.is_api_agent),
313
+ runtime_type: info.runtime_type,
314
+ provider_id: info.provider_id,
315
+ model_id: info.model_id,
316
+ capabilities: info.capabilities,
317
+ provider: info.provider,
318
+ provider_color: info.provider_color,
319
+ bot_capability: info.bot_capability,
320
+ });
329
321
  result[name] = {
330
322
  pid: info.pid,
331
323
  alive,
@@ -333,26 +325,32 @@ function apiAgents(query) {
333
325
  last_activity: lastActivity,
334
326
  last_message: lastMessageTime[name] || null,
335
327
  idle_seconds: alive ? idleSeconds : null,
336
- status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
328
+ status: info.is_api_agent ? (info.status || 'sleeping') : (!alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active'),
337
329
  listening_since: info.listening_since || null,
338
- is_listening: !!(info.listening_since && alive),
339
- provider: info.provider || 'unknown',
330
+ last_listened_at: info.last_listened_at || null,
331
+ is_listening: alive && isRecentlyListening(info),
332
+ runtime_type: runtimeMetadata.runtime_type,
333
+ provider_id: runtimeMetadata.provider_id,
334
+ model_id: runtimeMetadata.model_id,
335
+ capabilities: runtimeMetadata.capabilities,
336
+ provider: runtimeMetadata.provider || 'unknown',
340
337
  branch: info.branch || 'main',
341
338
  display_name: profile.display_name || name,
342
339
  avatar: profile.avatar || getDefaultAvatar(name),
343
340
  role: profile.role || '',
344
341
  bio: profile.bio || '',
342
+ ...buildRuntimeContractMetadata(contract),
345
343
  appearance: profile.appearance || {},
346
344
  hostname: info.hostname || null,
347
345
  is_remote: !isLocal && alive,
346
+ is_api_agent: !!(info.is_api_agent),
347
+ provider_color: runtimeMetadata.provider_color || null,
348
+ bot_capability: runtimeMetadata.bot_capability,
348
349
  };
349
350
  // Include workspace status for agent intent board
350
351
  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
- }
352
+ const workspace = canonicalState.readWorkspace(name, { branch: info.branch || 'main' });
353
+ if (workspace && workspace._status) result[name].current_status = workspace._status;
356
354
  } catch {}
357
355
  }
358
356
  return result;
@@ -397,6 +395,20 @@ function apiStatus(query) {
397
395
  return result;
398
396
  }
399
397
 
398
+ function apiPlanStatus(query) {
399
+ const projectPath = query.get('project') || null;
400
+ const branchResult = getValidatedBranch(query);
401
+ if (branchResult.error) return branchResult;
402
+ return getCanonicalState(projectPath).getPlanStatusView({ branch: branchResult.branch });
403
+ }
404
+
405
+ function apiPlanReport(query) {
406
+ const projectPath = query.get('project') || null;
407
+ const branchResult = getValidatedBranch(query);
408
+ if (branchResult.error) return branchResult;
409
+ return getCanonicalState(projectPath).getPlanReportView({ branch: branchResult.branch });
410
+ }
411
+
400
412
  function apiStats(query) {
401
413
  const projectPath = query.get('project') || null;
402
414
  const history = readJsonl(filePath('history.jsonl', projectPath));
@@ -640,10 +652,9 @@ function apiSearchAll(query) {
640
652
  }
641
653
 
642
654
  // --- v3.4: Replay Export ---
643
- function apiExportReplay(query) {
655
+ function apiExportReplay(query, branchName = 'main') {
644
656
  const projectPath = query.get('project') || null;
645
- const history = readJsonl(filePath('history.jsonl', projectPath));
646
- const profiles = readJson(filePath('profiles.json', projectPath));
657
+ const history = getCanonicalState(projectPath).getConversationMessages({ branch: branchName });
647
658
 
648
659
  const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
649
660
  const agentColors = {};
@@ -715,71 +726,26 @@ showNext();
715
726
 
716
727
  function apiReset(query) {
717
728
  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 };
729
+ return getCanonicalState(projectPath).resetRuntime();
741
730
  }
742
731
 
743
732
  function apiClearMessages(query) {
744
733
  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 };
734
+ const branchResult = getValidatedBranch(query);
735
+ if (branchResult.error) return branchResult;
736
+ return getCanonicalState(projectPath).clearMessages({ branch: branchResult.branch, actor: 'Dashboard' });
737
+ }
738
+
739
+ function apiClearTasks(query) {
740
+ const projectPath = query.get('project') || null;
741
+ const branchResult = getValidatedBranch(query);
742
+ if (branchResult.error) return branchResult;
743
+ return getCanonicalState(projectPath).clearTasks({ branch: branchResult.branch, actor: 'Dashboard' });
758
744
  }
759
745
 
760
746
  function apiNewConversation(query) {
761
747
  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 };
748
+ return getCanonicalState(projectPath).archiveCurrentConversation();
783
749
  }
784
750
 
785
751
  function apiListConversations(query) {
@@ -817,57 +783,30 @@ function apiLoadConversation(query) {
817
783
  if (!name || /[^a-zA-Z0-9_-]/.test(name) || name.length > 100) {
818
784
  return { error: 'Invalid conversation name' };
819
785
  }
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 };
786
+ return getCanonicalState(projectPath).loadConversation(name);
850
787
  }
851
788
 
852
789
  // Inject a message from the dashboard (system message or nudge to an agent)
853
790
  function apiInjectMessage(body, query) {
854
791
  const projectPath = query.get('project') || null;
792
+ const branchResult = getValidatedBranch(query);
793
+ if (branchResult.error) return branchResult;
794
+ const branch = branchResult.branch;
855
795
  const dataDir = resolveDataDir(projectPath);
856
- const messagesFile = path.join(dataDir, 'messages.jsonl');
857
- const historyFile = path.join(dataDir, 'history.jsonl');
796
+ const canonicalState = getCanonicalState(projectPath);
858
797
 
859
- if (!body.to || !body.content) {
798
+ if (!body.to || (!body.content && (!body.attachments || body.attachments.length === 0))) {
860
799
  return { error: 'Missing "to" and/or "content" fields' };
861
800
  }
862
- if (typeof body.content !== 'string' || body.content.length > 100000) {
801
+ if (body.content && (typeof body.content !== 'string' || body.content.length > 100000)) {
863
802
  return { error: 'Message content too long (max 100KB)' };
864
803
  }
865
804
  if (body.to !== '__all__' && !/^[a-zA-Z0-9_-]{1,20}$/.test(body.to)) {
866
805
  return { error: 'Invalid agent name' };
867
806
  }
868
-
869
807
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
870
- const fromName = 'Dashboard';
808
+ // Allow sending as 'Owner' or 'Dashboard' (both are treated as high-priority by agents)
809
+ const fromName = (body.from === 'Owner' || body.from === 'owner') ? 'Owner' : 'Dashboard';
871
810
  const now = new Date().toISOString();
872
811
 
873
812
  // Broadcast to all agents
@@ -879,12 +818,12 @@ function apiInjectMessage(body, query) {
879
818
  id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
880
819
  from: fromName,
881
820
  to: name,
882
- content: body.content,
821
+ content: body.content || '',
883
822
  timestamp: now,
884
823
  system: true,
885
824
  };
886
- fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
887
- fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
825
+ if (body.attachments && body.attachments.length > 0) msg.attachments = body.attachments;
826
+ canonicalState.appendMessage(msg, { branch });
888
827
  ids.push(msg.id);
889
828
  }
890
829
  return { success: true, messageIds: ids, broadcast: true };
@@ -894,13 +833,19 @@ function apiInjectMessage(body, query) {
894
833
  id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
895
834
  from: fromName,
896
835
  to: body.to,
897
- content: body.content,
836
+ content: body.content || '',
898
837
  timestamp: now,
899
838
  system: true,
900
839
  };
840
+ if (body.attachments && body.attachments.length > 0) msg.attachments = body.attachments;
901
841
 
902
- fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
903
- fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
842
+ // Route to private assistant channel if targeting Assistant
843
+ if (body.to === 'Assistant' && body.assistant_private === true) {
844
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
845
+ fs.appendFileSync(assistantMsgFile, JSON.stringify(msg) + '\n');
846
+ } else {
847
+ canonicalState.appendMessage(msg, { branch });
848
+ }
904
849
 
905
850
  return { success: true, messageId: msg.id };
906
851
  }
@@ -928,17 +873,111 @@ function apiAddProject(body) {
928
873
  const name = body.name || path.basename(absPath);
929
874
  if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
930
875
 
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 });
876
+ const initLogs = [];
877
+ let initResult;
878
+ try {
879
+ initResult = initProject({
880
+ cwd: absPath,
881
+ flag: DASHBOARD_INIT_FLAG,
882
+ argv: [process.execPath, path.join(__dirname, 'cli.js'), 'init', DASHBOARD_INIT_FLAG],
883
+ log: (line) => initLogs.push(typeof line === 'string' ? line : String(line || '')),
884
+ });
885
+ } catch (error) {
886
+ return {
887
+ error: 'Project initialization failed: ' + error.message,
888
+ init_failed: true,
889
+ project: { name, path: absPath },
890
+ initialization: {
891
+ mode: DASHBOARD_INIT_FLAG,
892
+ logs: initLogs,
893
+ },
894
+ };
895
+ }
934
896
 
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);
897
+ const launcherPath = initResult && initResult.launcherPath
898
+ ? initResult.launcherPath
899
+ : path.join(absPath, DEFAULT_DATA_DIR_NAME, 'launch.js');
900
+ if (!fs.existsSync(launcherPath)) {
901
+ return {
902
+ error: 'Project initialization failed: expected launcher missing at ' + launcherPath,
903
+ init_failed: true,
904
+ project: { name, path: absPath },
905
+ initialization: {
906
+ mode: DASHBOARD_INIT_FLAG,
907
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
908
+ launcher_path: launcherPath,
909
+ logs: initLogs,
910
+ },
911
+ };
912
+ }
938
913
 
939
- projects.push({ name, path: absPath, added_at: new Date().toISOString() });
940
- saveProjects(projects);
941
- return { success: true, project: { name, path: absPath } };
914
+ try {
915
+ projects.push({ name, path: absPath, added_at: new Date().toISOString() });
916
+ saveProjects(projects);
917
+ } catch (error) {
918
+ return {
919
+ error: 'Project registration failed after initialization: ' + error.message + '. The folder was initialized but not added to projects.json.',
920
+ project: { name, path: absPath },
921
+ initialization: {
922
+ mode: DASHBOARD_INIT_FLAG,
923
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
924
+ launcher_path: launcherPath,
925
+ logs: initLogs,
926
+ },
927
+ };
928
+ }
929
+
930
+ return {
931
+ success: true,
932
+ project: { name, path: absPath },
933
+ initialization: {
934
+ mode: DASHBOARD_INIT_FLAG,
935
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
936
+ launcher_path: launcherPath,
937
+ logs: initLogs,
938
+ },
939
+ };
940
+ }
941
+
942
+ function apiReinitProject(body) {
943
+ const targetPath = body && body.path ? body.path : null;
944
+ if (!targetPath) return { error: 'Missing "path" field' };
945
+ const absPath = path.resolve(targetPath);
946
+ if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
947
+
948
+ const projects = getProjects();
949
+ const known = projects.find((p) => p.path === absPath);
950
+ if (!known) {
951
+ return { error: 'Project is not registered. Use Add Project first.' };
952
+ }
953
+
954
+ const initLogs = [];
955
+ let initResult;
956
+ try {
957
+ initResult = initProject({
958
+ cwd: absPath,
959
+ flag: DASHBOARD_INIT_FLAG,
960
+ argv: [process.execPath, path.join(__dirname, 'cli.js'), 'init', DASHBOARD_INIT_FLAG],
961
+ log: (line) => initLogs.push(typeof line === 'string' ? line : String(line || '')),
962
+ });
963
+ } catch (error) {
964
+ return {
965
+ error: 'Reinstall failed: ' + error.message,
966
+ project: { name: known.name, path: absPath },
967
+ initialization: { mode: DASHBOARD_INIT_FLAG, logs: initLogs },
968
+ };
969
+ }
970
+
971
+ return {
972
+ success: true,
973
+ project: { name: known.name, path: absPath },
974
+ initialization: {
975
+ mode: DASHBOARD_INIT_FLAG,
976
+ targets: initResult && Array.isArray(initResult.targets) ? initResult.targets : [],
977
+ launcher_path: initResult && initResult.launcherPath ? initResult.launcherPath : null,
978
+ logs: initLogs,
979
+ },
980
+ };
942
981
  }
943
982
 
944
983
  function apiRemoveProject(body) {
@@ -953,12 +992,9 @@ function apiRemoveProject(body) {
953
992
  }
954
993
 
955
994
  // Export conversation as self-contained HTML
956
- function apiExportHtml(query) {
995
+ function apiExportHtml(query, branchName = 'main') {
957
996
  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]; });
997
+ const history = getCanonicalState(projectPath).getConversationMessages({ branch: branchName });
962
998
 
963
999
  const agentNames = [...new Set(history.map(m => m.from))];
964
1000
  const exportDate = new Date().toLocaleString();
@@ -1109,56 +1145,88 @@ function apiTimeline(query) {
1109
1145
  // Tasks API
1110
1146
  function apiTasks(query) {
1111
1147
  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 []; }
1148
+ const branchResult = getValidatedBranch(query);
1149
+ if (branchResult.error) return branchResult;
1150
+ return getCanonicalState(projectPath).listTasks({ branch: branchResult.branch });
1115
1151
  }
1116
1152
 
1117
- function apiUpdateTask(body, query) {
1153
+ function apiSearch(query) {
1118
1154
  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' };
1155
+ const branchResult = getValidatedBranch(query);
1156
+ if (branchResult.error) return branchResult;
1121
1157
 
1122
- let tasks = [];
1123
- if (fs.existsSync(tasksFile)) {
1124
- try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
1158
+ const searchQuery = (query.get('q') || '').trim();
1159
+ const from = query.get('from') || null;
1160
+ const limit = Math.min(Math.max(1, parseInt(query.get('limit') || '50', 10)), 100);
1161
+
1162
+ if (searchQuery.length < 2) {
1163
+ return { error: 'Query must be at least 2 characters' };
1125
1164
  }
1126
1165
 
1127
- const task = tasks.find(t => t.id === body.task_id);
1128
- if (!task) return { error: 'Task not found' };
1166
+ const results = getCanonicalState(projectPath).getSearchResultsView({
1167
+ branch: branchResult.branch,
1168
+ query: searchQuery,
1169
+ from,
1170
+ limit,
1171
+ });
1172
+
1173
+ return {
1174
+ query: searchQuery,
1175
+ results_count: results.length,
1176
+ results,
1177
+ };
1178
+ }
1129
1179
 
1130
- const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
1180
+ function apiUpdateTask(body, query) {
1181
+ const projectPath = query.get('project') || null;
1182
+ const branchResult = getValidatedBranch(query);
1183
+ if (branchResult.error) return branchResult;
1184
+ if (!body.task_id || !body.status) return { error: 'Missing task_id or status' };
1185
+
1186
+ const validStatuses = ['pending', 'in_progress', 'in_review', 'done', 'blocked'];
1131
1187
  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() });
1188
+
1189
+ let evidence = null;
1190
+ if (body.status === 'done') {
1191
+ const provided = body.evidence && typeof body.evidence === 'object' ? body.evidence : {};
1192
+ evidence = {
1193
+ summary: (typeof provided.summary === 'string' && provided.summary.trim())
1194
+ || (typeof body.notes === 'string' && body.notes.trim())
1195
+ || 'Marked complete by operator from dashboard.',
1196
+ verification: (typeof provided.verification === 'string' && provided.verification.trim())
1197
+ || 'operator_marked',
1198
+ files_changed: Array.isArray(provided.files_changed) ? provided.files_changed : [],
1199
+ confidence: Number.isFinite(provided.confidence) ? provided.confidence : 100,
1200
+ };
1137
1201
  }
1138
1202
 
1139
- fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
1140
- return { success: true, task_id: task.id, status: task.status };
1203
+ return getCanonicalState(projectPath).updateTaskStatus({
1204
+ taskId: body.task_id,
1205
+ status: body.status,
1206
+ notes: body.notes,
1207
+ actor: 'Dashboard',
1208
+ branch: branchResult.branch,
1209
+ sourceTool: 'dashboard_update_task',
1210
+ correlationId: body.task_id,
1211
+ ...(evidence && { evidence }),
1212
+ });
1141
1213
  }
1142
1214
 
1143
1215
  // Rules API
1144
1216
  function apiRules(query) {
1145
1217
  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 []; }
1218
+ const branchResult = getValidatedBranch(query);
1219
+ if (branchResult.error) return branchResult;
1220
+ return getCanonicalState(projectPath).listRules({ branch: branchResult.branch });
1149
1221
  }
1150
1222
 
1151
1223
  function apiAddRule(body, query) {
1152
1224
  const projectPath = query.get('project') || null;
1153
- const rulesFile = filePath('rules.json', projectPath);
1225
+ const branchResult = getValidatedBranch(query);
1226
+ if (branchResult.error) return branchResult;
1154
1227
  if (!body.text || !body.text.trim()) return { error: 'Rule text is required' };
1155
1228
 
1156
1229
  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
1230
  const rule = {
1163
1231
  id: 'rule_' + crypto.randomBytes(6).toString('hex'),
1164
1232
  text: body.text.trim(),
@@ -1168,49 +1236,49 @@ function apiAddRule(body, query) {
1168
1236
  created_at: new Date().toISOString(),
1169
1237
  active: true
1170
1238
  };
1171
- rules.push(rule);
1172
- fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
1173
- return { success: true, rule };
1239
+ const created = getCanonicalState(projectPath).addRule({
1240
+ rule,
1241
+ actor: body.created_by || 'Dashboard',
1242
+ branch: branchResult.branch,
1243
+ correlationId: rule.id,
1244
+ });
1245
+ if (created.error) return created;
1246
+ return { success: true, rule: created.rule };
1174
1247
  }
1175
1248
 
1176
1249
  function apiUpdateRule(body, query) {
1177
1250
  const projectPath = query.get('project') || null;
1178
- const rulesFile = filePath('rules.json', projectPath);
1251
+ const branchResult = getValidatedBranch(query);
1252
+ if (branchResult.error) return branchResult;
1179
1253
  if (!body.rule_id) return { error: 'Missing rule_id' };
1180
1254
 
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 };
1255
+ const updated = getCanonicalState(projectPath).updateRule({
1256
+ ruleId: body.rule_id,
1257
+ text: body.text !== undefined ? body.text.trim() : undefined,
1258
+ category: body.category,
1259
+ priority: body.priority,
1260
+ active: body.active,
1261
+ actor: body.updated_by || 'Dashboard',
1262
+ branch: branchResult.branch,
1263
+ correlationId: body.rule_id,
1264
+ });
1265
+ if (updated.error) return updated;
1266
+ return { success: true, rule: updated.rule };
1197
1267
  }
1198
1268
 
1199
1269
  function apiDeleteRule(body, query) {
1200
1270
  const projectPath = query.get('project') || null;
1201
- const rulesFile = filePath('rules.json', projectPath);
1271
+ const branchResult = getValidatedBranch(query);
1272
+ if (branchResult.error) return branchResult;
1202
1273
  if (!body.rule_id) return { error: 'Missing rule_id' };
1203
1274
 
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));
1275
+ const removed = getCanonicalState(projectPath).removeRule({
1276
+ ruleId: body.rule_id,
1277
+ actor: body.deleted_by || 'Dashboard',
1278
+ branch: branchResult.branch,
1279
+ correlationId: body.rule_id,
1280
+ });
1281
+ if (removed.error) return removed;
1214
1282
  return { success: true };
1215
1283
  }
1216
1284
 
@@ -1228,6 +1296,7 @@ function apiDiscover() {
1228
1296
  const entries = fs.readdirSync(dir, { withFileTypes: true });
1229
1297
  for (const entry of entries) {
1230
1298
  if (!entry.isDirectory()) continue;
1299
+ if (entry.name === DEFAULT_MARKDOWN_WORKSPACE_DIR_NAME) continue;
1231
1300
  if (entry.name.startsWith('.') && entry.name !== '.agent-bridge') continue;
1232
1301
  if (entry.name === 'node_modules') continue;
1233
1302
  const fullPath = path.join(dir, entry.name);
@@ -1253,7 +1322,6 @@ function apiDiscover() {
1253
1322
  scanDir(path.join(home, 'Desktop'), 0);
1254
1323
  scanDir(path.join(home, 'Documents'), 0);
1255
1324
  scanDir(path.join(home, 'Projects'), 0);
1256
- scanDir(path.join(home, 'Desktop', 'Claude Projects'), 0);
1257
1325
  scanDir(path.join(home, 'Desktop', 'Projects'), 0);
1258
1326
  }
1259
1327
 
@@ -1339,124 +1407,42 @@ function apiLaunchAgent(body) {
1339
1407
  async function apiEditMessage(body, query) {
1340
1408
  const projectPath = query.get('project') || null;
1341
1409
  const { id, content } = body;
1410
+ const branch = query.get('branch') || null;
1342
1411
  if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
1343
1412
  if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
1344
1413
 
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();
1414
+ if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) return { error: 'Invalid branch name' };
1351
1415
 
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
- }
1416
+ const result = getCanonicalState(projectPath).editMessage({
1417
+ id,
1418
+ content,
1419
+ branch,
1420
+ actor: 'Dashboard',
1421
+ maxEditHistory: 10,
1374
1422
  });
1375
1423
 
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 };
1424
+ if (!result) return { error: 'Message not found' };
1425
+ return { success: true, id, edited_at: result.edited_at };
1403
1426
  }
1404
1427
 
1405
1428
  // --- v3.4: Message Delete ---
1406
1429
  async function apiDeleteMessage(body, query) {
1407
1430
  const projectPath = query.get('project') || null;
1408
1431
  const { id } = body;
1432
+ const branch = query.get('branch') || null;
1409
1433
  if (!id) return { error: 'Missing "id" field' };
1410
1434
 
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
- });
1435
+ if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) return { error: 'Invalid branch name' };
1440
1436
 
1441
- if (!found) return { error: 'Message not found' };
1442
-
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
- }
1437
+ const result = getCanonicalState(projectPath).deleteMessage({
1438
+ id,
1439
+ branch,
1440
+ actor: 'Dashboard',
1441
+ allowedFrom: ['Dashboard', 'dashboard', 'system', '__system__'],
1458
1442
  });
1459
1443
 
1444
+ if (!result.found) return { error: 'Message not found' };
1445
+ if (result.denied) return { error: 'Can only delete messages sent from Dashboard or system' };
1460
1446
  return { success: true, id };
1461
1447
  }
1462
1448
 
@@ -1579,7 +1565,7 @@ function apiUpdatePermissions(body, query) {
1579
1565
  // Load HTML at startup (re-read on each request in dev for hot-reload)
1580
1566
  let htmlContent = fs.readFileSync(HTML_FILE, 'utf8');
1581
1567
 
1582
- const MAX_BODY = 1 * 1024 * 1024; // 1 MB
1568
+ const MAX_BODY = 15 * 1024 * 1024; // 15 MB (supports base64 image attachments)
1583
1569
 
1584
1570
  function parseBody(req) {
1585
1571
  return new Promise((resolve, reject) => {
@@ -1842,8 +1828,8 @@ const server = http.createServer(async (req, res) => {
1842
1828
  const html = fs.readFileSync(HTML_FILE, 'utf8');
1843
1829
  res.writeHead(200, {
1844
1830
  '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',
1831
+ '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'",
1832
+ 'X-Frame-Options': 'SAMEORIGIN',
1847
1833
  'X-Content-Type-Options': 'nosniff',
1848
1834
  'Referrer-Policy': 'no-referrer',
1849
1835
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -1852,6 +1838,47 @@ const server = http.createServer(async (req, res) => {
1852
1838
  });
1853
1839
  res.end(html);
1854
1840
  }
1841
+ // --- Assistant private messages API ---
1842
+ else if (url.pathname === '/api/assistant/messages' && req.method === 'GET') {
1843
+ const projectPath = url.searchParams.get('project') || null;
1844
+ const dataDir = resolveDataDir(projectPath);
1845
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
1846
+ const assistantRepliesFile = path.join(dataDir, 'assistant-replies.jsonl');
1847
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10);
1848
+ let messages = [];
1849
+ // Read Dashboard→Assistant messages
1850
+ if (fs.existsSync(assistantMsgFile)) {
1851
+ const lines = fs.readFileSync(assistantMsgFile, 'utf8').split('\n').filter(l => l.trim());
1852
+ for (const line of lines) {
1853
+ try { messages.push(JSON.parse(line)); } catch {}
1854
+ }
1855
+ }
1856
+ // Read Assistant→Dashboard replies
1857
+ if (fs.existsSync(assistantRepliesFile)) {
1858
+ const lines = fs.readFileSync(assistantRepliesFile, 'utf8').split('\n').filter(l => l.trim());
1859
+ for (const line of lines) {
1860
+ try { messages.push(JSON.parse(line)); } catch {}
1861
+ }
1862
+ }
1863
+ // Sort by timestamp and return last N
1864
+ messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1865
+ if (messages.length > limit) messages = messages.slice(-limit);
1866
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1867
+ res.end(JSON.stringify({ messages, total: messages.length }));
1868
+ }
1869
+ // Clear assistant chat
1870
+ else if (url.pathname === '/api/assistant/clear' && req.method === 'POST') {
1871
+ const projectPath = url.searchParams.get('project') || null;
1872
+ const dataDir = resolveDataDir(projectPath);
1873
+ const assistantMsgFile = path.join(dataDir, 'assistant-messages.jsonl');
1874
+ const assistantRepliesFile = path.join(dataDir, 'assistant-replies.jsonl');
1875
+ const consumedFile = path.join(dataDir, 'consumed-assistant-private.json');
1876
+ try { fs.writeFileSync(assistantMsgFile, ''); } catch {}
1877
+ try { fs.writeFileSync(assistantRepliesFile, ''); } catch {}
1878
+ try { fs.writeFileSync(consumedFile, '[]'); } catch {}
1879
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1880
+ res.end(JSON.stringify({ success: true }));
1881
+ }
1855
1882
  // Existing APIs (now with ?project= param support)
1856
1883
  else if (url.pathname === '/api/history' && req.method === 'GET') {
1857
1884
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1867,7 +1894,13 @@ const server = http.createServer(async (req, res) => {
1867
1894
  }
1868
1895
  else if (url.pathname === '/api/decisions' && req.method === 'GET') {
1869
1896
  const projectPath = url.searchParams.get('project') || null;
1870
- const decisions = readJson(filePath('decisions.json', projectPath));
1897
+ const branchResult = getValidatedBranch(url.searchParams);
1898
+ if (branchResult.error) {
1899
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1900
+ res.end(JSON.stringify(branchResult));
1901
+ return;
1902
+ }
1903
+ const decisions = getCanonicalState(projectPath).listDecisions({ branch: branchResult.branch });
1871
1904
  res.writeHead(200, { 'Content-Type': 'application/json' });
1872
1905
  res.end(JSON.stringify(decisions || []));
1873
1906
  }
@@ -1917,11 +1950,19 @@ const server = http.createServer(async (req, res) => {
1917
1950
  return;
1918
1951
  }
1919
1952
  const projectPath = url.searchParams.get('project') || null;
1953
+ const branchResult = getValidatedBranch(url.searchParams);
1954
+ if (branchResult.error) {
1955
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1956
+ res.end(JSON.stringify(branchResult));
1957
+ return;
1958
+ }
1959
+ const branch = branchResult.branch;
1920
1960
  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));
1961
+ const canonicalState = getCanonicalState(projectPath);
1962
+ const agents = canonicalState.listAgents();
1963
+ const profiles = canonicalState.listProfiles();
1964
+ const tasks = canonicalState.listTasks({ branch });
1965
+ const config = canonicalState.getConversationConfigView({ branch });
1925
1966
 
1926
1967
  if (!agents[agentName]) {
1927
1968
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -1929,9 +1970,25 @@ const server = http.createServer(async (req, res) => {
1929
1970
  return;
1930
1971
  }
1931
1972
 
1932
- // Gather recovery snapshot if exists
1933
- const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
1934
- const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
1973
+ // Gather recovery snapshot if it belongs to the selected branch
1974
+ let recovery = null;
1975
+ const latestSessionSummary = canonicalState.getLatestSessionSummaryForAgent({ agentName, branch });
1976
+ const recoveryCandidates = [
1977
+ latestSessionSummary && latestSessionSummary.recovery_snapshot_file,
1978
+ `recovery-${agentName}.json`,
1979
+ ].filter((value, index, values) => typeof value === 'string' && value && values.indexOf(value) === index);
1980
+
1981
+ for (const recoveryCandidate of recoveryCandidates) {
1982
+ const recoveryPath = path.join(dataDir, path.basename(recoveryCandidate));
1983
+ const snapshot = canonicalState.readJson(recoveryPath, null);
1984
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) continue;
1985
+ const snapshotBranch = typeof snapshot.branch === 'string' && BRANCH_NAME_RE.test(snapshot.branch)
1986
+ ? snapshot.branch
1987
+ : 'main';
1988
+ if (snapshotBranch !== branch) continue;
1989
+ recovery = snapshot;
1990
+ break;
1991
+ }
1935
1992
 
1936
1993
  // Gather profile
1937
1994
  const profile = profiles[agentName] || {};
@@ -1942,7 +1999,10 @@ const server = http.createServer(async (req, res) => {
1942
1999
  const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
1943
2000
 
1944
2001
  // Gather recent history context (last 15 messages)
1945
- const history = readJsonl(filePath('history.jsonl', projectPath));
2002
+ const historyView = canonicalState.getHistoryView({ branch, limit: 15 });
2003
+ const history = Array.isArray(historyView)
2004
+ ? historyView
2005
+ : (historyView && Array.isArray(historyView.messages) ? historyView.messages : []);
1946
2006
  const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
1947
2007
 
1948
2008
  // Gather who's online
@@ -1951,14 +2011,8 @@ const server = http.createServer(async (req, res) => {
1951
2011
  .map(([n]) => n);
1952
2012
 
1953
2013
  // 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 {}
2014
+ const workspace = canonicalState.readWorkspace(agentName, { branch });
2015
+ const workspaceStatus = workspace && typeof workspace._status === 'string' ? workspace._status : '';
1962
2016
 
1963
2017
  // Build the respawn prompt
1964
2018
  const mode = config.conversation_mode || 'group';
@@ -2059,8 +2113,20 @@ const server = http.createServer(async (req, res) => {
2059
2113
  res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
2060
2114
  return;
2061
2115
  }
2062
- res.writeHead(200, { 'Content-Type': 'application/json' });
2063
- res.end(JSON.stringify(apiClearMessages(url.searchParams)));
2116
+ const result = apiClearMessages(url.searchParams);
2117
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2118
+ res.end(JSON.stringify(result));
2119
+ }
2120
+ else if (url.pathname === '/api/clear-tasks' && req.method === 'POST') {
2121
+ const body = await parseBody(req).catch(() => ({}));
2122
+ if (!body.confirm) {
2123
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2124
+ res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
2125
+ return;
2126
+ }
2127
+ const result = apiClearTasks(url.searchParams);
2128
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2129
+ res.end(JSON.stringify(result));
2064
2130
  }
2065
2131
  else if (url.pathname === '/api/new-conversation' && req.method === 'POST') {
2066
2132
  const body = await parseBody(req).catch(() => ({}));
@@ -2099,23 +2165,31 @@ const server = http.createServer(async (req, res) => {
2099
2165
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2100
2166
  res.end(JSON.stringify(result));
2101
2167
  }
2168
+ else if (url.pathname === '/api/projects/reinit' && req.method === 'POST') {
2169
+ const body = await parseBody(req);
2170
+ const result = apiReinitProject(body);
2171
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2172
+ res.end(JSON.stringify(result));
2173
+ }
2102
2174
  else if (url.pathname === '/api/timeline' && req.method === 'GET') {
2103
2175
  res.writeHead(200, { 'Content-Type': 'application/json' });
2104
2176
  res.end(JSON.stringify(apiTimeline(url.searchParams)));
2105
2177
  }
2106
2178
  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)));
2179
+ const result = apiTasks(url.searchParams);
2180
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2181
+ res.end(JSON.stringify(result));
2109
2182
  }
2110
2183
  else if (url.pathname === '/api/tasks' && req.method === 'POST') {
2111
2184
  const body = await parseBody(req);
2112
2185
  const result = apiUpdateTask(body, url.searchParams);
2113
- res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2186
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2114
2187
  res.end(JSON.stringify(result));
2115
2188
  }
2116
2189
  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)));
2190
+ const result = apiRules(url.searchParams);
2191
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2192
+ res.end(JSON.stringify(result));
2119
2193
  }
2120
2194
  else if (url.pathname === '/api/rules' && req.method === 'POST') {
2121
2195
  const body = await parseBody(req);
@@ -2129,56 +2203,30 @@ const server = http.createServer(async (req, res) => {
2129
2203
  res.end(JSON.stringify(result));
2130
2204
  }
2131
2205
  else if (url.pathname === '/api/search' && req.method === 'GET') {
2206
+ const result = apiSearch(url.searchParams);
2207
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2208
+ res.end(JSON.stringify(result));
2209
+ }
2210
+ else if (url.pathname === '/api/export-json' && req.method === 'GET') {
2132
2211
  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) {
2212
+ const branchResult = getValidatedBranch(url.searchParams);
2213
+ if (branchResult.error) {
2137
2214
  res.writeHead(400, { 'Content-Type': 'application/json' });
2138
- res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
2215
+ res.end(JSON.stringify(branchResult));
2139
2216
  return;
2140
2217
  }
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);
2218
+ const canonicalState = getCanonicalState(projectPath);
2219
+ const history = canonicalState.getConversationMessages({ branch: branchResult.branch });
2172
2220
  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);
2221
+ const decisions = canonicalState.listDecisions({ branch: branchResult.branch });
2222
+ const tasks = canonicalState.listTasks({ branch: branchResult.branch });
2223
+ const channels = canonicalState.getChannelsView({ branch: branchResult.branch });
2177
2224
  const pkg = readJson(path.join(__dirname, 'package.json')) || {};
2178
2225
  const result = {
2179
2226
  export_version: 1,
2180
2227
  exported_at: new Date().toISOString(),
2181
- project: projectPath || process.cwd(),
2228
+ project: projectPath || DEFAULT_PROJECT_ROOT,
2229
+ branch: branchResult.branch,
2182
2230
  version: pkg.version || 'unknown',
2183
2231
  summary: {
2184
2232
  message_count: history.length,
@@ -2204,7 +2252,13 @@ const server = http.createServer(async (req, res) => {
2204
2252
  res.end(JSON.stringify(result, null, 2));
2205
2253
  }
2206
2254
  else if (url.pathname === '/api/export' && req.method === 'GET') {
2207
- const html = apiExportHtml(url.searchParams);
2255
+ const branchResult = getValidatedBranch(url.searchParams);
2256
+ if (branchResult.error) {
2257
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2258
+ res.end(JSON.stringify(branchResult));
2259
+ return;
2260
+ }
2261
+ const html = apiExportHtml(url.searchParams, branchResult.branch);
2208
2262
  res.writeHead(200, {
2209
2263
  'Content-Type': 'text/html; charset=utf-8',
2210
2264
  'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '.html"',
@@ -2252,23 +2306,37 @@ const server = http.createServer(async (req, res) => {
2252
2306
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
2253
2307
  const projectPath = url.searchParams.get('project') || null;
2254
2308
  res.writeHead(200, { 'Content-Type': 'application/json' });
2255
- res.end(JSON.stringify(readJson(filePath('profiles.json', projectPath))));
2309
+ res.end(JSON.stringify(getCanonicalState(projectPath).listProfiles()));
2256
2310
  }
2257
2311
  else if (url.pathname === '/api/profiles' && req.method === 'POST') {
2258
2312
  const body = await parseBody(req);
2259
2313
  const projectPath = url.searchParams.get('project') || null;
2260
- const profilesFile = filePath('profiles.json', projectPath);
2261
- const profiles = readJson(profilesFile);
2262
2314
  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') {
2315
+ if (body.display_name !== undefined) {
2316
+ 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; }
2317
+ }
2318
+ if (body.avatar !== undefined) {
2319
+ 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; }
2320
+ 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; }
2321
+ }
2322
+ if (body.bio !== undefined) {
2323
+ 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; }
2324
+ }
2325
+ if (body.role !== undefined) {
2326
+ 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; }
2327
+ }
2328
+ const contractPatch = sanitizeContractProfilePatch({
2329
+ archetype: body.archetype,
2330
+ skills: body.skills,
2331
+ contract_mode: body.contract_mode,
2332
+ });
2333
+ if (!contractPatch.valid) {
2334
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2335
+ res.end(JSON.stringify({ error: contractPatch.errors.join('; ') }));
2336
+ return;
2337
+ }
2338
+ let cleanedAppearance;
2339
+ if (body.appearance && typeof body.appearance === 'object') {
2272
2340
  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
2341
  const enumValidation = {
2274
2342
  hair_style: ['none', 'short', 'spiky', 'long', 'ponytail', 'bob'],
@@ -2278,73 +2346,86 @@ const server = http.createServer(async (req, res) => {
2278
2346
  headwear: ['none', 'beanie', 'cap', 'headphones', 'headband'],
2279
2347
  neckwear: ['none', 'tie', 'bowtie', 'lanyard'],
2280
2348
  };
2281
- const cleaned = {};
2349
+ cleanedAppearance = {};
2282
2350
  for (const [k, v] of Object.entries(body.appearance)) {
2283
2351
  if (!validKeys.includes(k) || typeof v !== 'string' || v.length > 20) continue;
2284
2352
  if (enumValidation[k] && !enumValidation[k].includes(v)) continue;
2285
- cleaned[k] = v;
2353
+ cleanedAppearance[k] = v;
2286
2354
  }
2287
- profiles[body.agent].appearance = Object.assign(profiles[body.agent].appearance || {}, cleaned);
2288
2355
  }
2289
- profiles[body.agent].updated_at = new Date().toISOString();
2290
- fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
2356
+ const canonicalState = getCanonicalState(projectPath);
2357
+ canonicalState.upsertProfile({
2358
+ name: body.agent,
2359
+ displayName: body.display_name,
2360
+ avatar: body.avatar,
2361
+ bio: body.bio,
2362
+ role: body.role,
2363
+ appearance: cleanedAppearance,
2364
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'archetype') ? { archetype: contractPatch.normalized.archetype } : {}),
2365
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'skills') ? { skills: contractPatch.normalized.skills } : {}),
2366
+ ...(Object.prototype.hasOwnProperty.call(contractPatch.normalized, 'contract_mode') ? { contractMode: contractPatch.normalized.contract_mode } : {}),
2367
+ });
2291
2368
  res.writeHead(200, { 'Content-Type': 'application/json' });
2292
2369
  res.end(JSON.stringify({ success: true }));
2293
2370
  }
2294
2371
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
2295
2372
  const projectPath = url.searchParams.get('project') || null;
2373
+ const branchResult = getValidatedBranch(url.searchParams);
2374
+ if (branchResult.error) {
2375
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2376
+ res.end(JSON.stringify(branchResult));
2377
+ return;
2378
+ }
2296
2379
  const agentParam = url.searchParams.get('agent');
2297
2380
  if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
2298
2381
  res.writeHead(400, { 'Content-Type': 'application/json' });
2299
2382
  res.end(JSON.stringify({ error: 'Invalid agent name' }));
2300
2383
  return;
2301
2384
  }
2302
- const dataDir = resolveDataDir(projectPath);
2303
- const wsDir = path.join(dataDir, 'workspaces');
2385
+ const canonicalState = getCanonicalState(projectPath);
2304
2386
  const result = {};
2305
2387
  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));
2388
+ result[agentParam] = canonicalState.readWorkspace(agentParam, { branch: branchResult.branch });
2389
+ } else {
2390
+ for (const workspace of canonicalState.listWorkspaces({ branch: branchResult.branch })) {
2391
+ result[workspace.agent] = workspace.data;
2312
2392
  }
2313
2393
  }
2314
2394
  res.writeHead(200, { 'Content-Type': 'application/json' });
2315
2395
  res.end(JSON.stringify(result));
2316
2396
  }
2317
2397
  else if (url.pathname === '/api/workflows' && req.method === 'GET') {
2398
+ const branchResult = getValidatedBranch(url.searchParams);
2399
+ if (branchResult.error) {
2400
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2401
+ res.end(JSON.stringify(branchResult));
2402
+ return;
2403
+ }
2318
2404
  const projectPath = url.searchParams.get('project') || null;
2319
- const wfFile = filePath('workflows.json', projectPath);
2320
2405
  res.writeHead(200, { 'Content-Type': 'application/json' });
2321
- res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
2406
+ res.end(JSON.stringify(getCanonicalState(projectPath).listWorkflows({ branch: branchResult.branch })));
2322
2407
  }
2323
2408
  else if (url.pathname === '/api/workflows' && req.method === 'POST') {
2409
+ const branchResult = getValidatedBranch(url.searchParams);
2410
+ if (branchResult.error) {
2411
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2412
+ res.end(JSON.stringify(branchResult));
2413
+ return;
2414
+ }
2324
2415
  const body = await parseBody(req);
2325
2416
  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 {}
2417
+ const canonicalState = getCanonicalState(projectPath);
2329
2418
  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));
2419
+ const result = canonicalState.advanceWorkflow({ workflowId: body.workflow_id, notes: body.notes, branch: branchResult.branch });
2420
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2338
2421
  } 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));
2422
+ const result = canonicalState.skipWorkflowStep({
2423
+ workflowId: body.workflow_id,
2424
+ stepId: body.step_id,
2425
+ note: 'Skipped from dashboard',
2426
+ branch: branchResult.branch,
2427
+ });
2428
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2348
2429
  } else {
2349
2430
  res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
2350
2431
  }
@@ -2354,297 +2435,169 @@ const server = http.createServer(async (req, res) => {
2354
2435
  // ========== Plan Control API (v5.0 Autonomy Engine) ==========
2355
2436
 
2356
2437
  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
- }));
2438
+ const result = apiPlanStatus(url.searchParams);
2439
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2440
+ res.end(JSON.stringify(result));
2411
2441
  }
2412
2442
 
2413
2443
  else if (url.pathname === '/api/plan/pause' && req.method === 'POST') {
2444
+ const branchResult = getValidatedBranch(url.searchParams);
2445
+ if (branchResult.error) {
2446
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2447
+ res.end(JSON.stringify(branchResult));
2448
+ return;
2449
+ }
2414
2450
  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));
2451
+ const result = getCanonicalState(projectPath).pausePlan({ branch: branchResult.branch });
2452
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2424
2453
  // 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);
2454
+ 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
2455
  res.writeHead(200, { 'Content-Type': 'application/json' });
2427
- res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: activeWf.id }));
2456
+ res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: result.workflow_id }));
2428
2457
  }
2429
2458
 
2430
2459
  else if (url.pathname === '/api/plan/resume' && req.method === 'POST') {
2460
+ const branchResult = getValidatedBranch(url.searchParams);
2461
+ if (branchResult.error) {
2462
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2463
+ res.end(JSON.stringify(branchResult));
2464
+ return;
2465
+ }
2431
2466
  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);
2467
+ const result = getCanonicalState(projectPath).resumePlan({ branch: branchResult.branch });
2468
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2469
+ apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${result.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
2442
2470
  res.writeHead(200, { 'Content-Type': 'application/json' });
2443
- res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: pausedWf.id }));
2471
+ res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: result.workflow_id }));
2444
2472
  }
2445
2473
 
2446
2474
  else if (url.pathname === '/api/plan/stop' && req.method === 'POST') {
2475
+ const branchResult = getValidatedBranch(url.searchParams);
2476
+ if (branchResult.error) {
2477
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2478
+ res.end(JSON.stringify(branchResult));
2479
+ return;
2480
+ }
2447
2481
  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);
2482
+ const result = getCanonicalState(projectPath).stopPlan({ branch: branchResult.branch });
2483
+ if (result.error) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2484
+ apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${result.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
2458
2485
  res.writeHead(200, { 'Content-Type': 'application/json' });
2459
- res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: activeWf.id }));
2486
+ res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: result.workflow_id }));
2460
2487
  }
2461
2488
 
2462
2489
  else if (url.pathname.startsWith('/api/plan/skip/') && req.method === 'POST') {
2490
+ const branchResult = getValidatedBranch(url.searchParams);
2491
+ if (branchResult.error) {
2492
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2493
+ res.end(JSON.stringify(branchResult));
2494
+ return;
2495
+ }
2463
2496
  const stepId = parseInt(url.pathname.split('/').pop(), 10);
2464
2497
  const body = await parseBody(req);
2465
2498
  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'; });
2499
+ const canonicalState = getCanonicalState(projectPath);
2500
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2501
+ const wfId = body.workflow_id || ((workflows.find(w => w.status === 'active') || {}).id);
2502
+ const result = canonicalState.skipWorkflowStep({
2503
+ workflowId: wfId,
2504
+ stepId,
2505
+ note: ' [Skipped from dashboard]',
2506
+ appendNote: true,
2507
+ markSkipped: true,
2508
+ dependencyAware: true,
2509
+ branch: branchResult.branch,
2483
2510
  });
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));
2511
+ 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
2512
  res.writeHead(200, { 'Content-Type': 'application/json' });
2489
- res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: readySteps.map(s => s.id) }));
2513
+ res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: result.ready_steps }));
2490
2514
  }
2491
2515
 
2492
2516
  else if (url.pathname.startsWith('/api/plan/reassign/') && req.method === 'POST') {
2517
+ const branchResult = getValidatedBranch(url.searchParams);
2518
+ if (branchResult.error) {
2519
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2520
+ res.end(JSON.stringify(branchResult));
2521
+ return;
2522
+ }
2493
2523
  const stepId = parseInt(url.pathname.split('/').pop(), 10);
2494
2524
  const body = await parseBody(req);
2495
2525
  const projectPath = url.searchParams.get('project') || null;
2496
- const wfFile = filePath('workflows.json', projectPath);
2497
2526
  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 {}
2527
+ const canonicalState = getCanonicalState(projectPath);
2500
2528
  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);
2529
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2530
+ const workflow = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
2531
+ if (!workflow) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
2532
+ const result = canonicalState.reassignWorkflowStep({
2533
+ workflowId: workflow.id,
2534
+ stepId,
2535
+ newAssignee: body.new_assignee,
2536
+ branch: branchResult.branch,
2537
+ });
2538
+ 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; }
2539
+ const updatedStep = result.step || {};
2540
+ 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
2541
  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 }));
2542
+ res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: result.old_assignee, new_assignee: body.new_assignee }));
2512
2543
  }
2513
2544
 
2514
2545
  else if (url.pathname === '/api/plan/inject' && req.method === 'POST') {
2515
2546
  const body = await parseBody(req);
2516
2547
  if (!body.content) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'content required' })); return; }
2517
2548
  const result = apiInjectMessage({ to: body.to || '__all__', content: body.content }, url.searchParams);
2518
- res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2549
+ res.writeHead(result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2519
2550
  res.end(JSON.stringify(result));
2520
2551
  }
2521
2552
 
2522
2553
  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
-
2554
+ const result = apiPlanReport(url.searchParams);
2555
+ if (result && result.error) { res.writeHead(getRouteErrorStatus(result), { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; }
2556
+ if (!result) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
2586
2557
  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
- }));
2558
+ res.end(JSON.stringify(result));
2610
2559
  }
2611
2560
 
2612
2561
  else if (url.pathname === '/api/plan/skills' && req.method === 'GET') {
2562
+ const branchResult = getValidatedBranch(url.searchParams);
2563
+ if (branchResult.error) {
2564
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2565
+ res.end(JSON.stringify(branchResult));
2566
+ return;
2567
+ }
2613
2568
  const projectPath = url.searchParams.get('project') || null;
2614
- const kbFile = filePath('kb.json', projectPath);
2569
+ const kb = getCanonicalState(projectPath).readKnowledgeBase({ branch: branchResult.branch });
2615
2570
  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 {}
2571
+ for (const [key, val] of Object.entries(kb || {})) {
2572
+ if (key.startsWith('skill_') || key.startsWith('lesson_')) {
2573
+ skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
2574
+ }
2625
2575
  }
2626
2576
  res.writeHead(200, { 'Content-Type': 'application/json' });
2627
2577
  res.end(JSON.stringify({ count: skills.length, skills }));
2628
2578
  }
2629
2579
 
2630
2580
  else if (url.pathname === '/api/plan/retries' && req.method === 'GET') {
2581
+ const branchResult = getValidatedBranch(url.searchParams);
2582
+ if (branchResult.error) {
2583
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2584
+ res.end(JSON.stringify(branchResult));
2585
+ return;
2586
+ }
2631
2587
  const projectPath = url.searchParams.get('project') || null;
2632
- const dataDir = resolveDataDir(projectPath);
2633
- const wsDir = path.join(dataDir, 'workspaces');
2588
+ const canonicalState = getCanonicalState(projectPath);
2634
2589
  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
- }
2590
+ for (const workspace of canonicalState.listWorkspaces({ branch: branchResult.branch })) {
2591
+ try {
2592
+ if (workspace.data && workspace.data.retry_history) {
2593
+ const history = typeof workspace.data.retry_history === 'string'
2594
+ ? JSON.parse(workspace.data.retry_history)
2595
+ : workspace.data.retry_history;
2596
+ if (Array.isArray(history)) {
2597
+ for (const entry of history) { retries.push({ agent: workspace.agent, ...entry }); }
2645
2598
  }
2646
- } catch {}
2647
- }
2599
+ }
2600
+ } catch {}
2648
2601
  }
2649
2602
  retries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
2650
2603
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2686,15 +2639,13 @@ const server = http.createServer(async (req, res) => {
2686
2639
 
2687
2640
  // Monitor intervention log from workspace
2688
2641
  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
- }
2642
+ if (monitorName) {
2643
+ try {
2644
+ const canonicalState = getCanonicalState(projectPath);
2645
+ const monitorBranch = (agents[monitorName[0]] && agents[monitorName[0]].branch) || 'main';
2646
+ const ws = canonicalState.readWorkspace(monitorName[0], { branch: monitorBranch });
2647
+ if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
2648
+ } catch {}
2698
2649
  }
2699
2650
 
2700
2651
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2749,18 +2700,21 @@ const server = http.createServer(async (req, res) => {
2749
2700
 
2750
2701
  else if (url.pathname === '/api/stats' && req.method === 'GET') {
2751
2702
  const projectPath = url.searchParams.get('project') || null;
2703
+ const branchResult = getValidatedBranch(url.searchParams);
2704
+ if (branchResult.error) {
2705
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2706
+ res.end(JSON.stringify(branchResult));
2707
+ return;
2708
+ }
2752
2709
  const dataDir = resolveDataDir(projectPath);
2753
2710
  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);
2711
+ const canonicalState = getCanonicalState(projectPath);
2758
2712
 
2759
2713
  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 {}
2714
+ const workflows = canonicalState.listWorkflows({ branch: branchResult.branch });
2715
+ const tasks = canonicalState.listTasks({ branch: branchResult.branch });
2716
+ const msgCount = canonicalState.getConversationMessages({ branch: branchResult.branch }).length;
2717
+ const kbKeys = Object.keys(canonicalState.readKnowledgeBase({ branch: branchResult.branch }) || {}).length;
2764
2718
 
2765
2719
  const aliveCount = Object.values(agents).filter(a => { const idle = Date.now() - new Date(a.last_activity || 0).getTime(); return idle < 120000; }).length;
2766
2720
  const activeWf = workflows.filter(w => w.status === 'active');
@@ -2787,60 +2741,45 @@ const server = http.createServer(async (req, res) => {
2787
2741
  // ========== Rules API ==========
2788
2742
 
2789
2743
  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 : []));
2744
+ const result = apiRules(url.searchParams);
2745
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2746
+ res.end(JSON.stringify(result));
2795
2747
  }
2796
2748
 
2797
2749
  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
2750
  try {
2801
2751
  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));
2752
+ const result = apiAddRule(body, url.searchParams);
2753
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 201, { 'Content-Type': 'application/json' });
2754
+ res.end(JSON.stringify(result && result.rule ? result.rule : result));
2817
2755
  } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
2818
2756
  }
2819
2757
 
2820
2758
  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
2759
  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 }));
2760
+ const result = apiDeleteRule({ rule_id: ruleId }, url.searchParams);
2761
+ res.writeHead(result && result.error ? getRouteErrorStatus(result) : 200, { 'Content-Type': 'application/json' });
2762
+ res.end(JSON.stringify(result));
2831
2763
  }
2832
2764
 
2833
2765
  else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
2834
2766
  const projectPath = url.searchParams.get('project') || null;
2835
- const rulesFile = filePath('rules.json', projectPath);
2767
+ const branchResult = getValidatedBranch(url.searchParams);
2768
+ if (branchResult.error) {
2769
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2770
+ res.end(JSON.stringify(branchResult));
2771
+ return;
2772
+ }
2836
2773
  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));
2774
+ const toggled = getCanonicalState(projectPath).toggleRule({
2775
+ ruleId,
2776
+ actor: 'Dashboard',
2777
+ branch: branchResult.branch,
2778
+ correlationId: ruleId,
2779
+ });
2780
+ if (toggled.error) { res.writeHead(404); res.end(JSON.stringify({ error: toggled.error })); return; }
2842
2781
  res.writeHead(200, { 'Content-Type': 'application/json' });
2843
- res.end(JSON.stringify(rule));
2782
+ res.end(JSON.stringify(toggled.rule));
2844
2783
  }
2845
2784
 
2846
2785
  // ========== End Rules API ==========
@@ -2986,7 +2925,13 @@ const server = http.createServer(async (req, res) => {
2986
2925
  }
2987
2926
  // --- v3.4: Replay Export ---
2988
2927
  else if (url.pathname === '/api/export-replay' && req.method === 'GET') {
2989
- const html = apiExportReplay(url.searchParams);
2928
+ const branchResult = getValidatedBranch(url.searchParams);
2929
+ if (branchResult.error) {
2930
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2931
+ res.end(JSON.stringify(branchResult));
2932
+ return;
2933
+ }
2934
+ const html = apiExportReplay(url.searchParams, branchResult.branch);
2990
2935
  res.writeHead(200, {
2991
2936
  'Content-Type': 'text/html; charset=utf-8',
2992
2937
  'Content-Disposition': 'attachment; filename="replay-' + new Date().toISOString().slice(0, 10) + '.html"',
@@ -3179,7 +3124,7 @@ const server = http.createServer(async (req, res) => {
3179
3124
  const alive = isPidAlive(info.pid, info.last_activity);
3180
3125
  const lastActivity = info.last_activity || info.timestamp;
3181
3126
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
3182
- const isListening = !!(info.listening_since && alive);
3127
+ const isListening = alive && isRecentlyListening(info);
3183
3128
  const activeTasks = tasks.filter(t => t.assignee === name && t.status !== 'done');
3184
3129
  let behavior = 'dead';
3185
3130
  let location = null;
@@ -3279,6 +3224,82 @@ const server = http.createServer(async (req, res) => {
3279
3224
  res.writeHead(200, { 'Content-Type': 'application/json' });
3280
3225
  res.end(JSON.stringify({ hours, minutes, period, game_minutes: gameMinutes, speed, formatted: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}` }));
3281
3226
  }
3227
+ // ==================== API AGENTS ====================
3228
+ else if (url.pathname === '/api/api-agents' && req.method === 'GET') {
3229
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3230
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3231
+ res.end(JSON.stringify(engine.list()));
3232
+ }
3233
+ else if (url.pathname === '/api/api-agents' && req.method === 'POST') {
3234
+ const body = await parseBody(req);
3235
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3236
+ const result = engine.create(body.name, body.provider, {
3237
+ model: body.model,
3238
+ endpoint: body.endpoint,
3239
+ apiKey: body.apiKey,
3240
+ providerOptions: body.options,
3241
+ });
3242
+ sseNotifyAll('agents');
3243
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3244
+ res.end(JSON.stringify(result));
3245
+ }
3246
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+$/) && req.method === 'DELETE') {
3247
+ const name = decodeURIComponent(url.pathname.split('/').pop());
3248
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3249
+ const result = engine.remove(name);
3250
+ sseNotifyAll('agents');
3251
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3252
+ res.end(JSON.stringify(result));
3253
+ }
3254
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+\/start$/) && req.method === 'POST') {
3255
+ const name = decodeURIComponent(url.pathname.split('/')[3]);
3256
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3257
+ const result = engine.start(name);
3258
+ sseNotifyAll('agents');
3259
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3260
+ res.end(JSON.stringify(result));
3261
+ }
3262
+ else if (url.pathname.match(/^\/api\/api-agents\/[^/]+\/stop$/) && req.method === 'POST') {
3263
+ const name = decodeURIComponent(url.pathname.split('/')[3]);
3264
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3265
+ const result = engine.stop(name);
3266
+ sseNotifyAll('agents');
3267
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3268
+ res.end(JSON.stringify(result));
3269
+ }
3270
+ // ==================== MEDIA ====================
3271
+ else if (url.pathname === '/api/media' && req.method === 'GET') {
3272
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3273
+ const media = engine.getMedia({
3274
+ type: url.searchParams.get('type'),
3275
+ agent: url.searchParams.get('agent'),
3276
+ page: parseInt(url.searchParams.get('page') || '1', 10),
3277
+ limit: parseInt(url.searchParams.get('limit') || '20', 10),
3278
+ });
3279
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3280
+ res.end(JSON.stringify(media));
3281
+ }
3282
+ else if (url.pathname.match(/^\/api\/media\/[^/]+\/file$/) && req.method === 'GET') {
3283
+ const id = decodeURIComponent(url.pathname.split('/')[3]);
3284
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3285
+ const filep = engine.getMediaFilePath(id);
3286
+ if (filep) {
3287
+ const ext = path.extname(filep).toLowerCase();
3288
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.mp4': 'video/mp4' };
3289
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream', 'Cache-Control': 'public, max-age=3600' });
3290
+ fs.createReadStream(filep).pipe(res);
3291
+ } else {
3292
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3293
+ res.end(JSON.stringify({ error: 'Media not found' }));
3294
+ }
3295
+ }
3296
+ else if (url.pathname.match(/^\/api\/media\/[^/]+$/) && req.method === 'DELETE') {
3297
+ const id = decodeURIComponent(url.pathname.split('/').pop());
3298
+ const engine = getApiAgentEngine(url.searchParams.get('project'));
3299
+ const result = engine.deleteMedia(id);
3300
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
3301
+ res.end(JSON.stringify(result));
3302
+ }
3282
3303
  else {
3283
3304
  res.writeHead(404, { 'Content-Type': 'application/json' });
3284
3305
  res.end(JSON.stringify({ error: 'Not found' }));
@@ -3365,12 +3386,28 @@ function startFileWatcher() {
3365
3386
 
3366
3387
  startFileWatcher();
3367
3388
 
3389
+ // Initialize API Agent Engine
3390
+ let apiAgentEngine = null;
3391
+ function getApiAgentEngine(projectPath) {
3392
+ const dataDir = resolveDataDir(projectPath);
3393
+ if (!apiAgentEngine || apiAgentEngine.dataDir !== dataDir) {
3394
+ if (apiAgentEngine) apiAgentEngine.stopAll();
3395
+ apiAgentEngine = new ApiAgentEngine(dataDir);
3396
+ }
3397
+ return apiAgentEngine;
3398
+ }
3399
+
3400
+ // Clean up on exit
3401
+ process.on('exit', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); });
3402
+ process.on('SIGINT', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); process.exit(); });
3403
+ process.on('SIGTERM', () => { if (apiAgentEngine) apiAgentEngine.stopAll(); process.exit(); });
3404
+
3368
3405
  server.on('error', (err) => {
3369
3406
  if (err.code === 'EADDRINUSE') {
3370
3407
  console.error(`\n Error: Port ${PORT} is already in use.`);
3371
3408
  console.error(` Another dashboard may be running. Try:`);
3372
3409
  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`);
3410
+ console.error(` - Or use a different port: AGENT_BRIDGE_PORT=3001 node .agent-bridge/launch.js\n`);
3374
3411
  process.exit(1);
3375
3412
  }
3376
3413
  throw err;
@@ -3380,7 +3417,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
3380
3417
  const dataDir = resolveDataDir();
3381
3418
  const lanIP = getLanIP();
3382
3419
  console.log('');
3383
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.5.1');
3420
+ console.log(' Let Them Talk - Agent Bridge Dashboard v5.4.0');
3384
3421
  console.log(' ============================================');
3385
3422
  console.log(' Dashboard: http://localhost:' + PORT);
3386
3423
  if (LAN_MODE && lanIP) {