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.
- package/CHANGELOG.md +3 -1
- package/README.md +158 -592
- package/SECURITY.md +3 -3
- package/USAGE.md +151 -0
- package/agent-contracts.js +447 -0
- package/api-agents.js +760 -0
- package/autonomy/decision-v2.js +380 -0
- package/autonomy/watchdog-policy.js +572 -0
- package/cli.js +454 -298
- package/conversation-templates/autonomous-feature.json +83 -22
- package/conversation-templates/code-review.json +69 -21
- package/conversation-templates/debug-squad.json +69 -21
- package/conversation-templates/feature-build.json +69 -21
- package/conversation-templates/research-write.json +69 -21
- package/dashboard.html +3148 -174
- package/dashboard.js +823 -786
- package/data-dir.js +58 -0
- package/docs/architecture/branch-semantics.md +157 -0
- package/docs/architecture/canonical-event-schema.md +88 -0
- package/docs/architecture/markdown-workspace.md +183 -0
- package/docs/architecture/runtime-contract.md +459 -0
- package/docs/architecture/runtime-migration-hardening.md +64 -0
- package/events/hooks.js +154 -0
- package/events/log.js +457 -0
- package/events/replay.js +33 -0
- package/events/schema.js +432 -0
- package/managed-team-integration.js +261 -0
- package/office/agents.js +704 -597
- package/office/animation.js +1 -1
- package/office/assets/arcade-cabinet.js +141 -0
- package/office/assets/archway.js +77 -0
- package/office/assets/bar-counter.js +91 -0
- package/office/assets/bar-stool.js +71 -0
- package/office/assets/beanbag.js +64 -0
- package/office/assets/bench.js +99 -0
- package/office/assets/bollard.js +87 -0
- package/office/assets/cactus.js +100 -0
- package/office/assets/carpet-tile.js +46 -0
- package/office/assets/chair.js +123 -0
- package/office/assets/chandelier.js +107 -0
- package/office/assets/coffee-machine.js +95 -0
- package/office/assets/coffee-table.js +81 -0
- package/office/assets/column.js +95 -0
- package/office/assets/desk-lamp.js +102 -0
- package/office/assets/desk.js +76 -0
- package/office/assets/dining-table.js +105 -0
- package/office/assets/door.js +70 -0
- package/office/assets/dual-monitor.js +72 -0
- package/office/assets/fence.js +76 -0
- package/office/assets/filing-cabinet.js +111 -0
- package/office/assets/floor-lamp.js +69 -0
- package/office/assets/floor-tile.js +54 -0
- package/office/assets/flower-pot.js +76 -0
- package/office/assets/foosball.js +95 -0
- package/office/assets/fridge.js +99 -0
- package/office/assets/gaming-chair.js +154 -0
- package/office/assets/gaming-desk.js +105 -0
- package/office/assets/glass-door.js +72 -0
- package/office/assets/glass-wall.js +64 -0
- package/office/assets/half-wall.js +49 -0
- package/office/assets/hanging-plant.js +112 -0
- package/office/assets/index.js +151 -0
- package/office/assets/indoor-tree.js +90 -0
- package/office/assets/l-sofa.js +153 -0
- package/office/assets/marble-floor.js +64 -0
- package/office/assets/materials.js +40 -0
- package/office/assets/meeting-table.js +88 -0
- package/office/assets/microwave.js +94 -0
- package/office/assets/monitor.js +67 -0
- package/office/assets/neon-strip.js +73 -0
- package/office/assets/painting.js +84 -0
- package/office/assets/palm-tree.js +108 -0
- package/office/assets/pc-tower.js +91 -0
- package/office/assets/pendant-light.js +67 -0
- package/office/assets/ping-pong.js +114 -0
- package/office/assets/plant.js +72 -0
- package/office/assets/planter-box.js +95 -0
- package/office/assets/pool-table.js +94 -0
- package/office/assets/printer.js +113 -0
- package/office/assets/reception-desk.js +133 -0
- package/office/assets/rug.js +78 -0
- package/office/assets/sculpture.js +85 -0
- package/office/assets/server-rack.js +98 -0
- package/office/assets/sink.js +109 -0
- package/office/assets/sofa.js +106 -0
- package/office/assets/speaker.js +83 -0
- package/office/assets/spotlight.js +83 -0
- package/office/assets/street-lamp.js +97 -0
- package/office/assets/trash-can.js +83 -0
- package/office/assets/treadmill.js +126 -0
- package/office/assets/trophy.js +89 -0
- package/office/assets/tv-screen.js +79 -0
- package/office/assets/vase.js +84 -0
- package/office/assets/wall-clock.js +84 -0
- package/office/assets/wall.js +53 -0
- package/office/assets/water-cooler.js +146 -0
- package/office/assets/whiteboard.js +115 -0
- package/office/assets.js +3 -431
- package/office/builder.js +791 -355
- package/office/campus-env.js +1012 -1119
- package/office/environment.js +2 -0
- package/office/gallery.js +997 -0
- package/office/index.js +165 -61
- package/office/navigation.js +173 -152
- package/office/player.js +178 -68
- package/office/robot-character.js +272 -0
- package/office/spectator-camera.js +33 -10
- package/office/state.js +2 -0
- package/office/world-save.js +35 -4
- package/package.json +57 -3
- package/providers/comfyui.js +383 -0
- package/providers/dalle.js +79 -0
- package/providers/gemini.js +181 -0
- package/providers/ollama.js +184 -0
- package/providers/replicate.js +115 -0
- package/providers/zai.js +183 -0
- package/runtime-descriptor.js +270 -0
- package/scripts/check-agent-contract-advisory.js +132 -0
- package/scripts/check-api-agent-parity.js +277 -0
- package/scripts/check-autonomy-v2-decision.js +207 -0
- package/scripts/check-autonomy-v2-execution.js +588 -0
- package/scripts/check-autonomy-v2-watchdog.js +224 -0
- package/scripts/check-branch-fork-snapshot.js +337 -0
- package/scripts/check-branch-isolation.js +787 -0
- package/scripts/check-branch-semantics.js +139 -0
- package/scripts/check-dashboard-control-plane.js +1304 -0
- package/scripts/check-docs-onboarding.js +490 -0
- package/scripts/check-event-schema.js +276 -0
- package/scripts/check-evidence-completion.js +239 -0
- package/scripts/check-invariants.js +992 -0
- package/scripts/check-lifecycle-hooks.js +525 -0
- package/scripts/check-managed-team-integration.js +166 -0
- package/scripts/check-markdown-workspace-export.js +548 -0
- package/scripts/check-markdown-workspace-safety.js +347 -0
- package/scripts/check-markdown-workspace.js +136 -0
- package/scripts/check-message-replay.js +429 -0
- package/scripts/check-migration-hardening.js +300 -0
- package/scripts/check-performance-indexing.js +272 -0
- package/scripts/check-provider-capabilities.js +316 -0
- package/scripts/check-runtime-contract.js +109 -0
- package/scripts/check-session-aware-context.js +172 -0
- package/scripts/check-session-lifecycle.js +210 -0
- package/scripts/export-markdown-workspace.js +84 -0
- package/scripts/fixtures/message-replay/clean.jsonl +2 -0
- package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
- package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
- package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
- package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
- package/scripts/migrate-legacy-to-canonical.js +201 -0
- package/scripts/run-verification-suite.js +242 -0
- package/scripts/sync-packaged-docs.js +69 -0
- package/server.js +9546 -7214
- package/state/agents.js +161 -0
- package/state/canonical.js +3068 -0
- package/state/dashboard-queries.js +441 -0
- package/state/evidence.js +56 -0
- package/state/io.js +69 -0
- package/state/markdown-workspace.js +951 -0
- package/state/messages.js +669 -0
- package/state/sessions.js +683 -0
- package/state/tasks-workflows.js +92 -0
- package/templates/debate.json +2 -2
- package/templates/managed.json +4 -4
- package/templates/pair.json +2 -2
- package/templates/review.json +2 -2
- 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
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
|
218
|
-
if (
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
296
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
352
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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 =
|
|
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
|
|
1113
|
-
if (
|
|
1114
|
-
|
|
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
|
|
1153
|
+
function apiSearch(query) {
|
|
1118
1154
|
const projectPath = query.get('project') || null;
|
|
1119
|
-
const
|
|
1120
|
-
if (
|
|
1155
|
+
const branchResult = getValidatedBranch(query);
|
|
1156
|
+
if (branchResult.error) return branchResult;
|
|
1121
1157
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
if (body.
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
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
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
if (
|
|
1192
|
-
|
|
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
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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 =
|
|
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 '
|
|
1846
|
-
'X-Frame-Options': '
|
|
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
|
|
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
|
|
1922
|
-
const
|
|
1923
|
-
const
|
|
1924
|
-
const
|
|
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
|
|
1933
|
-
|
|
1934
|
-
const
|
|
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
|
|
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
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
2063
|
-
res.
|
|
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
|
-
|
|
2108
|
-
res.
|
|
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 ?
|
|
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
|
-
|
|
2118
|
-
res.
|
|
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
|
|
2134
|
-
|
|
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(
|
|
2215
|
+
res.end(JSON.stringify(branchResult));
|
|
2139
2216
|
return;
|
|
2140
2217
|
}
|
|
2141
|
-
|
|
2142
|
-
|
|
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 =
|
|
2174
|
-
const
|
|
2175
|
-
const
|
|
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 ||
|
|
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
|
|
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(
|
|
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 (
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
if (body.
|
|
2271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2353
|
+
cleanedAppearance[k] = v;
|
|
2286
2354
|
}
|
|
2287
|
-
profiles[body.agent].appearance = Object.assign(profiles[body.agent].appearance || {}, cleaned);
|
|
2288
2355
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
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
|
|
2303
|
-
const wsDir = path.join(dataDir, 'workspaces');
|
|
2385
|
+
const canonicalState = getCanonicalState(projectPath);
|
|
2304
2386
|
const result = {};
|
|
2305
2387
|
if (agentParam) {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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(
|
|
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
|
|
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
|
|
2331
|
-
if (
|
|
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
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
if (
|
|
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
|
|
2358
|
-
|
|
2359
|
-
|
|
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
|
|
2416
|
-
|
|
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] "${
|
|
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:
|
|
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
|
|
2433
|
-
|
|
2434
|
-
|
|
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:
|
|
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
|
|
2449
|
-
|
|
2450
|
-
|
|
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:
|
|
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
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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:
|
|
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 ?
|
|
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
|
|
2524
|
-
|
|
2525
|
-
|
|
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
|
|
2569
|
+
const kb = getCanonicalState(projectPath).readKnowledgeBase({ branch: branchResult.branch });
|
|
2615
2570
|
let skills = [];
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
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
|
|
2633
|
-
const wsDir = path.join(dataDir, 'workspaces');
|
|
2588
|
+
const canonicalState = getCanonicalState(projectPath);
|
|
2634
2589
|
let retries = [];
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
const
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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
|
|
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
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
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
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
|
2803
|
-
|
|
2804
|
-
|
|
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
|
|
2825
|
-
|
|
2826
|
-
|
|
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
|
|
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
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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) {
|