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