@yemi33/minions 0.1.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 +819 -0
- package/LICENSE +21 -0
- package/README.md +598 -0
- package/agents/dallas/charter.md +56 -0
- package/agents/lambert/charter.md +67 -0
- package/agents/ralph/charter.md +45 -0
- package/agents/rebecca/charter.md +57 -0
- package/agents/ripley/charter.md +47 -0
- package/bin/minions.js +467 -0
- package/config.template.json +28 -0
- package/dashboard.html +4822 -0
- package/dashboard.js +2623 -0
- package/docs/auto-discovery.md +416 -0
- package/docs/blog-first-successful-dispatch.md +128 -0
- package/docs/command-center.md +156 -0
- package/docs/demo/01-dashboard-overview.gif +0 -0
- package/docs/demo/02-command-center.gif +0 -0
- package/docs/demo/03-work-items.gif +0 -0
- package/docs/demo/04-plan-docchat.gif +0 -0
- package/docs/demo/05-prd-progress.gif +0 -0
- package/docs/demo/06-inbox-metrics.gif +0 -0
- package/docs/deprecated.json +83 -0
- package/docs/distribution.md +96 -0
- package/docs/engine-restart.md +92 -0
- package/docs/human-vs-automated.md +108 -0
- package/docs/index.html +221 -0
- package/docs/plan-lifecycle.md +140 -0
- package/docs/self-improvement.md +344 -0
- package/engine/ado-mcp-wrapper.js +42 -0
- package/engine/ado.js +383 -0
- package/engine/check-status.js +23 -0
- package/engine/cli.js +754 -0
- package/engine/consolidation.js +417 -0
- package/engine/github.js +331 -0
- package/engine/lifecycle.js +1113 -0
- package/engine/llm.js +116 -0
- package/engine/queries.js +677 -0
- package/engine/shared.js +397 -0
- package/engine/spawn-agent.js +151 -0
- package/engine.js +3227 -0
- package/minions.js +556 -0
- package/package.json +48 -0
- package/playbooks/ask.md +49 -0
- package/playbooks/build-and-test.md +155 -0
- package/playbooks/explore.md +64 -0
- package/playbooks/fix.md +57 -0
- package/playbooks/implement-shared.md +68 -0
- package/playbooks/implement.md +95 -0
- package/playbooks/plan-to-prd.md +104 -0
- package/playbooks/plan.md +99 -0
- package/playbooks/review.md +68 -0
- package/playbooks/test.md +75 -0
- package/playbooks/verify.md +190 -0
- package/playbooks/work-item.md +74 -0
package/dashboard.js
ADDED
|
@@ -0,0 +1,2623 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minions Mission Control Dashboard
|
|
4
|
+
* Run: node .minions/dashboard.js
|
|
5
|
+
* Opens: http://localhost:7331
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const zlib = require('zlib');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const llm = require('./engine/llm');
|
|
13
|
+
const shared = require('./engine/shared');
|
|
14
|
+
const queries = require('./engine/queries');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects } = shared;
|
|
18
|
+
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
19
|
+
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
20
|
+
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
21
|
+
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
|
|
22
|
+
|
|
23
|
+
const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
|
|
24
|
+
let CONFIG = queries.getConfig();
|
|
25
|
+
let PROJECTS = _getProjects(CONFIG);
|
|
26
|
+
let projectNames = PROJECTS.map(p => p.name || 'Project').join(' + ');
|
|
27
|
+
|
|
28
|
+
function reloadConfig() {
|
|
29
|
+
CONFIG = queries.getConfig();
|
|
30
|
+
PROJECTS = _getProjects(CONFIG);
|
|
31
|
+
projectNames = PROJECTS.map(p => p.name || 'Project').join(' + ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
|
|
35
|
+
|
|
36
|
+
// Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
|
|
37
|
+
function resolvePlanPath(file) {
|
|
38
|
+
if (file.endsWith('.json')) {
|
|
39
|
+
const active = path.join(PRD_DIR, file);
|
|
40
|
+
if (fs.existsSync(active)) return active;
|
|
41
|
+
const archived = path.join(PRD_DIR, 'archive', file);
|
|
42
|
+
if (fs.existsSync(archived)) return archived;
|
|
43
|
+
return active;
|
|
44
|
+
}
|
|
45
|
+
const active = path.join(PLANS_DIR, file);
|
|
46
|
+
if (fs.existsSync(active)) return active;
|
|
47
|
+
const archived = path.join(PLANS_DIR, 'archive', file);
|
|
48
|
+
if (fs.existsSync(archived)) return archived;
|
|
49
|
+
return active;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HTML_RAW = safeRead(path.join(MINIONS_DIR, 'dashboard.html')) || '';
|
|
53
|
+
const HTML = HTML_RAW.replace('Minions Mission Control', `Minions Mission Control — ${projectNames}`);
|
|
54
|
+
const HTML_GZ = zlib.gzipSync(HTML);
|
|
55
|
+
const HTML_ETAG = '"' + require('crypto').createHash('md5').update(HTML).digest('hex') + '"';
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// -- Data Collectors (most moved to engine/queries.js) --
|
|
59
|
+
|
|
60
|
+
function getVerifyGuides() {
|
|
61
|
+
const guidesDir = path.join(MINIONS_DIR, 'prd', 'guides');
|
|
62
|
+
const guides = [];
|
|
63
|
+
try {
|
|
64
|
+
const files = safeReadDir(guidesDir).filter(f => f.endsWith('.md'));
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
// Match guide to plan: verify-officeagent-2026-03-15.md → officeagent-2026-03-15.json
|
|
67
|
+
const planSlug = f.replace('verify-', '').replace('.md', '');
|
|
68
|
+
const planFile = planSlug + '.json';
|
|
69
|
+
guides.push({ file: f, planFile });
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
return guides;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getArchivedPrds() { return []; }
|
|
76
|
+
function getEngineState() { return queries.getControl(); }
|
|
77
|
+
|
|
78
|
+
function getMcpServers() {
|
|
79
|
+
try {
|
|
80
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
81
|
+
const claudeJsonPath = path.join(home, '.claude.json');
|
|
82
|
+
const data = JSON.parse(safeRead(claudeJsonPath) || '{}');
|
|
83
|
+
const servers = data.mcpServers || {};
|
|
84
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
85
|
+
name,
|
|
86
|
+
command: cfg.command || '',
|
|
87
|
+
args: (cfg.args || []).slice(-1)[0] || '',
|
|
88
|
+
}));
|
|
89
|
+
} catch { return []; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let _statusCache = null;
|
|
93
|
+
let _statusCacheTs = 0;
|
|
94
|
+
const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
|
|
95
|
+
function invalidateStatusCache() { _statusCache = null; }
|
|
96
|
+
|
|
97
|
+
function getStatus() {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) return _statusCache;
|
|
100
|
+
|
|
101
|
+
// Reload config on each cache miss — picks up external changes (minions init, minions add)
|
|
102
|
+
reloadConfig();
|
|
103
|
+
|
|
104
|
+
const prdInfo = getPrdInfo();
|
|
105
|
+
_statusCache = {
|
|
106
|
+
agents: getAgents(),
|
|
107
|
+
prdProgress: prdInfo.progress,
|
|
108
|
+
inbox: getInbox(),
|
|
109
|
+
notes: getNotesWithMeta(),
|
|
110
|
+
prd: prdInfo.status,
|
|
111
|
+
pullRequests: getPullRequests(),
|
|
112
|
+
verifyGuides: getVerifyGuides(),
|
|
113
|
+
archivedPrds: getArchivedPrds(),
|
|
114
|
+
engine: getEngineState(),
|
|
115
|
+
dispatch: getDispatchQueue(),
|
|
116
|
+
engineLog: getEngineLog(),
|
|
117
|
+
metrics: getMetrics(),
|
|
118
|
+
workItems: getWorkItems(),
|
|
119
|
+
skills: getSkills(),
|
|
120
|
+
mcpServers: getMcpServers(),
|
|
121
|
+
projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
|
|
122
|
+
initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
|
|
123
|
+
installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
_statusCacheTs = now;
|
|
127
|
+
return _statusCache;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
// ── Command Center: session state + helpers ─────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const CC_SESSION_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
134
|
+
const CC_SESSION_MAX_TURNS = 50;
|
|
135
|
+
let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
136
|
+
let ccInFlight = false;
|
|
137
|
+
let ccInFlightSince = 0; // timestamp — auto-release stuck guard
|
|
138
|
+
const CC_INFLIGHT_TIMEOUT_MS = 11 * 60 * 1000; // 11 minutes (slightly > LLM timeout)
|
|
139
|
+
|
|
140
|
+
function ccSessionValid() {
|
|
141
|
+
if (!ccSession.sessionId) return false;
|
|
142
|
+
const age = Date.now() - new Date(ccSession.lastActiveAt || 0).getTime();
|
|
143
|
+
return age < CC_SESSION_EXPIRY_MS && ccSession.turnCount < CC_SESSION_MAX_TURNS;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Load persisted CC session on startup
|
|
147
|
+
try {
|
|
148
|
+
const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
|
|
149
|
+
if (saved && saved.sessionId) {
|
|
150
|
+
const age = Date.now() - new Date(saved.lastActiveAt || 0).getTime();
|
|
151
|
+
if (age < CC_SESSION_EXPIRY_MS) ccSession = saved;
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
|
|
155
|
+
// Static system prompt — baked into session on creation, never changes
|
|
156
|
+
const CC_STATIC_SYSTEM_PROMPT = `You are the Command Center AI for a software engineering minions called "Minions."
|
|
157
|
+
You have full CLI-level power — you can read, write, edit files, run shell commands, and execute builds just like a Claude Code CLI session. You also have minions-specific actions to delegate work to agents.
|
|
158
|
+
|
|
159
|
+
## Guardrails — What You Must NOT Touch
|
|
160
|
+
|
|
161
|
+
These files are the live engine. Modifying them can crash the minions or corrupt state:
|
|
162
|
+
- \`${MINIONS_DIR}/engine.js\` and \`${MINIONS_DIR}/engine/*.js\` — engine source code
|
|
163
|
+
- \`${MINIONS_DIR}/dashboard.js\` and \`${MINIONS_DIR}/dashboard.html\` — dashboard source
|
|
164
|
+
- \`${MINIONS_DIR}/minions.js\` and \`${MINIONS_DIR}/bin/*.js\` — CLI source
|
|
165
|
+
- \`${MINIONS_DIR}/engine/control.json\` — engine state (use CLI commands instead)
|
|
166
|
+
- \`${MINIONS_DIR}/engine/dispatch.json\` — dispatch queue (use actions instead)
|
|
167
|
+
- \`${MINIONS_DIR}/config.json\` — engine config (use actions or CLI instead)
|
|
168
|
+
|
|
169
|
+
You CAN freely read any of these files. You just must not write/edit them.
|
|
170
|
+
|
|
171
|
+
You CAN freely modify: notes, plans, knowledge base, work items, pull-requests.json, routing.md, agent charters, skills, playbooks, and anything in project repos.
|
|
172
|
+
|
|
173
|
+
## Filesystem — What's Where
|
|
174
|
+
|
|
175
|
+
\`\`\`
|
|
176
|
+
${MINIONS_DIR}/
|
|
177
|
+
├── config.json # Engine + project config (READ ONLY)
|
|
178
|
+
├── routing.md # Agent dispatch routing rules
|
|
179
|
+
├── notes.md # Consolidated team notes
|
|
180
|
+
├── work-items.json # Central work item queue (cross-project tasks)
|
|
181
|
+
├── projects/{name}/ # Per-project state (centralized, NOT in project repos)
|
|
182
|
+
│ ├── work-items.json # Project-specific work items
|
|
183
|
+
│ └── pull-requests.json # PR tracking for this project
|
|
184
|
+
├── agents/
|
|
185
|
+
│ ├── {id}/charter.md # Agent role definition
|
|
186
|
+
│ ├── {id}/output.log # Latest agent output
|
|
187
|
+
│ └── {id}/output-{dispatchId}.log # Archived outputs
|
|
188
|
+
├── plans/ # Source plans (.md)
|
|
189
|
+
├── prd/ # PRD items (.json), prd/archive/, prd/guides/
|
|
190
|
+
├── knowledge/{category}/*.md # Knowledge base
|
|
191
|
+
├── engine/ # Engine internals (READ ONLY)
|
|
192
|
+
│ ├── dispatch.json # Pending, active, completed dispatches
|
|
193
|
+
│ ├── metrics.json # Token/cost tracking
|
|
194
|
+
│ ├── control.json # Engine state (running/paused/stopped)
|
|
195
|
+
│ └── cooldowns.json # Dispatch cooldown timers
|
|
196
|
+
├── playbooks/*.md # Task templates
|
|
197
|
+
├── skills/*.md # Reusable agent workflow definitions
|
|
198
|
+
└── notes/inbox/*.md # Unconsolidated agent findings
|
|
199
|
+
\`\`\`
|
|
200
|
+
|
|
201
|
+
Projects are configured in \`config.json\` under \`projects[]\`. Per-project state lives centrally in \`${MINIONS_DIR}/projects/{name}/\` — NOT inside project repos. There are no \`.minions/\` folders inside project repos.
|
|
202
|
+
|
|
203
|
+
## Direct Execution
|
|
204
|
+
|
|
205
|
+
You have Bash, Write, Edit, and all standard tools. Use them directly when the task is straightforward:
|
|
206
|
+
- **Build & run projects** — \`cd <project> && npm install && npm run dev\`
|
|
207
|
+
- **Inspect code** — read files, grep, explore
|
|
208
|
+
- **Edit project files** — fix configs, update docs, tweak settings
|
|
209
|
+
- **Git operations** — fetch, checkout, merge, diff (but do NOT push without the user confirming)
|
|
210
|
+
- **Start dev servers** — for long-running servers, use detached processes so they survive after you finish
|
|
211
|
+
|
|
212
|
+
**When to do it yourself vs delegate to an agent:**
|
|
213
|
+
- Quick, one-shot tasks (build, read, check, install, start a server) → do it yourself
|
|
214
|
+
- Complex multi-file code changes, PR creation, code review → dispatch to an agent
|
|
215
|
+
- Anything that needs deep codebase knowledge or iterative coding → dispatch to an agent
|
|
216
|
+
|
|
217
|
+
## Minions Actions (Delegation)
|
|
218
|
+
|
|
219
|
+
When you want to delegate work to agents, append actions at the END of your response.
|
|
220
|
+
|
|
221
|
+
**Format:** Write your conversational response first, then on a new line write exactly \`===ACTIONS===\` followed by a JSON array of actions. Example:
|
|
222
|
+
|
|
223
|
+
I'll save that as a note and dispatch dallas to fix the bug.
|
|
224
|
+
|
|
225
|
+
===ACTIONS===
|
|
226
|
+
[{"type": "note", "title": "API v3 migration needed", "content": "We need to migrate..."}, {"type": "dispatch", "title": "Fix login bug", "workType": "fix", "priority": "high", "agents": ["dallas"], "project": "OfficeAgent", "description": "..."}]
|
|
227
|
+
|
|
228
|
+
**CRITICAL:** The ===ACTIONS=== line and JSON array must be the LAST thing in your response. No text after it. The JSON must be a valid array on a single line.
|
|
229
|
+
|
|
230
|
+
If no actions are needed (just answering a question, or you handled it directly), do NOT include the ===ACTIONS=== line.
|
|
231
|
+
|
|
232
|
+
Available action types:
|
|
233
|
+
- **dispatch**: Create a work item for an agent. Fields: title, workType (ask/explore/fix/review/test/implement/verify), priority (low/medium/high), agents (array of IDs, optional), project, description. Use \`verify\` when the user wants to build PRs locally, merge branches together, start a dev server, and get a localhost URL to test.
|
|
234
|
+
- **note**: Save a note/decision. Fields: title, content
|
|
235
|
+
- **plan**: Create a multi-step plan. Fields: title, description, project, branchStrategy (parallel/shared-branch)
|
|
236
|
+
- **cancel**: Cancel a running agent. Fields: agent (agent ID), reason
|
|
237
|
+
- **retry**: Retry failed work items. Fields: ids (array of work item IDs)
|
|
238
|
+
- **pause-plan**: Pause a PRD (stop materializing items). Fields: file (PRD .json filename)
|
|
239
|
+
- **approve-plan**: Approve a PRD (start materializing items). Fields: file (PRD .json filename)
|
|
240
|
+
- **edit-prd-item**: Edit a PRD item. Fields: source (PRD filename), itemId, name, description, priority, complexity
|
|
241
|
+
- **remove-prd-item**: Remove a PRD item. Fields: source (PRD filename), itemId
|
|
242
|
+
- **delete-work-item**: Delete a work item. Fields: id, source (project name or "central")
|
|
243
|
+
- **plan-edit**: Revise/edit a plan .md file. Fields: file (plan .md filename from plans/), instruction (what to change).
|
|
244
|
+
- **execute-plan**: Execute an existing plan .md file. Fields: file (plan .md filename), project (optional)
|
|
245
|
+
- **file-edit**: Edit any minions file via LLM. Fields: file (path relative to minions dir), instruction (what to change).
|
|
246
|
+
|
|
247
|
+
## Rules
|
|
248
|
+
|
|
249
|
+
1. **Use tools proactively.** Read files before answering — don't guess from the state snapshot alone.
|
|
250
|
+
2. Be specific — cite IDs, agent names, statuses, filenames, line numbers.
|
|
251
|
+
3. When delegating, include the action block AND explain what you're doing.
|
|
252
|
+
4. Resolve references like "ripley's plan", "the failing PR" by reading files.
|
|
253
|
+
5. When recommending which agent to assign, read \`routing.md\` and agent charters.
|
|
254
|
+
6. Keep responses concise but informative. Use markdown.
|
|
255
|
+
7. **Never modify engine source code** (engine.js, engine/*.js, dashboard.js/html, minions.js, bin/).
|
|
256
|
+
8. **Never push to git remotes** without the user explicitly confirming.
|
|
257
|
+
9. For long-running processes (dev servers), start them detached so they survive after your session.`;
|
|
258
|
+
|
|
259
|
+
function buildCCStatePreamble() {
|
|
260
|
+
// Lightweight snapshot — just enough to orient. Use tools for details.
|
|
261
|
+
const agents = getAgents().map(a => `- ${a.name} (${a.id}): ${a.status}${a.currentTask ? ' — ' + a.currentTask.slice(0, 60) : ''}`).join('\n');
|
|
262
|
+
const projects = PROJECTS.map(p => `- ${p.name}: ${p.localPath}`).join('\n');
|
|
263
|
+
|
|
264
|
+
const dq = getDispatchQueue();
|
|
265
|
+
const active = (dq.active || []).map(d => `- ${d.agentName || d.agent}: ${(d.task || '').slice(0, 50)}`).join('\n') || '(none)';
|
|
266
|
+
const pending = (dq.pending || []).length;
|
|
267
|
+
|
|
268
|
+
const prCount = getPullRequests().length;
|
|
269
|
+
const wiCount = getWorkItems().length;
|
|
270
|
+
|
|
271
|
+
const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
|
|
272
|
+
|
|
273
|
+
return `### Agents
|
|
274
|
+
${agents}
|
|
275
|
+
|
|
276
|
+
### Active Dispatch
|
|
277
|
+
${active}
|
|
278
|
+
Pending: ${pending}
|
|
279
|
+
|
|
280
|
+
### Quick Counts
|
|
281
|
+
PRs: ${prCount} | Work items: ${wiCount} | Plans/PRDs on disk: ${planFiles.length}
|
|
282
|
+
|
|
283
|
+
### Projects
|
|
284
|
+
${projects}
|
|
285
|
+
|
|
286
|
+
For details on any of the above, use your tools to read files under \`${MINIONS_DIR}\`.`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseCCActions(text) {
|
|
290
|
+
let actions = [];
|
|
291
|
+
let displayText = text;
|
|
292
|
+
const delimIdx = text.indexOf('===ACTIONS===');
|
|
293
|
+
if (delimIdx >= 0) {
|
|
294
|
+
displayText = text.slice(0, delimIdx).trim();
|
|
295
|
+
try {
|
|
296
|
+
const parsed = JSON.parse(text.slice(delimIdx + '===ACTIONS==='.length).trim());
|
|
297
|
+
actions = Array.isArray(parsed) ? parsed : [parsed];
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
300
|
+
if (actions.length === 0) {
|
|
301
|
+
const actionRegex = /`{3,}\s*action\s*\r?\n([\s\S]*?)`{3,}/g;
|
|
302
|
+
let match;
|
|
303
|
+
while ((match = actionRegex.exec(displayText)) !== null) {
|
|
304
|
+
try { actions.push(JSON.parse(match[1].trim())); } catch {}
|
|
305
|
+
}
|
|
306
|
+
if (actions.length > 0) displayText = displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
|
|
307
|
+
}
|
|
308
|
+
return { text: displayText, actions };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Shared LLM call core — used by CC panel and doc modals ──────────────────
|
|
312
|
+
|
|
313
|
+
// Session store for doc modals — keyed by filePath or title, persisted to disk
|
|
314
|
+
const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
|
|
315
|
+
const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
|
|
316
|
+
|
|
317
|
+
// Load persisted doc sessions on startup
|
|
318
|
+
try {
|
|
319
|
+
const saved = safeJson(DOC_SESSIONS_PATH);
|
|
320
|
+
if (saved && typeof saved === 'object') {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
for (const [key, s] of Object.entries(saved)) {
|
|
323
|
+
const age = now - new Date(s.lastActiveAt || 0).getTime();
|
|
324
|
+
if (age < CC_SESSION_EXPIRY_MS && s.turnCount < CC_SESSION_MAX_TURNS) {
|
|
325
|
+
docSessions.set(key, s);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {}
|
|
330
|
+
|
|
331
|
+
function persistDocSessions() {
|
|
332
|
+
const obj = {};
|
|
333
|
+
for (const [key, s] of docSessions) obj[key] = s;
|
|
334
|
+
safeWrite(DOC_SESSIONS_PATH, obj);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Resolve session from any store (CC global or doc-specific)
|
|
338
|
+
function resolveSession(store, key) {
|
|
339
|
+
if (store === 'cc') {
|
|
340
|
+
return ccSessionValid() ? { sessionId: ccSession.sessionId, turnCount: ccSession.turnCount } : null;
|
|
341
|
+
}
|
|
342
|
+
if (!key) return null;
|
|
343
|
+
const s = docSessions.get(key);
|
|
344
|
+
if (!s) return null;
|
|
345
|
+
const age = Date.now() - new Date(s.lastActiveAt).getTime();
|
|
346
|
+
if (age > CC_SESSION_EXPIRY_MS || s.turnCount >= CC_SESSION_MAX_TURNS) {
|
|
347
|
+
docSessions.delete(key);
|
|
348
|
+
persistDocSessions();
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return s;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Update session after successful call
|
|
355
|
+
function updateSession(store, key, sessionId, existing) {
|
|
356
|
+
if (!sessionId) return;
|
|
357
|
+
const now = new Date().toISOString();
|
|
358
|
+
if (store === 'cc') {
|
|
359
|
+
ccSession = {
|
|
360
|
+
sessionId,
|
|
361
|
+
createdAt: existing ? ccSession.createdAt : now,
|
|
362
|
+
lastActiveAt: now,
|
|
363
|
+
turnCount: (existing ? ccSession.turnCount : 0) + 1,
|
|
364
|
+
};
|
|
365
|
+
safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
|
|
366
|
+
} else if (key) {
|
|
367
|
+
const prev = docSessions.get(key);
|
|
368
|
+
docSessions.set(key, {
|
|
369
|
+
sessionId,
|
|
370
|
+
lastActiveAt: now,
|
|
371
|
+
turnCount: (existing && prev ? prev.turnCount : 0) + 1,
|
|
372
|
+
});
|
|
373
|
+
persistDocSessions();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Core LLM call — shared by CC panel and doc modals.
|
|
379
|
+
* @param {string} message - User message
|
|
380
|
+
* @param {object} opts
|
|
381
|
+
* @param {string} opts.store - 'cc' or 'doc'
|
|
382
|
+
* @param {string} opts.sessionKey - Key for doc session (filePath or title)
|
|
383
|
+
* @param {string} opts.extraContext - Additional context prepended to message (e.g., document)
|
|
384
|
+
* @param {string} opts.label - Metrics label
|
|
385
|
+
* @param {number} opts.timeout - Timeout in ms
|
|
386
|
+
* @param {number} opts.maxTurns - Max tool-use turns
|
|
387
|
+
* @param {string} opts.allowedTools - Comma-separated tool list
|
|
388
|
+
*/
|
|
389
|
+
async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = 900000, maxTurns = 25, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, model = 'sonnet' } = {}) {
|
|
390
|
+
const existing = resolveSession(store, sessionKey);
|
|
391
|
+
let sessionId = existing ? existing.sessionId : null;
|
|
392
|
+
|
|
393
|
+
const parts = skipStatePreamble ? [] : [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`];
|
|
394
|
+
if (extraContext) parts.push(extraContext);
|
|
395
|
+
parts.push(message);
|
|
396
|
+
const prompt = parts.join('\n\n---\n\n');
|
|
397
|
+
|
|
398
|
+
let result;
|
|
399
|
+
|
|
400
|
+
// Attempt 1: resume existing session (skip for single-turn/no-tool calls — nothing to resume)
|
|
401
|
+
if (sessionId && maxTurns > 1) {
|
|
402
|
+
result = await llm.callLLM(prompt, '', {
|
|
403
|
+
timeout, label, model, maxTurns, allowedTools, sessionId,
|
|
404
|
+
});
|
|
405
|
+
llm.trackEngineUsage(label, result.usage);
|
|
406
|
+
|
|
407
|
+
if (result.code === 0 && result.text) {
|
|
408
|
+
updateSession(store, sessionKey, result.sessionId || sessionId, true);
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Distinguish "session exists but call failed" (e.g. tool timeout, signal timeout)
|
|
413
|
+
// from "session is truly dead" (no sessionId returned, or stderr indicates invalid session).
|
|
414
|
+
// If the session still exists, preserve it so the next "try again" can resume.
|
|
415
|
+
const sessionStillValid = llm.isResumeSessionStillValid(result);
|
|
416
|
+
if (sessionStillValid) {
|
|
417
|
+
console.log(`[${label}] Resume call failed (code=${result.code}, empty=${!result.text}) but session is still valid — preserving session for retry`);
|
|
418
|
+
// Update lastActiveAt so session doesn't expire while user retries
|
|
419
|
+
updateSession(store, sessionKey, result.sessionId || sessionId, true);
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
console.log(`[${label}] Resume failed — session appears dead (code=${result.code}, empty=${!result.text}), retrying fresh...`);
|
|
424
|
+
sessionId = null;
|
|
425
|
+
// Invalidate the dead session so future calls don't try to resume it
|
|
426
|
+
if (store === 'cc') {
|
|
427
|
+
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
428
|
+
safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
|
|
429
|
+
} else if (sessionKey) {
|
|
430
|
+
docSessions.delete(sessionKey);
|
|
431
|
+
persistDocSessions();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Attempt 2: fresh session
|
|
436
|
+
result = await llm.callLLM(prompt, CC_STATIC_SYSTEM_PROMPT, {
|
|
437
|
+
timeout, label, model, maxTurns, allowedTools,
|
|
438
|
+
});
|
|
439
|
+
llm.trackEngineUsage(label, result.usage);
|
|
440
|
+
|
|
441
|
+
if (result.code === 0 && result.text) {
|
|
442
|
+
updateSession(store, sessionKey, result.sessionId, false);
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Attempt 3: one more retry after a brief pause (skip for single-turn — not worth the latency)
|
|
447
|
+
if (maxTurns <= 1) return result;
|
|
448
|
+
console.log(`[${label}] Fresh call also failed (code=${result.code}, empty=${!result.text}), retrying once more...`);
|
|
449
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
450
|
+
result = await llm.callLLM(prompt, CC_STATIC_SYSTEM_PROMPT, {
|
|
451
|
+
timeout, label, model, maxTurns, allowedTools,
|
|
452
|
+
});
|
|
453
|
+
llm.trackEngineUsage(label, result.usage);
|
|
454
|
+
|
|
455
|
+
if (result.code === 0 && result.text) {
|
|
456
|
+
updateSession(store, sessionKey, result.sessionId, false);
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Doc-specific wrapper — adds document context, parses ---DOCUMENT---
|
|
462
|
+
async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson }) {
|
|
463
|
+
const docContext = `## Document Context\n**${title || 'Document'}**${filePath ? ' (`' + filePath + '`)' : ''}${isJson ? ' (JSON)' : ''}\n${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) + '\n' : ''}\n\`\`\`\n${document.slice(0, 20000)}\n\`\`\`\n${canEdit ? '\nIf editing: respond with your explanation, then `---DOCUMENT---` on its own line, then the COMPLETE updated file.' : '\n(Read-only — answer questions only.)'}`;
|
|
464
|
+
|
|
465
|
+
// Plans: Sonnet with tools for codebase-aware Q&A and edits
|
|
466
|
+
// Everything else: Haiku, 1 turn, no tools — fast
|
|
467
|
+
const isPlan = filePath && /^plans\//.test(filePath);
|
|
468
|
+
const result = await ccCall(message, {
|
|
469
|
+
store: 'doc', sessionKey: filePath || title,
|
|
470
|
+
extraContext: docContext, label: 'doc-chat',
|
|
471
|
+
timeout: isPlan ? 300000 : 60000,
|
|
472
|
+
maxTurns: isPlan ? 10 : 1,
|
|
473
|
+
model: isPlan ? 'sonnet' : 'haiku',
|
|
474
|
+
allowedTools: isPlan ? 'Read,Glob,Grep' : '',
|
|
475
|
+
skipStatePreamble: !isPlan,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (result.code !== 0 || !result.text) {
|
|
479
|
+
return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const { text: stripped, actions } = parseCCActions(result.text);
|
|
483
|
+
|
|
484
|
+
const delimIdx = stripped.indexOf('---DOCUMENT---');
|
|
485
|
+
if (delimIdx >= 0) {
|
|
486
|
+
const answer = stripped.slice(0, delimIdx).trim();
|
|
487
|
+
let content = stripped.slice(delimIdx + '---DOCUMENT---'.length).trim();
|
|
488
|
+
content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
|
|
489
|
+
return { answer, content, actions };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return { answer: stripped, content: null, actions };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// -- POST helpers --
|
|
496
|
+
|
|
497
|
+
function readBody(req) {
|
|
498
|
+
return new Promise((resolve, reject) => {
|
|
499
|
+
let body = '';
|
|
500
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
|
|
501
|
+
req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function jsonReply(res, code, data, req) {
|
|
506
|
+
res.setHeader('Content-Type', 'application/json');
|
|
507
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
508
|
+
res.statusCode = code;
|
|
509
|
+
const json = JSON.stringify(data);
|
|
510
|
+
const ae = req && req.headers && req.headers['accept-encoding'] || '';
|
|
511
|
+
if (ae.includes('gzip') && json.length > 1024) {
|
|
512
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
513
|
+
res.end(zlib.gzipSync(json));
|
|
514
|
+
} else {
|
|
515
|
+
res.end(json);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// -- Dispatch cleanup helper --
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Remove dispatch entries matching a predicate. Scans pending, active, completed queues.
|
|
523
|
+
* Also kills agent processes for matched active entries.
|
|
524
|
+
* @param {(entry) => boolean} matchFn - return true for entries to remove
|
|
525
|
+
* @returns {number} count of removed entries
|
|
526
|
+
*/
|
|
527
|
+
function cleanDispatchEntries(matchFn) {
|
|
528
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
529
|
+
try {
|
|
530
|
+
let removed = 0;
|
|
531
|
+
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
532
|
+
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
533
|
+
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
534
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
535
|
+
for (const queue of ['pending', 'active', 'completed']) {
|
|
536
|
+
const before = dispatch[queue].length;
|
|
537
|
+
if (queue === 'active') {
|
|
538
|
+
for (const d of dispatch[queue]) {
|
|
539
|
+
if (matchFn(d) && d.agent) {
|
|
540
|
+
const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
|
|
541
|
+
try {
|
|
542
|
+
const status = JSON.parse(safeRead(statusPath) || '{}');
|
|
543
|
+
if (status.pid) try { process.kill(status.pid, 'SIGTERM'); } catch {}
|
|
544
|
+
status.status = 'idle';
|
|
545
|
+
delete status.currentTask;
|
|
546
|
+
safeWrite(statusPath, status);
|
|
547
|
+
} catch {}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
|
|
552
|
+
removed += before - dispatch[queue].length;
|
|
553
|
+
}
|
|
554
|
+
return dispatch;
|
|
555
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
556
|
+
return removed;
|
|
557
|
+
} catch { return 0; }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
|
|
561
|
+
|
|
562
|
+
function spawnEngine() {
|
|
563
|
+
const controlPath = path.join(ENGINE_DIR, 'control.json');
|
|
564
|
+
safeWrite(controlPath, { state: 'stopped', pid: null, restarted_at: new Date().toISOString() });
|
|
565
|
+
const { spawn: cpSpawn } = require('child_process');
|
|
566
|
+
const childEnv = { ...process.env };
|
|
567
|
+
for (const key of Object.keys(childEnv)) {
|
|
568
|
+
if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
|
|
569
|
+
}
|
|
570
|
+
const engineProc = cpSpawn(process.execPath, [path.join(MINIONS_DIR, 'engine.js'), 'start'], {
|
|
571
|
+
cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv,
|
|
572
|
+
});
|
|
573
|
+
engineProc.unref();
|
|
574
|
+
return engineProc.pid;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function killEnginePid(pid) {
|
|
578
|
+
const { execSync } = require('child_process');
|
|
579
|
+
try {
|
|
580
|
+
if (process.platform === 'win32') {
|
|
581
|
+
execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 5000 });
|
|
582
|
+
} else {
|
|
583
|
+
process.kill(pid, 'SIGKILL');
|
|
584
|
+
}
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function restartEngine() {
|
|
589
|
+
const control = getEngineState();
|
|
590
|
+
if (control.pid) {
|
|
591
|
+
killEnginePid(control.pid);
|
|
592
|
+
console.log(`[watchdog] Killed engine PID ${control.pid}`);
|
|
593
|
+
}
|
|
594
|
+
const newPid = spawnEngine();
|
|
595
|
+
console.log(`[watchdog] Engine restarted (new PID: ${newPid})`);
|
|
596
|
+
return newPid;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// -- Server --
|
|
600
|
+
|
|
601
|
+
const server = http.createServer(async (req, res) => {
|
|
602
|
+
// CORS preflight
|
|
603
|
+
if (req.method === 'OPTIONS') {
|
|
604
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
605
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
606
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
607
|
+
res.statusCode = 204;
|
|
608
|
+
res.end();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// POST /api/plans/trigger-verify — manually trigger verification for a completed plan
|
|
613
|
+
if (req.method === 'POST' && req.url === '/api/plans/trigger-verify') {
|
|
614
|
+
try {
|
|
615
|
+
const body = await readBody(req);
|
|
616
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
617
|
+
|
|
618
|
+
// Find the PRD — check active and archive
|
|
619
|
+
const prdDir = path.join(MINIONS_DIR, 'prd');
|
|
620
|
+
let prdPath = path.join(prdDir, body.file);
|
|
621
|
+
let fromArchive = false;
|
|
622
|
+
if (!fs.existsSync(prdPath)) {
|
|
623
|
+
prdPath = path.join(prdDir, 'archive', body.file);
|
|
624
|
+
fromArchive = true;
|
|
625
|
+
}
|
|
626
|
+
if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
|
|
627
|
+
|
|
628
|
+
// If archived, temporarily restore to active so checkPlanCompletion can find it
|
|
629
|
+
const activePath = path.join(prdDir, body.file);
|
|
630
|
+
if (fromArchive) {
|
|
631
|
+
const plan = JSON.parse(safeRead(prdPath));
|
|
632
|
+
plan.status = 'approved';
|
|
633
|
+
delete plan.completedAt;
|
|
634
|
+
safeWrite(activePath, plan);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Trigger completion check
|
|
638
|
+
const lifecycle = require('./engine/lifecycle');
|
|
639
|
+
const config = queries.getConfig();
|
|
640
|
+
lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
|
|
641
|
+
|
|
642
|
+
// Check if verify was created
|
|
643
|
+
const project = PROJECTS.find(p => {
|
|
644
|
+
const plan = JSON.parse(safeRead(activePath) || safeRead(prdPath) || '{}');
|
|
645
|
+
return p.name?.toLowerCase() === (plan.project || '').toLowerCase();
|
|
646
|
+
}) || PROJECTS[0];
|
|
647
|
+
if (project) {
|
|
648
|
+
const wiPath = shared.projectWorkItemsPath(project);
|
|
649
|
+
const items = JSON.parse(safeRead(wiPath) || '[]');
|
|
650
|
+
const verify = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
|
|
651
|
+
if (verify) {
|
|
652
|
+
return jsonReply(res, 200, { ok: true, verifyId: verify.id });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return jsonReply(res, 200, { ok: true, message: 'Completion check ran but no verify task was needed' });
|
|
656
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// POST /api/work-items/retry — reset a failed/dispatched item to pending
|
|
660
|
+
if (req.method === 'POST' && req.url === '/api/work-items/retry') {
|
|
661
|
+
try {
|
|
662
|
+
const body = await readBody(req);
|
|
663
|
+
const { id, source } = body;
|
|
664
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
665
|
+
|
|
666
|
+
// Find the right file
|
|
667
|
+
let wiPath;
|
|
668
|
+
if (!source || source === 'central') {
|
|
669
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
670
|
+
} else {
|
|
671
|
+
const proj = PROJECTS.find(p => p.name === source);
|
|
672
|
+
if (proj) {
|
|
673
|
+
wiPath = shared.projectWorkItemsPath(proj);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
|
|
677
|
+
|
|
678
|
+
const items = JSON.parse(safeRead(wiPath) || '[]');
|
|
679
|
+
const item = items.find(i => i.id === id);
|
|
680
|
+
if (!item) return jsonReply(res, 404, { error: 'item not found' });
|
|
681
|
+
|
|
682
|
+
item.status = 'pending';
|
|
683
|
+
item._retryCount = 0; // Reset retry counter on manual retry
|
|
684
|
+
delete item.dispatched_at;
|
|
685
|
+
delete item.dispatched_to;
|
|
686
|
+
delete item.failReason;
|
|
687
|
+
delete item.failedAt;
|
|
688
|
+
delete item.fanOutAgents;
|
|
689
|
+
safeWrite(wiPath, items);
|
|
690
|
+
|
|
691
|
+
// Clear completed dispatch entries so the engine doesn't dedup this item
|
|
692
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
693
|
+
const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
|
|
694
|
+
const dispatchKey = sourcePrefix + id;
|
|
695
|
+
try {
|
|
696
|
+
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
697
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
698
|
+
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
699
|
+
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
700
|
+
return dispatch;
|
|
701
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
702
|
+
} catch {}
|
|
703
|
+
|
|
704
|
+
// Clear cooldown so item isn't blocked by exponential backoff
|
|
705
|
+
try {
|
|
706
|
+
const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
|
|
707
|
+
const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
|
|
708
|
+
if (cooldowns[dispatchKey]) {
|
|
709
|
+
delete cooldowns[dispatchKey];
|
|
710
|
+
safeWrite(cooldownPath, cooldowns);
|
|
711
|
+
}
|
|
712
|
+
} catch {}
|
|
713
|
+
|
|
714
|
+
return jsonReply(res, 200, { ok: true, id });
|
|
715
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// POST /api/work-items/delete — remove a work item, kill agent, clear dispatch
|
|
719
|
+
if (req.method === 'POST' && req.url === '/api/work-items/delete') {
|
|
720
|
+
try {
|
|
721
|
+
const body = await readBody(req);
|
|
722
|
+
const { id, source } = body;
|
|
723
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
724
|
+
|
|
725
|
+
// Find the right work-items file
|
|
726
|
+
let wiPath;
|
|
727
|
+
if (!source || source === 'central') {
|
|
728
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
729
|
+
} else {
|
|
730
|
+
const proj = PROJECTS.find(p => p.name === source);
|
|
731
|
+
if (proj) {
|
|
732
|
+
wiPath = shared.projectWorkItemsPath(proj);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
|
|
736
|
+
|
|
737
|
+
const items = JSON.parse(safeRead(wiPath) || '[]');
|
|
738
|
+
const idx = items.findIndex(i => i.id === id);
|
|
739
|
+
if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
|
|
740
|
+
|
|
741
|
+
const item = items[idx];
|
|
742
|
+
|
|
743
|
+
// Remove item from work-items file
|
|
744
|
+
items.splice(idx, 1);
|
|
745
|
+
safeWrite(wiPath, items);
|
|
746
|
+
|
|
747
|
+
// Clean dispatch entries + kill running agent
|
|
748
|
+
const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
|
|
749
|
+
const dispatchKey = sourcePrefix + id;
|
|
750
|
+
cleanDispatchEntries(d =>
|
|
751
|
+
d.meta?.dispatchKey === dispatchKey ||
|
|
752
|
+
(d.meta?.parentKey && d.meta.parentKey === dispatchKey) ||
|
|
753
|
+
d.meta?.item?.id === id
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
return jsonReply(res, 200, { ok: true, id });
|
|
757
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// POST /api/work-items/archive — move a completed/failed work item to archive
|
|
761
|
+
if (req.method === 'POST' && req.url === '/api/work-items/archive') {
|
|
762
|
+
try {
|
|
763
|
+
const body = await readBody(req);
|
|
764
|
+
const { id, source } = body;
|
|
765
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
766
|
+
|
|
767
|
+
let wiPath;
|
|
768
|
+
if (!source || source === 'central') {
|
|
769
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
770
|
+
} else {
|
|
771
|
+
const proj = PROJECTS.find(p => p.name === source);
|
|
772
|
+
if (proj) {
|
|
773
|
+
wiPath = shared.projectWorkItemsPath(proj);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
|
|
777
|
+
|
|
778
|
+
const items = JSON.parse(safeRead(wiPath) || '[]');
|
|
779
|
+
const idx = items.findIndex(i => i.id === id);
|
|
780
|
+
if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
|
|
781
|
+
|
|
782
|
+
const item = items.splice(idx, 1)[0];
|
|
783
|
+
item.archivedAt = new Date().toISOString();
|
|
784
|
+
|
|
785
|
+
// Append to archive file
|
|
786
|
+
const archivePath = wiPath.replace('.json', '-archive.json');
|
|
787
|
+
let archive = [];
|
|
788
|
+
const existing = safeRead(archivePath);
|
|
789
|
+
if (existing) { try { archive = JSON.parse(existing); } catch {} }
|
|
790
|
+
archive.push(item);
|
|
791
|
+
safeWrite(archivePath, archive);
|
|
792
|
+
safeWrite(wiPath, items);
|
|
793
|
+
|
|
794
|
+
// Clean dispatch entries for archived item
|
|
795
|
+
const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
|
|
796
|
+
cleanDispatchEntries(d =>
|
|
797
|
+
d.meta?.dispatchKey === sourcePrefix + id ||
|
|
798
|
+
d.meta?.item?.id === id
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
return jsonReply(res, 200, { ok: true, id });
|
|
802
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// GET /api/work-items/archive — list archived work items
|
|
806
|
+
if (req.method === 'GET' && req.url === '/api/work-items/archive') {
|
|
807
|
+
try {
|
|
808
|
+
let allArchived = [];
|
|
809
|
+
// Central archive
|
|
810
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items-archive.json');
|
|
811
|
+
const central = safeRead(centralPath);
|
|
812
|
+
if (central) { try { allArchived.push(...JSON.parse(central).map(i => ({ ...i, _source: 'central' }))); } catch {} }
|
|
813
|
+
// Project archives
|
|
814
|
+
for (const project of PROJECTS) {
|
|
815
|
+
const archPath = shared.projectWorkItemsPath(project).replace('.json', '-archive.json');
|
|
816
|
+
const content = safeRead(archPath);
|
|
817
|
+
if (content) { try { allArchived.push(...JSON.parse(content).map(i => ({ ...i, _source: project.name }))); } catch {} }
|
|
818
|
+
}
|
|
819
|
+
return jsonReply(res, 200, allArchived);
|
|
820
|
+
} catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, 500, { error: e.message }); }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// POST /api/work-items
|
|
824
|
+
if (req.method === 'POST' && req.url === '/api/work-items') {
|
|
825
|
+
try {
|
|
826
|
+
const body = await readBody(req);
|
|
827
|
+
if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
|
|
828
|
+
let wiPath;
|
|
829
|
+
if (body.project) {
|
|
830
|
+
// Write to project-specific queue
|
|
831
|
+
const targetProject = PROJECTS.find(p => p.name === body.project) || PROJECTS[0];
|
|
832
|
+
wiPath = shared.projectWorkItemsPath(targetProject);
|
|
833
|
+
} else {
|
|
834
|
+
// Write to central queue — agent decides which project
|
|
835
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
836
|
+
}
|
|
837
|
+
let items = [];
|
|
838
|
+
const existing = safeRead(wiPath);
|
|
839
|
+
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
840
|
+
const id = 'W-' + shared.uid();
|
|
841
|
+
const item = {
|
|
842
|
+
id, title: body.title, type: body.type || 'implement',
|
|
843
|
+
priority: body.priority || 'medium', description: body.description || '',
|
|
844
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
|
|
845
|
+
};
|
|
846
|
+
if (body.scope) item.scope = body.scope;
|
|
847
|
+
if (body.agent) item.agent = body.agent;
|
|
848
|
+
if (body.agents) item.agents = body.agents;
|
|
849
|
+
items.push(item);
|
|
850
|
+
safeWrite(wiPath, items);
|
|
851
|
+
return jsonReply(res, 200, { ok: true, id });
|
|
852
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// POST /api/notes — write to inbox so it flows through normal consolidation
|
|
856
|
+
if (req.method === 'POST' && req.url === '/api/notes') {
|
|
857
|
+
try {
|
|
858
|
+
const body = await readBody(req);
|
|
859
|
+
if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
|
|
860
|
+
const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
861
|
+
fs.mkdirSync(inboxDir, { recursive: true });
|
|
862
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
863
|
+
const author = body.author || os.userInfo().username;
|
|
864
|
+
const slug = (body.title || 'note').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
|
|
865
|
+
const filename = `${author}-${slug}-${today}-${shared.uid().slice(-4)}.md`;
|
|
866
|
+
const content = `# ${body.title}\n\n**By:** ${author}\n**Date:** ${today}\n\n${body.what}\n${body.why ? '\n**Why:** ' + body.why + '\n' : ''}`;
|
|
867
|
+
safeWrite(shared.uniquePath(path.join(inboxDir, filename)), content);
|
|
868
|
+
return jsonReply(res, 200, { ok: true });
|
|
869
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// POST /api/plan — create a plan work item that chains to PRD on completion
|
|
873
|
+
if (req.method === 'POST' && req.url === '/api/plan') {
|
|
874
|
+
try {
|
|
875
|
+
const body = await readBody(req);
|
|
876
|
+
if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
|
|
877
|
+
// Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
|
|
878
|
+
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
879
|
+
let items = [];
|
|
880
|
+
const existing = safeRead(wiPath);
|
|
881
|
+
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
882
|
+
const id = 'W-' + shared.uid();
|
|
883
|
+
const item = {
|
|
884
|
+
id, title: body.title, type: 'plan',
|
|
885
|
+
priority: body.priority || 'high', description: body.description || '',
|
|
886
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
|
|
887
|
+
branchStrategy: body.branch_strategy || 'parallel',
|
|
888
|
+
};
|
|
889
|
+
if (body.project) item.project = body.project;
|
|
890
|
+
if (body.agent) item.agent = body.agent;
|
|
891
|
+
items.push(item);
|
|
892
|
+
safeWrite(wiPath, items);
|
|
893
|
+
return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
|
|
894
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// POST /api/prd-items — create a PRD item as a plan file in prd/ (auto-approved)
|
|
898
|
+
if (req.method === 'POST' && req.url === '/api/prd-items') {
|
|
899
|
+
try {
|
|
900
|
+
const body = await readBody(req);
|
|
901
|
+
if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
|
|
902
|
+
|
|
903
|
+
if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
|
|
904
|
+
|
|
905
|
+
const id = body.id || ('M' + String(Date.now()).slice(-4));
|
|
906
|
+
const planFile = 'manual-' + shared.uid() + '.json';
|
|
907
|
+
const plan = {
|
|
908
|
+
version: 'manual-' + new Date().toISOString().slice(0, 10),
|
|
909
|
+
project: body.project || (PROJECTS[0]?.name || 'Unknown'),
|
|
910
|
+
generated_by: 'dashboard',
|
|
911
|
+
generated_at: new Date().toISOString().slice(0, 10),
|
|
912
|
+
plan_summary: body.name,
|
|
913
|
+
status: 'approved',
|
|
914
|
+
requires_approval: false,
|
|
915
|
+
branch_strategy: 'parallel',
|
|
916
|
+
missing_features: [{
|
|
917
|
+
id, name: body.name, description: body.description || '',
|
|
918
|
+
priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
|
|
919
|
+
status: 'missing', depends_on: [], acceptance_criteria: [],
|
|
920
|
+
}],
|
|
921
|
+
open_questions: [],
|
|
922
|
+
};
|
|
923
|
+
safeWrite(path.join(PRD_DIR, planFile), plan);
|
|
924
|
+
return jsonReply(res, 200, { ok: true, id, file: planFile });
|
|
925
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// POST /api/prd-items/update — edit a PRD item in its source plan JSON
|
|
929
|
+
if (req.method === 'POST' && req.url === '/api/prd-items/update') {
|
|
930
|
+
try {
|
|
931
|
+
const body = await readBody(req);
|
|
932
|
+
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
933
|
+
const planPath = resolvePlanPath(body.source);
|
|
934
|
+
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
935
|
+
const plan = safeJson(planPath);
|
|
936
|
+
const item = (plan.missing_features || []).find(f => f.id === body.itemId);
|
|
937
|
+
if (!item) return jsonReply(res, 404, { error: 'item not found in plan' });
|
|
938
|
+
|
|
939
|
+
// Update allowed fields
|
|
940
|
+
if (body.name !== undefined) item.name = body.name;
|
|
941
|
+
if (body.description !== undefined) item.description = body.description;
|
|
942
|
+
if (body.priority !== undefined) item.priority = body.priority;
|
|
943
|
+
if (body.estimated_complexity !== undefined) item.estimated_complexity = body.estimated_complexity;
|
|
944
|
+
if (body.status !== undefined) item.status = body.status;
|
|
945
|
+
|
|
946
|
+
// Re-read plan before writing to minimize race window with engine
|
|
947
|
+
const freshPlan = safeJson(planPath) || plan;
|
|
948
|
+
const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
|
|
949
|
+
if (freshItem) {
|
|
950
|
+
if (body.name !== undefined) freshItem.name = body.name;
|
|
951
|
+
if (body.description !== undefined) freshItem.description = body.description;
|
|
952
|
+
if (body.priority !== undefined) freshItem.priority = body.priority;
|
|
953
|
+
if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
|
|
954
|
+
if (body.status !== undefined) freshItem.status = body.status;
|
|
955
|
+
}
|
|
956
|
+
safeWrite(planPath, freshPlan);
|
|
957
|
+
|
|
958
|
+
// Feature 3: Sync edits to materialized work item if still pending
|
|
959
|
+
let workItemSynced = false;
|
|
960
|
+
const wiSyncPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
961
|
+
for (const proj of PROJECTS) {
|
|
962
|
+
wiSyncPaths.push(shared.projectWorkItemsPath(proj));
|
|
963
|
+
}
|
|
964
|
+
for (const wiPath of wiSyncPaths) {
|
|
965
|
+
try {
|
|
966
|
+
const items = safeJson(wiPath);
|
|
967
|
+
const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
|
|
968
|
+
if (wi && wi.status === 'pending') {
|
|
969
|
+
if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
|
|
970
|
+
if (body.description !== undefined) wi.description = body.description;
|
|
971
|
+
if (body.priority !== undefined) wi.priority = body.priority;
|
|
972
|
+
if (body.estimated_complexity !== undefined) {
|
|
973
|
+
wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
|
|
974
|
+
}
|
|
975
|
+
safeWrite(wiPath, items);
|
|
976
|
+
workItemSynced = true;
|
|
977
|
+
}
|
|
978
|
+
} catch {}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return jsonReply(res, 200, { ok: true, item, workItemSynced });
|
|
982
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// POST /api/prd-items/remove — remove a PRD item from plan + cancel materialized work item
|
|
986
|
+
if (req.method === 'POST' && req.url === '/api/prd-items/remove') {
|
|
987
|
+
try {
|
|
988
|
+
const body = await readBody(req);
|
|
989
|
+
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
990
|
+
const planPath = resolvePlanPath(body.source);
|
|
991
|
+
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
992
|
+
const plan = safeJson(planPath);
|
|
993
|
+
const idx = (plan.missing_features || []).findIndex(f => f.id === body.itemId);
|
|
994
|
+
if (idx < 0) return jsonReply(res, 404, { error: 'item not found in plan' });
|
|
995
|
+
|
|
996
|
+
plan.missing_features.splice(idx, 1);
|
|
997
|
+
safeWrite(planPath, plan);
|
|
998
|
+
|
|
999
|
+
// Also remove any materialized work item for this plan item
|
|
1000
|
+
let cancelled = false;
|
|
1001
|
+
for (const proj of PROJECTS) {
|
|
1002
|
+
const wiPath = shared.projectWorkItemsPath(proj);
|
|
1003
|
+
try {
|
|
1004
|
+
const items = safeJson(wiPath);
|
|
1005
|
+
const before = items.length;
|
|
1006
|
+
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
1007
|
+
if (filtered.length < before) {
|
|
1008
|
+
safeWrite(wiPath, filtered);
|
|
1009
|
+
cancelled = true;
|
|
1010
|
+
}
|
|
1011
|
+
} catch {}
|
|
1012
|
+
}
|
|
1013
|
+
// Also check central work-items
|
|
1014
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1015
|
+
try {
|
|
1016
|
+
const items = safeJson(centralPath);
|
|
1017
|
+
const before = items.length;
|
|
1018
|
+
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
1019
|
+
if (filtered.length < before) { safeWrite(centralPath, filtered); cancelled = true; }
|
|
1020
|
+
} catch {}
|
|
1021
|
+
|
|
1022
|
+
// Clean dispatch entries for this item
|
|
1023
|
+
cleanDispatchEntries(d =>
|
|
1024
|
+
d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === body.itemId
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
return jsonReply(res, 200, { ok: true, cancelled });
|
|
1028
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// POST /api/agents/cancel — cancel an active agent by ID or task substring
|
|
1032
|
+
if (req.method === 'POST' && req.url === '/api/agents/cancel') {
|
|
1033
|
+
try {
|
|
1034
|
+
const body = await readBody(req);
|
|
1035
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
1036
|
+
const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
|
|
1037
|
+
const active = dispatch.active || [];
|
|
1038
|
+
const cancelled = [];
|
|
1039
|
+
|
|
1040
|
+
for (const d of active) {
|
|
1041
|
+
const matchAgent = body.agent && d.agent === body.agent;
|
|
1042
|
+
const matchTask = body.task && (d.task || '').toLowerCase().includes((body.task || '').toLowerCase());
|
|
1043
|
+
if (!matchAgent && !matchTask) continue;
|
|
1044
|
+
|
|
1045
|
+
// Kill agent process
|
|
1046
|
+
const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
|
|
1047
|
+
try {
|
|
1048
|
+
const status = JSON.parse(safeRead(statusPath) || '{}');
|
|
1049
|
+
if (status.pid) {
|
|
1050
|
+
if (process.platform === 'win32') {
|
|
1051
|
+
try { require('child_process').execSync('taskkill /PID ' + status.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch {}
|
|
1052
|
+
} else {
|
|
1053
|
+
try { process.kill(status.pid, 'SIGTERM'); } catch {}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
status.status = 'idle';
|
|
1057
|
+
delete status.currentTask;
|
|
1058
|
+
delete status.dispatched;
|
|
1059
|
+
safeWrite(statusPath, status);
|
|
1060
|
+
} catch {}
|
|
1061
|
+
|
|
1062
|
+
cancelled.push({ agent: d.agent, task: d.task });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Remove cancelled from active dispatch
|
|
1066
|
+
if (cancelled.length > 0) {
|
|
1067
|
+
const cancelledIds = new Set(cancelled.map(c => c.agent));
|
|
1068
|
+
mutateJsonFileLocked(dispatchPath, (dp) => {
|
|
1069
|
+
dp.active = Array.isArray(dp.active) ? dp.active : [];
|
|
1070
|
+
dp.active = dp.active.filter(d => !cancelledIds.has(d.agent));
|
|
1071
|
+
return dp;
|
|
1072
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return jsonReply(res, 200, { ok: true, cancelled });
|
|
1076
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// GET /api/agent/:id/live — tail live output for a working agent
|
|
1080
|
+
const liveMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/);
|
|
1081
|
+
if (liveMatch && req.method === 'GET') {
|
|
1082
|
+
const agentId = liveMatch[1];
|
|
1083
|
+
const livePath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
|
|
1084
|
+
const content = safeRead(livePath);
|
|
1085
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1086
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1087
|
+
if (!content) {
|
|
1088
|
+
res.end('No live output. Agent may not be running.');
|
|
1089
|
+
} else {
|
|
1090
|
+
// Return last N bytes via ?tail=N param (default last 8KB)
|
|
1091
|
+
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
1092
|
+
const tailBytes = parseInt(params.get('tail')) || 8192;
|
|
1093
|
+
res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// GET /api/agent/:id/output — fetch final output.log for an agent
|
|
1099
|
+
const outputMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/);
|
|
1100
|
+
if (outputMatch && req.method === 'GET') {
|
|
1101
|
+
const agentId = outputMatch[1];
|
|
1102
|
+
const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'output.log');
|
|
1103
|
+
const content = safeRead(outputPath);
|
|
1104
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1105
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1106
|
+
res.end(content || 'No output log found for this agent.');
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// GET /api/notes-full — return full notes.md content
|
|
1111
|
+
if (req.method === 'GET' && req.url === '/api/notes-full') {
|
|
1112
|
+
const content = safeRead(path.join(MINIONS_DIR, 'notes.md'));
|
|
1113
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1114
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1115
|
+
res.end(content || 'No notes file found.');
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// POST /api/notes-save — save edited notes.md content
|
|
1120
|
+
if (req.method === 'POST' && req.url === '/api/notes-save') {
|
|
1121
|
+
try {
|
|
1122
|
+
const body = await readBody(req);
|
|
1123
|
+
if (!body.content && body.content !== '') return jsonReply(res, 400, { error: 'content required' });
|
|
1124
|
+
const file = body.file || 'notes.md';
|
|
1125
|
+
// Only allow saving notes.md (prevent arbitrary file writes)
|
|
1126
|
+
if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
|
|
1127
|
+
safeWrite(path.join(MINIONS_DIR, file), body.content);
|
|
1128
|
+
return jsonReply(res, 200, { ok: true });
|
|
1129
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// GET /api/knowledge — list all knowledge base entries grouped by category
|
|
1133
|
+
if (req.method === 'GET' && req.url === '/api/knowledge') {
|
|
1134
|
+
const entries = getKnowledgeBaseEntries();
|
|
1135
|
+
const result = {};
|
|
1136
|
+
for (const cat of shared.KB_CATEGORIES) result[cat] = [];
|
|
1137
|
+
for (const e of entries) {
|
|
1138
|
+
if (!result[e.cat]) result[e.cat] = [];
|
|
1139
|
+
result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, size: e.size, preview: e.preview });
|
|
1140
|
+
}
|
|
1141
|
+
const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
|
|
1142
|
+
if (swept) result.lastSwept = swept.timestamp;
|
|
1143
|
+
return jsonReply(res, 200, result);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// GET /api/knowledge/:category/:file — read a specific knowledge base entry
|
|
1147
|
+
const kbMatch = req.url.match(/^\/api\/knowledge\/([^/]+)\/([^?]+)/);
|
|
1148
|
+
if (kbMatch && req.method === 'GET') {
|
|
1149
|
+
const cat = kbMatch[1];
|
|
1150
|
+
const file = decodeURIComponent(kbMatch[2]);
|
|
1151
|
+
// Prevent path traversal
|
|
1152
|
+
if (file.includes('..') || file.includes('/') || file.includes('\\')) {
|
|
1153
|
+
return jsonReply(res, 400, { error: 'invalid file name' });
|
|
1154
|
+
}
|
|
1155
|
+
const content = safeRead(path.join(MINIONS_DIR, 'knowledge', cat, file));
|
|
1156
|
+
if (content === null) return jsonReply(res, 404, { error: 'not found' });
|
|
1157
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1158
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1159
|
+
res.end(content);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// POST /api/knowledge/sweep — deduplicate, consolidate, and reorganize knowledge base
|
|
1164
|
+
if (req.method === 'POST' && req.url === '/api/knowledge/sweep') {
|
|
1165
|
+
if (global._kbSweepInFlight) return jsonReply(res, 409, { error: 'sweep already in progress' });
|
|
1166
|
+
global._kbSweepInFlight = true;
|
|
1167
|
+
try {
|
|
1168
|
+
const entries = getKnowledgeBaseEntries();
|
|
1169
|
+
if (entries.length < 2) return jsonReply(res, 200, { ok: true, summary: 'nothing to sweep (< 2 entries)' });
|
|
1170
|
+
|
|
1171
|
+
// Build a manifest of all KB entries with their content
|
|
1172
|
+
const manifest = [];
|
|
1173
|
+
for (const e of entries) {
|
|
1174
|
+
const content = safeRead(path.join(MINIONS_DIR, 'knowledge', e.cat, e.file));
|
|
1175
|
+
if (!content) continue;
|
|
1176
|
+
manifest.push({ category: e.cat, file: e.file, title: e.title, agent: e.agent, date: e.date, content: content.slice(0, 3000) });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const prompt = `You are a knowledge base curator. Analyze these ${manifest.length} knowledge base entries and produce a cleanup plan.
|
|
1180
|
+
|
|
1181
|
+
## Entries
|
|
1182
|
+
|
|
1183
|
+
${manifest.map((m, i) => `<entry index="${i}" category="${m.category}" file="${m.file}" date="${m.date}" agent="${m.agent || 'unknown'}">
|
|
1184
|
+
${m.title}
|
|
1185
|
+
${m.content.slice(0, 1500)}
|
|
1186
|
+
</entry>`).join('\n\n')}
|
|
1187
|
+
|
|
1188
|
+
## Instructions
|
|
1189
|
+
|
|
1190
|
+
1. **Find duplicates**: entries with substantially the same content or insights (same findings from different agents or dispatch runs). List pairs/groups by index. When choosing which to keep, prefer the more recent entry (later date) as it likely reflects the current state of the codebase.
|
|
1191
|
+
|
|
1192
|
+
2. **Find misclassified**: entries in the wrong category. Common: build reports in conventions, reviews in architecture.
|
|
1193
|
+
|
|
1194
|
+
3. **Find stale/empty**: entries with no actionable content (boilerplate, "no changes needed", bail-out notes).
|
|
1195
|
+
|
|
1196
|
+
## Output Format
|
|
1197
|
+
|
|
1198
|
+
Respond with ONLY valid JSON (no markdown fences, no preamble):
|
|
1199
|
+
|
|
1200
|
+
{
|
|
1201
|
+
"duplicates": [
|
|
1202
|
+
{ "keep": 0, "remove": [1, 5], "reason": "same PR review findings" }
|
|
1203
|
+
],
|
|
1204
|
+
"reclassify": [
|
|
1205
|
+
{ "index": 3, "from": "conventions", "to": "build-reports", "reason": "..." }
|
|
1206
|
+
],
|
|
1207
|
+
"remove": [
|
|
1208
|
+
{ "index": 7, "reason": "empty bail-out note" }
|
|
1209
|
+
]
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
1213
|
+
|
|
1214
|
+
const { callLLM, trackEngineUsage } = require('./engine/llm');
|
|
1215
|
+
const result = await callLLM(prompt, 'You are a concise knowledge curator. Output only JSON.', {
|
|
1216
|
+
timeout: 180000, label: 'kb-sweep', model: 'haiku', maxTurns: 1
|
|
1217
|
+
});
|
|
1218
|
+
trackEngineUsage('kb-sweep', result.usage);
|
|
1219
|
+
|
|
1220
|
+
let plan;
|
|
1221
|
+
try {
|
|
1222
|
+
let jsonStr = result.text.trim();
|
|
1223
|
+
const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1224
|
+
if (fenceMatch) jsonStr = fenceMatch[1].trim();
|
|
1225
|
+
plan = JSON.parse(jsonStr);
|
|
1226
|
+
} catch {
|
|
1227
|
+
return jsonReply(res, 200, { ok: false, error: 'LLM returned invalid JSON', raw: result.text.slice(0, 500) });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
let removed = 0, reclassified = 0, merged = 0;
|
|
1231
|
+
const kbDir = path.join(MINIONS_DIR, 'knowledge');
|
|
1232
|
+
|
|
1233
|
+
// If nothing to do, return early
|
|
1234
|
+
const totalActions = (plan.remove || []).length + (plan.duplicates || []).reduce((n, d) => n + (d.remove || []).length, 0) + (plan.reclassify || []).length;
|
|
1235
|
+
if (totalActions === 0) {
|
|
1236
|
+
return jsonReply(res, 200, { ok: true, summary: 'KB is clean — nothing to sweep', plan });
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Archive dir for swept files (never delete, always preserve)
|
|
1240
|
+
const kbArchiveDir = path.join(kbDir, '_swept');
|
|
1241
|
+
if (!fs.existsSync(kbArchiveDir)) fs.mkdirSync(kbArchiveDir, { recursive: true });
|
|
1242
|
+
|
|
1243
|
+
function archiveKbFile(filePath, reason) {
|
|
1244
|
+
if (!fs.existsSync(filePath)) return;
|
|
1245
|
+
const basename = path.basename(filePath);
|
|
1246
|
+
const destPath = shared.uniquePath(path.join(kbArchiveDir, basename));
|
|
1247
|
+
try {
|
|
1248
|
+
const content = safeRead(filePath);
|
|
1249
|
+
if (content === null) return; // don't delete if we can't read
|
|
1250
|
+
const meta = `<!-- swept: ${new Date().toISOString()} | reason: ${reason} -->\n`;
|
|
1251
|
+
safeWrite(destPath, meta + content);
|
|
1252
|
+
safeUnlink(filePath);
|
|
1253
|
+
} catch {}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Process removals (stale/empty) — archive, not delete
|
|
1257
|
+
for (const r of (plan.remove || [])) {
|
|
1258
|
+
const entry = manifest[r.index];
|
|
1259
|
+
if (!entry) continue;
|
|
1260
|
+
const fp = path.join(kbDir, entry.category, entry.file);
|
|
1261
|
+
archiveKbFile(fp, 'stale: ' + (r.reason || ''));
|
|
1262
|
+
removed++;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Process duplicates — archive the duplicates, keep the primary
|
|
1266
|
+
for (const d of (plan.duplicates || [])) {
|
|
1267
|
+
for (const idx of (d.remove || [])) {
|
|
1268
|
+
const entry = manifest[idx];
|
|
1269
|
+
if (!entry) continue;
|
|
1270
|
+
const fp = path.join(kbDir, entry.category, entry.file);
|
|
1271
|
+
archiveKbFile(fp, 'duplicate of index ' + d.keep + ': ' + (d.reason || ''));
|
|
1272
|
+
merged++;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Process reclassifications (move between categories)
|
|
1277
|
+
for (const r of (plan.reclassify || [])) {
|
|
1278
|
+
const entry = manifest[r.index];
|
|
1279
|
+
if (!entry || !shared.KB_CATEGORIES.includes(r.to)) continue;
|
|
1280
|
+
const srcPath = path.join(kbDir, entry.category, entry.file);
|
|
1281
|
+
const destDir = path.join(kbDir, r.to);
|
|
1282
|
+
if (!fs.existsSync(srcPath)) continue;
|
|
1283
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
1284
|
+
try {
|
|
1285
|
+
const content = safeRead(srcPath);
|
|
1286
|
+
const updated = content.replace(/^(category:\s*).+$/m, `$1${r.to}`);
|
|
1287
|
+
safeWrite(path.join(destDir, entry.file), updated);
|
|
1288
|
+
safeUnlink(srcPath);
|
|
1289
|
+
reclassified++;
|
|
1290
|
+
} catch {}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Prune swept files older than 30 days
|
|
1294
|
+
let pruned = 0;
|
|
1295
|
+
const SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1296
|
+
try {
|
|
1297
|
+
for (const f of fs.readdirSync(kbArchiveDir)) {
|
|
1298
|
+
const fp = path.join(kbArchiveDir, f);
|
|
1299
|
+
try {
|
|
1300
|
+
if (Date.now() - fs.statSync(fp).mtimeMs > SWEPT_RETENTION_MS) { safeUnlink(fp); pruned++; }
|
|
1301
|
+
} catch {}
|
|
1302
|
+
}
|
|
1303
|
+
} catch {}
|
|
1304
|
+
|
|
1305
|
+
const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
|
|
1306
|
+
safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
|
|
1307
|
+
return jsonReply(res, 200, { ok: true, summary, plan });
|
|
1308
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); } finally { global._kbSweepInFlight = false; }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// GET /api/plans — list plan files (.md drafts from plans/ + .json PRDs from prd/)
|
|
1312
|
+
if (req.method === 'GET' && req.url === '/api/plans') {
|
|
1313
|
+
const dirs = [
|
|
1314
|
+
{ dir: PLANS_DIR, archived: false },
|
|
1315
|
+
{ dir: path.join(PLANS_DIR, 'archive'), archived: true },
|
|
1316
|
+
{ dir: PRD_DIR, archived: false },
|
|
1317
|
+
{ dir: path.join(PRD_DIR, 'archive'), archived: true },
|
|
1318
|
+
];
|
|
1319
|
+
// Load work items to check for completed plan-to-prd conversions
|
|
1320
|
+
const centralWi = JSON.parse(safeRead(path.join(MINIONS_DIR, 'work-items.json')) || '[]');
|
|
1321
|
+
const completedPrdFiles = new Set(
|
|
1322
|
+
centralWi.filter(w => w.type === 'plan-to-prd' && w.status === 'done' && w.planFile)
|
|
1323
|
+
.map(w => w.planFile)
|
|
1324
|
+
);
|
|
1325
|
+
const plans = [];
|
|
1326
|
+
for (const { dir, archived } of dirs) {
|
|
1327
|
+
const allFiles = safeReadDir(dir).filter(f => f.endsWith('.json') || f.endsWith('.md'));
|
|
1328
|
+
for (const f of allFiles) {
|
|
1329
|
+
const filePath = path.join(dir, f);
|
|
1330
|
+
const content = safeRead(filePath) || '';
|
|
1331
|
+
let updatedAt = '';
|
|
1332
|
+
try { updatedAt = new Date(fs.statSync(filePath).mtimeMs).toISOString(); } catch {}
|
|
1333
|
+
const isJson = f.endsWith('.json');
|
|
1334
|
+
if (isJson) {
|
|
1335
|
+
try {
|
|
1336
|
+
const plan = JSON.parse(content);
|
|
1337
|
+
const status = plan.status || 'active';
|
|
1338
|
+
plans.push({
|
|
1339
|
+
file: f, format: 'prd', archived,
|
|
1340
|
+
project: plan.project || '',
|
|
1341
|
+
summary: plan.plan_summary || '',
|
|
1342
|
+
status,
|
|
1343
|
+
branchStrategy: plan.branch_strategy || 'parallel',
|
|
1344
|
+
featureBranch: plan.feature_branch || '',
|
|
1345
|
+
itemCount: (plan.missing_features || []).length,
|
|
1346
|
+
generatedBy: plan.generated_by || '',
|
|
1347
|
+
generatedAt: plan.generated_at || '',
|
|
1348
|
+
completedAt: plan.completedAt || '',
|
|
1349
|
+
updatedAt,
|
|
1350
|
+
requiresApproval: plan.requires_approval || false,
|
|
1351
|
+
revisionFeedback: plan.revision_feedback || null,
|
|
1352
|
+
sourcePlan: plan.source_plan || null,
|
|
1353
|
+
});
|
|
1354
|
+
} catch {}
|
|
1355
|
+
} else {
|
|
1356
|
+
const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
|
|
1357
|
+
const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
|
|
1358
|
+
const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
|
|
1359
|
+
const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
|
|
1360
|
+
const versionMatch = f.match(/-v(\d+)/);
|
|
1361
|
+
plans.push({
|
|
1362
|
+
file: f, format: 'draft', archived,
|
|
1363
|
+
project: projectMatch ? projectMatch[1].trim() : '',
|
|
1364
|
+
summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
|
|
1365
|
+
status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
|
|
1366
|
+
branchStrategy: '',
|
|
1367
|
+
featureBranch: '',
|
|
1368
|
+
itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
|
|
1369
|
+
generatedBy: authorMatch ? authorMatch[1].trim() : '',
|
|
1370
|
+
generatedAt: dateMatch ? dateMatch[1].trim() : '',
|
|
1371
|
+
updatedAt,
|
|
1372
|
+
requiresApproval: false,
|
|
1373
|
+
revisionFeedback: null,
|
|
1374
|
+
version: versionMatch ? parseInt(versionMatch[1]) : null,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
|
|
1380
|
+
return jsonReply(res, 200, plans);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// GET /api/plans/archive/:file — read archived plan (checks prd/archive/ and plans/archive/)
|
|
1384
|
+
const archiveFileMatch = req.url.match(/^\/api\/plans\/archive\/([^?]+)$/);
|
|
1385
|
+
if (archiveFileMatch && req.method === 'GET') {
|
|
1386
|
+
const file = decodeURIComponent(archiveFileMatch[1]);
|
|
1387
|
+
if (file.includes('..')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1388
|
+
// Check prd/archive/ first for .json, then plans/archive/ for .md
|
|
1389
|
+
const archiveDir = file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
|
|
1390
|
+
let content = safeRead(path.join(archiveDir, file));
|
|
1391
|
+
// Fallback: check the other archive dir
|
|
1392
|
+
if (!content) content = safeRead(path.join(file.endsWith('.json') ? path.join(PLANS_DIR, 'archive') : path.join(PRD_DIR, 'archive'), file));
|
|
1393
|
+
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
1394
|
+
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
1395
|
+
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
1396
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1397
|
+
res.end(content);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// GET /api/plans/:file — read full plan (JSON from prd/ or markdown from plans/)
|
|
1402
|
+
const planFileMatch = req.url.match(/^\/api\/plans\/([^?]+)$/);
|
|
1403
|
+
if (planFileMatch && req.method === 'GET') {
|
|
1404
|
+
const file = decodeURIComponent(planFileMatch[1]);
|
|
1405
|
+
if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1406
|
+
let content = safeRead(resolvePlanPath(file));
|
|
1407
|
+
// Fallback: check all directories (prd/, plans/, guides/, archives)
|
|
1408
|
+
if (!content) content = safeRead(path.join(PRD_DIR, file));
|
|
1409
|
+
if (!content) content = safeRead(path.join(PRD_DIR, 'guides', file));
|
|
1410
|
+
if (!content) content = safeRead(path.join(PLANS_DIR, file));
|
|
1411
|
+
if (!content) content = safeRead(path.join(PRD_DIR, 'archive', file));
|
|
1412
|
+
if (!content) content = safeRead(path.join(PLANS_DIR, 'archive', file));
|
|
1413
|
+
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
1414
|
+
// Find the actual file path for Last-Modified header + expose resolved relative path
|
|
1415
|
+
const planCandidates = [resolvePlanPath(file), path.join(PRD_DIR, file), path.join(PRD_DIR, 'guides', file), path.join(PLANS_DIR, file), path.join(PRD_DIR, 'archive', file), path.join(PLANS_DIR, 'archive', file)];
|
|
1416
|
+
for (const p of planCandidates) { try { const st = fs.statSync(p); if (st) { res.setHeader('Last-Modified', st.mtime.toISOString()); res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, p).replace(/\\/g, '/')); break; } } catch {} }
|
|
1417
|
+
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
1418
|
+
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
1419
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1420
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1421
|
+
res.end(content);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// POST /api/plans/approve — approve a plan for execution
|
|
1426
|
+
if (req.method === 'POST' && req.url === '/api/plans/approve') {
|
|
1427
|
+
try {
|
|
1428
|
+
const body = await readBody(req);
|
|
1429
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1430
|
+
const planPath = resolvePlanPath(body.file);
|
|
1431
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
1432
|
+
plan.status = 'approved';
|
|
1433
|
+
plan.approvedAt = new Date().toISOString();
|
|
1434
|
+
plan.approvedBy = body.approvedBy || os.userInfo().username;
|
|
1435
|
+
delete plan.pausedAt;
|
|
1436
|
+
safeWrite(planPath, plan);
|
|
1437
|
+
|
|
1438
|
+
// Resume paused work items across all projects
|
|
1439
|
+
let resumed = 0;
|
|
1440
|
+
const resumedItemIds = [];
|
|
1441
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1442
|
+
for (const proj of PROJECTS) {
|
|
1443
|
+
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
1444
|
+
}
|
|
1445
|
+
for (const wiPath of wiPaths) {
|
|
1446
|
+
try {
|
|
1447
|
+
const items = safeJson(wiPath);
|
|
1448
|
+
if (!items) continue;
|
|
1449
|
+
let changed = false;
|
|
1450
|
+
for (const w of items) {
|
|
1451
|
+
if (w.sourcePlan === body.file && w.status === 'paused' && w._pausedBy === 'prd-pause') {
|
|
1452
|
+
w.status = 'pending';
|
|
1453
|
+
delete w._pausedBy;
|
|
1454
|
+
w._resumedAt = new Date().toISOString();
|
|
1455
|
+
resumedItemIds.push(w.id);
|
|
1456
|
+
resumed++;
|
|
1457
|
+
changed = true;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (changed) safeWrite(wiPath, items);
|
|
1461
|
+
} catch {}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Clear dispatch completed entries for resumed items so they aren't dedup-blocked
|
|
1465
|
+
if (resumedItemIds.length > 0) {
|
|
1466
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
1467
|
+
const resumedSet = new Set(resumedItemIds);
|
|
1468
|
+
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
1469
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
1470
|
+
dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
|
|
1471
|
+
return dispatch;
|
|
1472
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
invalidateStatusCache();
|
|
1476
|
+
return jsonReply(res, 200, { ok: true, status: 'approved', resumedWorkItems: resumed });
|
|
1477
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// POST /api/plans/pause — pause a plan (stops new item materialization + pauses work items)
|
|
1481
|
+
if (req.method === 'POST' && req.url === '/api/plans/pause') {
|
|
1482
|
+
try {
|
|
1483
|
+
const body = await readBody(req);
|
|
1484
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1485
|
+
const planPath = resolvePlanPath(body.file);
|
|
1486
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
1487
|
+
plan.status = 'paused';
|
|
1488
|
+
plan.pausedAt = new Date().toISOString();
|
|
1489
|
+
safeWrite(planPath, plan);
|
|
1490
|
+
|
|
1491
|
+
// Propagate pause to materialized work items across all projects
|
|
1492
|
+
// But skip items that already have active PRs — those are past the point of pausing
|
|
1493
|
+
let paused = 0;
|
|
1494
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1495
|
+
const allPrItemIds = new Set();
|
|
1496
|
+
for (const proj of PROJECTS) {
|
|
1497
|
+
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
1498
|
+
try {
|
|
1499
|
+
const prs = safeJson(shared.projectPrPath(proj)) || [];
|
|
1500
|
+
for (const pr of prs) {
|
|
1501
|
+
if (pr.status === 'active' && pr.prdItems?.length) {
|
|
1502
|
+
pr.prdItems.forEach(id => allPrItemIds.add(id));
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
} catch {}
|
|
1506
|
+
}
|
|
1507
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
1508
|
+
const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
|
|
1509
|
+
const killedAgents = [];
|
|
1510
|
+
|
|
1511
|
+
for (const wiPath of wiPaths) {
|
|
1512
|
+
try {
|
|
1513
|
+
const items = safeJson(wiPath);
|
|
1514
|
+
if (!items) continue;
|
|
1515
|
+
let changed = false;
|
|
1516
|
+
for (const w of items) {
|
|
1517
|
+
if (w.sourcePlan !== body.file) continue;
|
|
1518
|
+
// Don't pause if this item already has an active PR
|
|
1519
|
+
if (allPrItemIds.has(w.id) || allPrItemIds.has(w.sourcePlanItem)) continue;
|
|
1520
|
+
|
|
1521
|
+
if (w.status === 'pending') {
|
|
1522
|
+
w.status = 'paused';
|
|
1523
|
+
w._pausedBy = 'prd-pause';
|
|
1524
|
+
paused++;
|
|
1525
|
+
changed = true;
|
|
1526
|
+
} else if (w.status === 'dispatched') {
|
|
1527
|
+
// Kill the agent working on this item
|
|
1528
|
+
const activeEntry = (dispatch.active || []).find(d => d.meta?.dispatchKey?.includes(w.id));
|
|
1529
|
+
if (activeEntry) {
|
|
1530
|
+
const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
|
|
1531
|
+
try {
|
|
1532
|
+
const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
|
|
1533
|
+
if (agentStatus.pid) {
|
|
1534
|
+
if (process.platform === 'win32') {
|
|
1535
|
+
try { require('child_process').execSync('taskkill /PID ' + agentStatus.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch {}
|
|
1536
|
+
} else {
|
|
1537
|
+
try { process.kill(agentStatus.pid, 'SIGTERM'); } catch {}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
agentStatus.status = 'idle';
|
|
1541
|
+
delete agentStatus.currentTask;
|
|
1542
|
+
delete agentStatus.dispatched;
|
|
1543
|
+
safeWrite(statusPath, agentStatus);
|
|
1544
|
+
} catch {}
|
|
1545
|
+
killedAgents.push(activeEntry.agent);
|
|
1546
|
+
}
|
|
1547
|
+
w.status = 'paused';
|
|
1548
|
+
w._pausedBy = 'prd-pause';
|
|
1549
|
+
paused++;
|
|
1550
|
+
changed = true;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (changed) safeWrite(wiPath, items);
|
|
1554
|
+
} catch {}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Remove killed agents from dispatch active
|
|
1558
|
+
if (killedAgents.length > 0) {
|
|
1559
|
+
const killedSet = new Set(killedAgents);
|
|
1560
|
+
mutateJsonFileLocked(dispatchPath, (dp) => {
|
|
1561
|
+
dp.active = Array.isArray(dp.active) ? dp.active : [];
|
|
1562
|
+
dp.active = dp.active.filter(d => !killedSet.has(d.agent));
|
|
1563
|
+
return dp;
|
|
1564
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
invalidateStatusCache();
|
|
1568
|
+
return jsonReply(res, 200, { ok: true, status: 'paused', pausedWorkItems: paused });
|
|
1569
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// POST /api/prd/regenerate — regenerate PRD from revised source plan
|
|
1573
|
+
if (req.method === 'POST' && req.url === '/api/prd/regenerate') {
|
|
1574
|
+
try {
|
|
1575
|
+
const body = await readBody(req);
|
|
1576
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file is required' });
|
|
1577
|
+
if (body.file.includes('..')) return jsonReply(res, 400, { error: 'invalid file path' });
|
|
1578
|
+
|
|
1579
|
+
const prdPath = path.join(PRD_DIR, body.file);
|
|
1580
|
+
const plan = safeJson(prdPath);
|
|
1581
|
+
if (!plan) return jsonReply(res, 404, { error: 'PRD file not found' });
|
|
1582
|
+
if (!plan.source_plan) return jsonReply(res, 400, { error: 'PRD has no source_plan — cannot regenerate' });
|
|
1583
|
+
|
|
1584
|
+
const sourcePlanPath = path.join(PLANS_DIR, plan.source_plan);
|
|
1585
|
+
if (!fs.existsSync(sourcePlanPath)) return jsonReply(res, 400, { error: `Source plan not found: ${plan.source_plan}` });
|
|
1586
|
+
|
|
1587
|
+
// Collect completed item IDs from the old PRD to carry over
|
|
1588
|
+
const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
|
|
1589
|
+
const completedItems = (plan.missing_features || [])
|
|
1590
|
+
.filter(f => completedStatuses.has(f.status))
|
|
1591
|
+
.map(f => ({ id: f.id, name: f.name, status: f.status }));
|
|
1592
|
+
|
|
1593
|
+
// Clean pending/failed work items from old PRD (keep done items)
|
|
1594
|
+
const { getProjects, projectWorkItemsPath } = shared;
|
|
1595
|
+
const config = queries.getConfig();
|
|
1596
|
+
for (const p of getProjects(config)) {
|
|
1597
|
+
const projWiPath = projectWorkItemsPath(p);
|
|
1598
|
+
const projItems = safeJson(projWiPath);
|
|
1599
|
+
if (!projItems) continue;
|
|
1600
|
+
const filtered = projItems.filter(w => {
|
|
1601
|
+
if (w.sourcePlan !== body.file) return true; // different plan, keep
|
|
1602
|
+
return completedStatuses.has(w.status); // keep completed, remove pending/failed
|
|
1603
|
+
});
|
|
1604
|
+
if (filtered.length < projItems.length) safeWrite(projWiPath, filtered);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Delete old PRD — agent will write replacement at same path
|
|
1608
|
+
try { fs.unlinkSync(prdPath); } catch {}
|
|
1609
|
+
|
|
1610
|
+
// Queue plan-to-prd regeneration with instructions to preserve completed items
|
|
1611
|
+
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1612
|
+
let items = [];
|
|
1613
|
+
const existing = safeRead(wiPath);
|
|
1614
|
+
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
1615
|
+
|
|
1616
|
+
// Dedup: check if already queued
|
|
1617
|
+
const alreadyQueued = items.find(w =>
|
|
1618
|
+
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
|
|
1619
|
+
);
|
|
1620
|
+
if (alreadyQueued) return jsonReply(res, 200, { id: alreadyQueued.id, alreadyQueued: true });
|
|
1621
|
+
|
|
1622
|
+
const completedContext = completedItems.length > 0
|
|
1623
|
+
? `\n\n**Previously completed items (preserve their status in the new PRD):**\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
|
|
1624
|
+
: '';
|
|
1625
|
+
|
|
1626
|
+
const id = 'W-' + shared.uid();
|
|
1627
|
+
items.push({
|
|
1628
|
+
id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
|
|
1629
|
+
type: 'plan-to-prd', priority: 'high',
|
|
1630
|
+
description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
|
|
1631
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
|
|
1632
|
+
project: plan.project || '', planFile: plan.source_plan,
|
|
1633
|
+
_targetPrdFile: body.file,
|
|
1634
|
+
});
|
|
1635
|
+
safeWrite(wiPath, items);
|
|
1636
|
+
return jsonReply(res, 200, { id, file: plan.source_plan });
|
|
1637
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// POST /api/plans/execute — queue plan-to-prd conversion for a .md plan
|
|
1641
|
+
if (req.method === 'POST' && req.url === '/api/plans/execute') {
|
|
1642
|
+
try {
|
|
1643
|
+
const body = await readBody(req);
|
|
1644
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1645
|
+
if (!body.file.endsWith('.md')) return jsonReply(res, 400, { error: 'only .md plans can be executed' });
|
|
1646
|
+
const planPath = path.join(MINIONS_DIR, 'plans', body.file);
|
|
1647
|
+
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
1648
|
+
|
|
1649
|
+
// Check if already queued
|
|
1650
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1651
|
+
const items = JSON.parse(safeRead(centralPath) || '[]');
|
|
1652
|
+
const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status === 'pending' || w.status === 'dispatched'));
|
|
1653
|
+
if (existing) return jsonReply(res, 200, { ok: true, id: existing.id, alreadyQueued: true });
|
|
1654
|
+
|
|
1655
|
+
const id = 'W-' + shared.uid();
|
|
1656
|
+
items.push({
|
|
1657
|
+
id, title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
|
|
1658
|
+
type: 'plan-to-prd', priority: 'high',
|
|
1659
|
+
description: 'Plan file: plans/' + body.file,
|
|
1660
|
+
status: 'pending', created: new Date().toISOString(),
|
|
1661
|
+
createdBy: 'dashboard:execute', project: body.project || '',
|
|
1662
|
+
planFile: body.file,
|
|
1663
|
+
});
|
|
1664
|
+
safeWrite(centralPath, items);
|
|
1665
|
+
invalidateStatusCache();
|
|
1666
|
+
return jsonReply(res, 200, { ok: true, id });
|
|
1667
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// POST /api/plans/reject — reject a plan
|
|
1671
|
+
if (req.method === 'POST' && req.url === '/api/plans/reject') {
|
|
1672
|
+
try {
|
|
1673
|
+
const body = await readBody(req);
|
|
1674
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1675
|
+
const planPath = resolvePlanPath(body.file);
|
|
1676
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
1677
|
+
plan.status = 'rejected';
|
|
1678
|
+
plan.rejectedAt = new Date().toISOString();
|
|
1679
|
+
plan.rejectedBy = body.rejectedBy || os.userInfo().username;
|
|
1680
|
+
if (body.reason) plan.rejectionReason = body.reason;
|
|
1681
|
+
safeWrite(planPath, plan);
|
|
1682
|
+
return jsonReply(res, 200, { ok: true, status: 'rejected' });
|
|
1683
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// POST /api/plans/regenerate — reset pending/failed work items for a plan so they re-materialize
|
|
1687
|
+
if (req.method === 'POST' && req.url === '/api/plans/regenerate') {
|
|
1688
|
+
try {
|
|
1689
|
+
const body = await readBody(req);
|
|
1690
|
+
if (!body.source) return jsonReply(res, 400, { error: 'source required' });
|
|
1691
|
+
const planPath = resolvePlanPath(body.source);
|
|
1692
|
+
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
1693
|
+
const plan = safeJson(planPath);
|
|
1694
|
+
const planItems = plan.missing_features || [];
|
|
1695
|
+
|
|
1696
|
+
let reset = 0, kept = 0, newCount = 0;
|
|
1697
|
+
const deletedItemIds = [];
|
|
1698
|
+
|
|
1699
|
+
// Scan all work item sources for materialized items from this plan
|
|
1700
|
+
const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
|
|
1701
|
+
for (const proj of PROJECTS) {
|
|
1702
|
+
wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Track which plan items have materialized work items
|
|
1706
|
+
const materializedPlanItemIds = new Set();
|
|
1707
|
+
|
|
1708
|
+
for (const wiInfo of wiPaths) {
|
|
1709
|
+
try {
|
|
1710
|
+
const items = safeJson(wiInfo.path);
|
|
1711
|
+
const filtered = [];
|
|
1712
|
+
for (const w of items) {
|
|
1713
|
+
if (w.sourcePlan === body.source) {
|
|
1714
|
+
materializedPlanItemIds.add(w.id);
|
|
1715
|
+
if (w.status === 'pending' || w.status === 'failed') {
|
|
1716
|
+
// Delete — will re-materialize on next tick with updated plan data
|
|
1717
|
+
reset++;
|
|
1718
|
+
deletedItemIds.push(w.id);
|
|
1719
|
+
} else {
|
|
1720
|
+
// dispatched or done — leave alone
|
|
1721
|
+
kept++;
|
|
1722
|
+
filtered.push(w);
|
|
1723
|
+
}
|
|
1724
|
+
} else {
|
|
1725
|
+
filtered.push(w);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (filtered.length < items.length) {
|
|
1729
|
+
safeWrite(wiInfo.path, filtered);
|
|
1730
|
+
}
|
|
1731
|
+
} catch {}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Count plan items that have no work item yet (will auto-materialize)
|
|
1735
|
+
for (const pi of planItems) {
|
|
1736
|
+
if (!materializedPlanItemIds.has(pi.id)) newCount++;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Clean dispatch entries for deleted items
|
|
1740
|
+
for (const itemId of deletedItemIds) {
|
|
1741
|
+
cleanDispatchEntries(d =>
|
|
1742
|
+
d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === itemId
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
return jsonReply(res, 200, { ok: true, reset, kept, new: newCount });
|
|
1747
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// POST /api/plans/delete — delete a plan file
|
|
1751
|
+
if (req.method === 'POST' && req.url === '/api/plans/delete') {
|
|
1752
|
+
try {
|
|
1753
|
+
const body = await readBody(req);
|
|
1754
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1755
|
+
if (body.file.includes('..') || body.file.includes('/') || body.file.includes('\\')) {
|
|
1756
|
+
return jsonReply(res, 400, { error: 'invalid filename' });
|
|
1757
|
+
}
|
|
1758
|
+
const planPath = resolvePlanPath(body.file);
|
|
1759
|
+
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
1760
|
+
// Read PRD content before deleting to get source_plan for cleanup
|
|
1761
|
+
let prdSourcePlan = null;
|
|
1762
|
+
if (body.file.endsWith('.json')) {
|
|
1763
|
+
try { prdSourcePlan = JSON.parse(safeRead(planPath) || '{}').source_plan || null; } catch {}
|
|
1764
|
+
}
|
|
1765
|
+
safeUnlink(planPath);
|
|
1766
|
+
|
|
1767
|
+
// Clean up materialized work items from all projects + central
|
|
1768
|
+
let cleaned = 0;
|
|
1769
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1770
|
+
for (const proj of PROJECTS) {
|
|
1771
|
+
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
1772
|
+
}
|
|
1773
|
+
for (const wiPath of wiPaths) {
|
|
1774
|
+
try {
|
|
1775
|
+
const items = safeJson(wiPath);
|
|
1776
|
+
const filtered = items.filter(w => w.sourcePlan !== body.file);
|
|
1777
|
+
if (filtered.length < items.length) {
|
|
1778
|
+
cleaned += items.length - filtered.length;
|
|
1779
|
+
safeWrite(wiPath, filtered);
|
|
1780
|
+
}
|
|
1781
|
+
} catch {}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Clean up dispatch entries for this plan's items
|
|
1785
|
+
const dispatchCleaned = cleanDispatchEntries(d =>
|
|
1786
|
+
d.meta?.item?.sourcePlan === body.file ||
|
|
1787
|
+
d.meta?.planFile === body.file ||
|
|
1788
|
+
(d.task && d.task.includes(body.file))
|
|
1789
|
+
);
|
|
1790
|
+
|
|
1791
|
+
// If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
|
|
1792
|
+
if (prdSourcePlan) {
|
|
1793
|
+
try {
|
|
1794
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1795
|
+
const centralItems = safeJson(centralPath) || [];
|
|
1796
|
+
let changed = false;
|
|
1797
|
+
for (const w of centralItems) {
|
|
1798
|
+
if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
|
|
1799
|
+
w.status = 'cancelled';
|
|
1800
|
+
w._cancelledBy = 'prd-deleted';
|
|
1801
|
+
changed = true;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (changed) safeWrite(centralPath, centralItems);
|
|
1805
|
+
} catch {}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
invalidateStatusCache();
|
|
1809
|
+
return jsonReply(res, 200, { ok: true, cleanedWorkItems: cleaned, cleanedDispatches: dispatchCleaned });
|
|
1810
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// POST /api/plans/revise — request revision with feedback, dispatches agent to revise
|
|
1814
|
+
if (req.method === 'POST' && req.url === '/api/plans/revise') {
|
|
1815
|
+
try {
|
|
1816
|
+
const body = await readBody(req);
|
|
1817
|
+
if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
|
|
1818
|
+
const planPath = resolvePlanPath(body.file);
|
|
1819
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
1820
|
+
plan.status = 'revision-requested';
|
|
1821
|
+
plan.revision_feedback = body.feedback;
|
|
1822
|
+
plan.revisionRequestedAt = new Date().toISOString();
|
|
1823
|
+
plan.revisionRequestedBy = body.requestedBy || os.userInfo().username;
|
|
1824
|
+
safeWrite(planPath, plan);
|
|
1825
|
+
|
|
1826
|
+
// Create a work item to revise the plan
|
|
1827
|
+
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1828
|
+
let items = [];
|
|
1829
|
+
const existing = safeRead(wiPath);
|
|
1830
|
+
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
1831
|
+
const id = 'W-' + shared.uid();
|
|
1832
|
+
items.push({
|
|
1833
|
+
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
1834
|
+
type: 'plan-to-prd', priority: 'high',
|
|
1835
|
+
description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
|
|
1836
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
|
|
1837
|
+
project: plan.project || '',
|
|
1838
|
+
planFile: body.file,
|
|
1839
|
+
});
|
|
1840
|
+
safeWrite(wiPath, items);
|
|
1841
|
+
return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
|
|
1842
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// POST /api/plans/revise-and-regenerate — REMOVED: plan versioning now handled by /api/doc-chat
|
|
1846
|
+
// The "Replace old PRD" flow uses qaReplacePrd (frontend) which calls /api/plans/pause + /api/plans/regenerate + planExecute
|
|
1847
|
+
if (false && req.method === 'POST' && req.url === '/api/plans/revise-and-regenerate') {
|
|
1848
|
+
try {
|
|
1849
|
+
const body = await readBody(req);
|
|
1850
|
+
if (!body.source || !body.instruction) return jsonReply(res, 400, { error: 'source and instruction required' });
|
|
1851
|
+
|
|
1852
|
+
// Find the source plan .md file for this PRD
|
|
1853
|
+
// Convention: PRD JSON references plan via plan_summary containing the work item ID,
|
|
1854
|
+
// or the .md file has a matching name prefix
|
|
1855
|
+
const prdPath = path.join(PRD_DIR, body.source);
|
|
1856
|
+
if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD file not found' });
|
|
1857
|
+
|
|
1858
|
+
// Look for corresponding .md plan file
|
|
1859
|
+
let sourcePlanFile = null;
|
|
1860
|
+
const planFiles = safeReadDir(PLANS_DIR).filter(f => f.endsWith('.md'));
|
|
1861
|
+
if (body.sourcePlan) {
|
|
1862
|
+
// Explicit source plan provided
|
|
1863
|
+
sourcePlanFile = body.sourcePlan;
|
|
1864
|
+
} else {
|
|
1865
|
+
// Heuristic: find .md plan by matching prefix or by reading PRD's generated_from field
|
|
1866
|
+
const prd = JSON.parse(safeRead(prdPath) || '{}');
|
|
1867
|
+
if (prd.source_plan) {
|
|
1868
|
+
sourcePlanFile = prd.source_plan;
|
|
1869
|
+
} else {
|
|
1870
|
+
// Match by prefix: officeagent-2026-03-15.json → plan-*officeagent* or plan-w025*.md
|
|
1871
|
+
const prdBase = body.source.replace('.json', '');
|
|
1872
|
+
for (const f of planFiles) {
|
|
1873
|
+
// Check if plan file mentions the same project or was created around same time
|
|
1874
|
+
const content = safeRead(path.join(PLANS_DIR, f)) || '';
|
|
1875
|
+
if (content.includes(prd.project || '___nomatch___') || content.includes(prd.plan_summary?.slice(0, 40) || '___nomatch___')) {
|
|
1876
|
+
sourcePlanFile = f;
|
|
1877
|
+
break;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
// Last resort: most recent .md plan
|
|
1881
|
+
if (!sourcePlanFile && planFiles.length > 0) {
|
|
1882
|
+
sourcePlanFile = planFiles.sort((a, b) => {
|
|
1883
|
+
try { return fs.statSync(path.join(PLANS_DIR, b)).mtimeMs - fs.statSync(path.join(PLANS_DIR, a)).mtimeMs; } catch { return 0; }
|
|
1884
|
+
})[0];
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (!sourcePlanFile) {
|
|
1890
|
+
return jsonReply(res, 404, { error: 'No source plan (.md) found for this PRD. You can edit the PRD JSON directly using "Edit Plan".' });
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const sourcePlanPath = path.join(PLANS_DIR, sourcePlanFile);
|
|
1894
|
+
const planContent = safeRead(sourcePlanPath);
|
|
1895
|
+
if (!planContent) return jsonReply(res, 404, { error: 'Source plan file not readable: ' + sourcePlanFile });
|
|
1896
|
+
|
|
1897
|
+
// Step 1: Steer the source plan with the user's instruction via CC
|
|
1898
|
+
const result = await ccDocCall({
|
|
1899
|
+
message: body.instruction,
|
|
1900
|
+
document: planContent,
|
|
1901
|
+
title: sourcePlanFile,
|
|
1902
|
+
filePath: 'plans/' + sourcePlanFile,
|
|
1903
|
+
selection: body.selection || '',
|
|
1904
|
+
canEdit: true,
|
|
1905
|
+
isJson: false,
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
if (!result.content) {
|
|
1909
|
+
return jsonReply(res, 200, { ok: true, answer: result.answer, updated: false });
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Save the revised plan
|
|
1913
|
+
safeWrite(sourcePlanPath, result.content);
|
|
1914
|
+
|
|
1915
|
+
// Step 2: Pause the old PRD so it stops materializing items
|
|
1916
|
+
const prd = JSON.parse(safeRead(prdPath) || '{}');
|
|
1917
|
+
prd.status = 'revision-requested';
|
|
1918
|
+
prd.revision_feedback = body.instruction;
|
|
1919
|
+
prd.revisionRequestedAt = new Date().toISOString();
|
|
1920
|
+
safeWrite(prdPath, prd);
|
|
1921
|
+
|
|
1922
|
+
// Step 3: Clean up pending/failed work items from old PRD
|
|
1923
|
+
let reset = 0, kept = 0;
|
|
1924
|
+
const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
|
|
1925
|
+
for (const proj of PROJECTS) {
|
|
1926
|
+
wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
|
|
1927
|
+
}
|
|
1928
|
+
const deletedItemIds = [];
|
|
1929
|
+
for (const wiInfo of wiPaths) {
|
|
1930
|
+
try {
|
|
1931
|
+
const items = safeJson(wiInfo.path);
|
|
1932
|
+
const filtered = [];
|
|
1933
|
+
for (const w of items) {
|
|
1934
|
+
if (w.sourcePlan === body.source) {
|
|
1935
|
+
if (w.status === 'pending' || w.status === 'failed') {
|
|
1936
|
+
reset++;
|
|
1937
|
+
deletedItemIds.push(w.id);
|
|
1938
|
+
} else {
|
|
1939
|
+
kept++;
|
|
1940
|
+
filtered.push(w);
|
|
1941
|
+
}
|
|
1942
|
+
} else {
|
|
1943
|
+
filtered.push(w);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (filtered.length < items.length) safeWrite(wiInfo.path, filtered);
|
|
1947
|
+
} catch {}
|
|
1948
|
+
}
|
|
1949
|
+
for (const itemId of deletedItemIds) {
|
|
1950
|
+
cleanDispatchEntries(d =>
|
|
1951
|
+
d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === itemId
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Step 4: Dispatch plan-to-prd to regenerate PRD from revised plan
|
|
1956
|
+
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1957
|
+
let centralItems = [];
|
|
1958
|
+
try { centralItems = JSON.parse(safeRead(centralWiPath) || '[]'); } catch {}
|
|
1959
|
+
const wiId = 'W-' + shared.uid();
|
|
1960
|
+
centralItems.push({
|
|
1961
|
+
id: wiId,
|
|
1962
|
+
title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
|
|
1963
|
+
type: 'plan-to-prd',
|
|
1964
|
+
priority: 'high',
|
|
1965
|
+
description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
|
|
1966
|
+
status: 'pending',
|
|
1967
|
+
created: new Date().toISOString(),
|
|
1968
|
+
createdBy: 'dashboard:revise-and-regenerate',
|
|
1969
|
+
project: prd.project || '',
|
|
1970
|
+
planFile: sourcePlanFile,
|
|
1971
|
+
});
|
|
1972
|
+
safeWrite(centralWiPath, centralItems);
|
|
1973
|
+
|
|
1974
|
+
return jsonReply(res, 200, {
|
|
1975
|
+
ok: true,
|
|
1976
|
+
answer: result.answer,
|
|
1977
|
+
updated: true,
|
|
1978
|
+
sourcePlan: sourcePlanFile,
|
|
1979
|
+
prdPaused: true,
|
|
1980
|
+
reset,
|
|
1981
|
+
kept,
|
|
1982
|
+
workItemId: wiId,
|
|
1983
|
+
});
|
|
1984
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// POST /api/plans/discuss — generate a plan discussion session script
|
|
1988
|
+
if (req.method === 'POST' && req.url === '/api/plans/discuss') {
|
|
1989
|
+
try {
|
|
1990
|
+
const body = await readBody(req);
|
|
1991
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1992
|
+
const planPath = resolvePlanPath(body.file);
|
|
1993
|
+
const planContent = safeRead(planPath);
|
|
1994
|
+
if (!planContent) return jsonReply(res, 404, { error: 'plan not found' });
|
|
1995
|
+
|
|
1996
|
+
const plan = JSON.parse(planContent);
|
|
1997
|
+
const projectName = plan.project || 'Unknown';
|
|
1998
|
+
|
|
1999
|
+
// Build the session launch script
|
|
2000
|
+
const sessionName = 'plan-review-' + body.file.replace(/\.json$/, '');
|
|
2001
|
+
const sysPrompt = `You are a Plan Advisor helping a human review and refine a feature plan before it gets dispatched to an agent minions.
|
|
2002
|
+
|
|
2003
|
+
## Your Role
|
|
2004
|
+
- Help the user understand, question, and refine the plan
|
|
2005
|
+
- Accept feedback and update the plan accordingly
|
|
2006
|
+
- When the user is satisfied, write the approved plan back to disk
|
|
2007
|
+
|
|
2008
|
+
## The Plan File
|
|
2009
|
+
Path: ${planPath}
|
|
2010
|
+
Project: ${projectName}
|
|
2011
|
+
|
|
2012
|
+
## How This Works
|
|
2013
|
+
1. The user will discuss the plan with you — answer questions, suggest changes
|
|
2014
|
+
2. When they want changes, update the plan items (add/remove/reorder/modify)
|
|
2015
|
+
3. When they say ANY of these (or similar intent):
|
|
2016
|
+
- "approve", "go", "ship it", "looks good", "lgtm"
|
|
2017
|
+
- "clear context and implement", "clear context and go"
|
|
2018
|
+
- "go build it", "start working", "dispatch", "execute"
|
|
2019
|
+
- "do it", "proceed", "let's go", "send it"
|
|
2020
|
+
|
|
2021
|
+
Then:
|
|
2022
|
+
a. Read the current plan file fresh from disk
|
|
2023
|
+
b. Update status to "approved", set approvedAt and approvedBy
|
|
2024
|
+
c. Write it back to ${planPath} using the Write tool
|
|
2025
|
+
d. Print exactly: "Plan approved and saved. The engine will dispatch work on the next tick. You can close this session."
|
|
2026
|
+
e. Then EXIT the session — use /exit or simply stop responding. The user does NOT need to interact further.
|
|
2027
|
+
|
|
2028
|
+
4. If they say "reject" or "cancel":
|
|
2029
|
+
- Update status to "rejected"
|
|
2030
|
+
- Write it back
|
|
2031
|
+
- Confirm and exit.
|
|
2032
|
+
|
|
2033
|
+
## Important
|
|
2034
|
+
- Always read the plan file fresh before writing (another process may have modified it)
|
|
2035
|
+
- Preserve all existing fields when writing back
|
|
2036
|
+
- Use the Write tool to save changes
|
|
2037
|
+
- You have full file access — you can also read the project codebase for context
|
|
2038
|
+
- When the user signals approval, ALWAYS write the file and exit. Do not ask for confirmation — their intent is clear.`;
|
|
2039
|
+
|
|
2040
|
+
const initialPrompt = `Here's the plan awaiting your review:
|
|
2041
|
+
|
|
2042
|
+
**${plan.plan_summary || body.file}**
|
|
2043
|
+
Project: ${projectName}
|
|
2044
|
+
Strategy: ${plan.branch_strategy || 'parallel'}
|
|
2045
|
+
Branch: ${plan.feature_branch || 'per-item'}
|
|
2046
|
+
Items: ${(plan.missing_features || []).length}
|
|
2047
|
+
|
|
2048
|
+
${(plan.missing_features || []).map((f, i) =>
|
|
2049
|
+
`${i + 1}. **${f.id}: ${f.name}** (${f.estimated_complexity}, ${f.priority})${f.depends_on?.length ? ' → depends on: ' + f.depends_on.join(', ') : ''}
|
|
2050
|
+
${f.description || ''}`
|
|
2051
|
+
).join('\n\n')}
|
|
2052
|
+
|
|
2053
|
+
${plan.open_questions?.length ? '\n**Open Questions:**\n' + plan.open_questions.map(q => '- ' + q).join('\n') : ''}
|
|
2054
|
+
|
|
2055
|
+
What would you like to discuss or change? When you're happy, say "approve" and I'll finalize it.`;
|
|
2056
|
+
|
|
2057
|
+
// Write session files
|
|
2058
|
+
const sessionDir = path.join(MINIONS_DIR, 'engine');
|
|
2059
|
+
const id = shared.uid();
|
|
2060
|
+
const sysFile = path.join(sessionDir, `plan-discuss-sys-${id}.md`);
|
|
2061
|
+
const promptFile = path.join(sessionDir, `plan-discuss-prompt-${id}.md`);
|
|
2062
|
+
safeWrite(sysFile, sysPrompt);
|
|
2063
|
+
safeWrite(promptFile, initialPrompt);
|
|
2064
|
+
|
|
2065
|
+
// Generate the launch command
|
|
2066
|
+
const cmd = `claude --system-prompt "$(cat '${sysFile.replace(/\\/g, '/')}')" --name "${sessionName}" --add-dir "${MINIONS_DIR.replace(/\\/g, '/')}" < "${promptFile.replace(/\\/g, '/')}"`;
|
|
2067
|
+
|
|
2068
|
+
// Also generate a PowerShell-friendly version
|
|
2069
|
+
const psCmd = `Get-Content "${promptFile}" | claude --system-prompt (Get-Content "${sysFile}" -Raw) --name "${sessionName}" --add-dir "${MINIONS_DIR}"`;
|
|
2070
|
+
|
|
2071
|
+
return jsonReply(res, 200, {
|
|
2072
|
+
ok: true,
|
|
2073
|
+
sessionName,
|
|
2074
|
+
command: cmd,
|
|
2075
|
+
psCommand: psCmd,
|
|
2076
|
+
sysFile,
|
|
2077
|
+
promptFile,
|
|
2078
|
+
planFile: body.file,
|
|
2079
|
+
});
|
|
2080
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// POST /api/doc-chat — routes through CC session for minions-aware doc Q&A + editing
|
|
2084
|
+
if (req.method === 'POST' && req.url === '/api/doc-chat') {
|
|
2085
|
+
try {
|
|
2086
|
+
const body = await readBody(req);
|
|
2087
|
+
if (!body.message) return jsonReply(res, 400, { error: 'message required' });
|
|
2088
|
+
if (!body.document) return jsonReply(res, 400, { error: 'document required' });
|
|
2089
|
+
|
|
2090
|
+
const canEdit = !!body.filePath;
|
|
2091
|
+
const isJson = body.filePath?.endsWith('.json');
|
|
2092
|
+
let currentContent = body.document;
|
|
2093
|
+
let fullPath = null;
|
|
2094
|
+
if (canEdit) {
|
|
2095
|
+
fullPath = path.resolve(MINIONS_DIR, body.filePath);
|
|
2096
|
+
if (!fullPath.startsWith(path.resolve(MINIONS_DIR))) return jsonReply(res, 400, { error: 'path must be under minions directory' });
|
|
2097
|
+
const diskContent = safeRead(fullPath);
|
|
2098
|
+
if (diskContent !== null) currentContent = diskContent;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
const { answer, content, actions } = await ccDocCall({
|
|
2102
|
+
message: body.message, document: currentContent, title: body.title,
|
|
2103
|
+
filePath: body.filePath, selection: body.selection, canEdit, isJson,
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
if (!content) return jsonReply(res, 200, { ok: true, answer, edited: false, actions });
|
|
2107
|
+
|
|
2108
|
+
if (isJson) {
|
|
2109
|
+
try { JSON.parse(content); } catch (e) {
|
|
2110
|
+
return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(JSON invalid — not saved: ' + e.message + ')', edited: false, actions });
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (canEdit && fullPath) {
|
|
2114
|
+
// Always save in-place — the engine's staleness detection handles PRD sync
|
|
2115
|
+
// if the source plan changes while an active PRD is running.
|
|
2116
|
+
safeWrite(fullPath, content);
|
|
2117
|
+
return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions });
|
|
2118
|
+
}
|
|
2119
|
+
return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
|
|
2120
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// POST /api/inbox/persist — promote an inbox item to team notes
|
|
2124
|
+
if (req.method === 'POST' && req.url === '/api/inbox/persist') {
|
|
2125
|
+
try {
|
|
2126
|
+
const body = await readBody(req);
|
|
2127
|
+
const { name } = body;
|
|
2128
|
+
if (!name) return jsonReply(res, 400, { error: 'name required' });
|
|
2129
|
+
|
|
2130
|
+
const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
|
|
2131
|
+
const content = safeRead(inboxPath);
|
|
2132
|
+
if (!content) return jsonReply(res, 404, { error: 'inbox item not found' });
|
|
2133
|
+
|
|
2134
|
+
// Extract a title from the first heading or first line
|
|
2135
|
+
const titleMatch = content.match(/^#+ (.+)$/m);
|
|
2136
|
+
const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
|
|
2137
|
+
|
|
2138
|
+
// Append to notes.md as a new team note
|
|
2139
|
+
const notesPath = path.join(MINIONS_DIR, 'notes.md');
|
|
2140
|
+
let notes = safeRead(notesPath) || '# Minions Notes\n\n## Active Notes\n';
|
|
2141
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2142
|
+
const entry = `\n### ${today}: ${title}\n**By:** Persisted from inbox (${name})\n**What:** ${content.slice(0, 500)}\n\n---\n`;
|
|
2143
|
+
|
|
2144
|
+
const marker = '## Active Notes';
|
|
2145
|
+
const idx = notes.indexOf(marker);
|
|
2146
|
+
if (idx !== -1) {
|
|
2147
|
+
const insertAt = idx + marker.length;
|
|
2148
|
+
notes = notes.slice(0, insertAt) + '\n' + entry + notes.slice(insertAt);
|
|
2149
|
+
} else {
|
|
2150
|
+
notes += '\n' + entry;
|
|
2151
|
+
}
|
|
2152
|
+
safeWrite(notesPath, notes);
|
|
2153
|
+
|
|
2154
|
+
// Move to archive
|
|
2155
|
+
const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
2156
|
+
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
2157
|
+
try { const _c = safeRead(inboxPath); safeWrite(path.join(archiveDir, `persisted-${name}`), _c); safeUnlink(inboxPath); } catch {}
|
|
2158
|
+
|
|
2159
|
+
return jsonReply(res, 200, { ok: true, title });
|
|
2160
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// POST /api/inbox/promote-kb — promote an inbox item to the knowledge base
|
|
2164
|
+
if (req.method === 'POST' && req.url === '/api/inbox/promote-kb') {
|
|
2165
|
+
try {
|
|
2166
|
+
const body = await readBody(req);
|
|
2167
|
+
const { name, category } = body;
|
|
2168
|
+
if (!name) return jsonReply(res, 400, { error: 'name required' });
|
|
2169
|
+
if (!category || !shared.KB_CATEGORIES.includes(category)) {
|
|
2170
|
+
return jsonReply(res, 400, { error: 'category required: ' + shared.KB_CATEGORIES.join(', ') });
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
|
|
2174
|
+
const content = safeRead(inboxPath);
|
|
2175
|
+
if (!content) return jsonReply(res, 404, { error: 'inbox item not found' });
|
|
2176
|
+
|
|
2177
|
+
// Add frontmatter if not present
|
|
2178
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2179
|
+
let kbContent = content;
|
|
2180
|
+
if (!content.startsWith('---')) {
|
|
2181
|
+
const titleMatch = content.match(/^#+ (.+)$/m);
|
|
2182
|
+
const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
|
|
2183
|
+
kbContent = `---\ntitle: ${title}\ncategory: ${category}\ndate: ${today}\nsource: inbox/${name}\n---\n\n${content}`;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Write to knowledge base
|
|
2187
|
+
const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
|
|
2188
|
+
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
|
|
2189
|
+
const kbFile = path.join(kbDir, name);
|
|
2190
|
+
safeWrite(kbFile, kbContent);
|
|
2191
|
+
|
|
2192
|
+
// Move inbox item to archive
|
|
2193
|
+
const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
2194
|
+
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
2195
|
+
try { const _c = safeRead(inboxPath); safeWrite(path.join(archiveDir, `kb-${category}-${name}`), _c); safeUnlink(inboxPath); } catch {}
|
|
2196
|
+
|
|
2197
|
+
return jsonReply(res, 200, { ok: true, category, file: name });
|
|
2198
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// POST /api/inbox/open — open inbox file in Windows explorer
|
|
2202
|
+
if (req.method === 'POST' && req.url === '/api/inbox/open') {
|
|
2203
|
+
try {
|
|
2204
|
+
const body = await readBody(req);
|
|
2205
|
+
const { name } = body;
|
|
2206
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
2207
|
+
return jsonReply(res, 400, { error: 'invalid name' });
|
|
2208
|
+
}
|
|
2209
|
+
const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
|
|
2210
|
+
if (!fs.existsSync(filePath)) return jsonReply(res, 404, { error: 'file not found' });
|
|
2211
|
+
|
|
2212
|
+
const { execFile } = require('child_process');
|
|
2213
|
+
try {
|
|
2214
|
+
if (process.platform === 'win32') {
|
|
2215
|
+
execFile('explorer', ['/select,', filePath.replace(/\//g, '\\')]);
|
|
2216
|
+
} else if (process.platform === 'darwin') {
|
|
2217
|
+
execFile('open', ['-R', filePath]);
|
|
2218
|
+
} else {
|
|
2219
|
+
execFile('xdg-open', [path.dirname(filePath)]);
|
|
2220
|
+
}
|
|
2221
|
+
} catch (e) {
|
|
2222
|
+
return jsonReply(res, 500, { error: 'Could not open file manager: ' + e.message });
|
|
2223
|
+
}
|
|
2224
|
+
return jsonReply(res, 200, { ok: true });
|
|
2225
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// POST /api/inbox/delete — delete an inbox note
|
|
2229
|
+
if (req.method === 'POST' && req.url === '/api/inbox/delete') {
|
|
2230
|
+
try {
|
|
2231
|
+
const body = await readBody(req);
|
|
2232
|
+
const { name } = body;
|
|
2233
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
2234
|
+
return jsonReply(res, 400, { error: 'invalid name' });
|
|
2235
|
+
}
|
|
2236
|
+
const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
|
|
2237
|
+
if (!fs.existsSync(filePath)) return jsonReply(res, 404, { error: 'file not found' });
|
|
2238
|
+
safeUnlink(filePath);
|
|
2239
|
+
return jsonReply(res, 200, { ok: true });
|
|
2240
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// GET /api/skill?file=<name>&source=claude-code|project:<name>&dir=<path>
|
|
2244
|
+
if (req.method === 'GET' && req.url.startsWith('/api/skill?')) {
|
|
2245
|
+
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
2246
|
+
const file = params.get('file');
|
|
2247
|
+
const dir = params.get('dir');
|
|
2248
|
+
if (!file || file.includes('..')) { res.statusCode = 400; res.end('Invalid file'); return; }
|
|
2249
|
+
|
|
2250
|
+
let content = '';
|
|
2251
|
+
if (dir) {
|
|
2252
|
+
// Direct path from collectSkillFiles
|
|
2253
|
+
const fullPath = path.join(dir.replace(/\//g, path.sep), file);
|
|
2254
|
+
if (!fullPath.includes('..')) content = safeRead(fullPath) || '';
|
|
2255
|
+
}
|
|
2256
|
+
if (!content) {
|
|
2257
|
+
// Fallback: search Claude Code skills, then project skills
|
|
2258
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
2259
|
+
const claudePath = path.join(home, '.claude', 'skills', file.replace('.md', '').replace('SKILL', ''), 'SKILL.md');
|
|
2260
|
+
content = safeRead(claudePath) || '';
|
|
2261
|
+
if (!content) {
|
|
2262
|
+
const source = params.get('source') || '';
|
|
2263
|
+
if (source.startsWith('project:')) {
|
|
2264
|
+
const proj = PROJECTS.find(p => p.name === source.replace('project:', ''));
|
|
2265
|
+
if (proj) content = safeRead(path.join(proj.localPath, '.claude', 'skills', file)) || '';
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
2270
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2271
|
+
res.end(content || 'Skill not found.');
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// POST /api/projects/browse — open folder picker dialog, return selected path
|
|
2276
|
+
if (req.method === 'POST' && req.url === '/api/projects/browse') {
|
|
2277
|
+
try {
|
|
2278
|
+
const { execSync } = require('child_process');
|
|
2279
|
+
let selectedPath = '';
|
|
2280
|
+
if (process.platform === 'win32') {
|
|
2281
|
+
// PowerShell folder browser dialog
|
|
2282
|
+
const ps = `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select project folder'; $f.ShowNewFolderButton = $false; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`;
|
|
2283
|
+
selectedPath = execSync(`powershell -NoProfile -Command "${ps}"`, { encoding: 'utf8', timeout: 120000, windowsHide: true }).trim();
|
|
2284
|
+
} else if (process.platform === 'darwin') {
|
|
2285
|
+
selectedPath = execSync(`osascript -e 'POSIX path of (choose folder with prompt "Select project folder")'`, { encoding: 'utf8', timeout: 120000 }).trim();
|
|
2286
|
+
} else {
|
|
2287
|
+
selectedPath = execSync(`zenity --file-selection --directory --title="Select project folder" 2>/dev/null`, { encoding: 'utf8', timeout: 120000 }).trim();
|
|
2288
|
+
}
|
|
2289
|
+
if (!selectedPath) return jsonReply(res, 200, { cancelled: true });
|
|
2290
|
+
return jsonReply(res, 200, { path: selectedPath.replace(/\\/g, '/') });
|
|
2291
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// POST /api/projects/add — auto-discover and add a project to config
|
|
2295
|
+
if (req.method === 'POST' && req.url === '/api/projects/add') {
|
|
2296
|
+
try {
|
|
2297
|
+
const body = await readBody(req);
|
|
2298
|
+
if (!body.path) return jsonReply(res, 400, { error: 'path required' });
|
|
2299
|
+
const target = path.resolve(body.path);
|
|
2300
|
+
if (!fs.existsSync(target)) return jsonReply(res, 400, { error: 'Directory not found: ' + target });
|
|
2301
|
+
|
|
2302
|
+
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
2303
|
+
const config = safeJson(configPath);
|
|
2304
|
+
if (!config.projects) config.projects = [];
|
|
2305
|
+
|
|
2306
|
+
// Check if already linked
|
|
2307
|
+
if (config.projects.find(p => path.resolve(p.localPath) === target)) {
|
|
2308
|
+
return jsonReply(res, 400, { error: 'Project already linked at ' + target });
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Auto-discover from git repo
|
|
2312
|
+
const { execSync: ex } = require('child_process');
|
|
2313
|
+
const detected = { name: path.basename(target), _found: [] };
|
|
2314
|
+
try {
|
|
2315
|
+
const head = ex('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
|
|
2316
|
+
detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
|
|
2317
|
+
} catch { detected.mainBranch = 'main'; }
|
|
2318
|
+
try {
|
|
2319
|
+
const remoteUrl = ex('git remote get-url origin', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
|
|
2320
|
+
if (remoteUrl.includes('github.com')) {
|
|
2321
|
+
detected.repoHost = 'github';
|
|
2322
|
+
const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
2323
|
+
if (m) { detected.org = m[1]; detected.repoName = m[2]; }
|
|
2324
|
+
} else if (remoteUrl.includes('visualstudio.com') || remoteUrl.includes('dev.azure.com')) {
|
|
2325
|
+
detected.repoHost = 'ado';
|
|
2326
|
+
const m = remoteUrl.match(/https:\/\/([^.]+)\.visualstudio\.com[^/]*\/([^/]+)\/_git\/([^/\s]+)/) ||
|
|
2327
|
+
remoteUrl.match(/https:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/);
|
|
2328
|
+
if (m) { detected.org = m[1]; detected.project = m[2]; detected.repoName = m[3]; }
|
|
2329
|
+
}
|
|
2330
|
+
} catch {}
|
|
2331
|
+
try {
|
|
2332
|
+
const pkgPath = path.join(target, 'package.json');
|
|
2333
|
+
if (fs.existsSync(pkgPath)) {
|
|
2334
|
+
const pkg = safeJson(pkgPath);
|
|
2335
|
+
if (pkg.name) detected.name = pkg.name.replace(/^@[^/]+\//, '');
|
|
2336
|
+
}
|
|
2337
|
+
} catch {}
|
|
2338
|
+
let description = '';
|
|
2339
|
+
try {
|
|
2340
|
+
const claudeMd = path.join(target, 'CLAUDE.md');
|
|
2341
|
+
if (fs.existsSync(claudeMd)) {
|
|
2342
|
+
const lines = (safeRead(claudeMd) || '').split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
|
2343
|
+
if (lines[0] && lines[0].length < 200) description = lines[0].trim();
|
|
2344
|
+
}
|
|
2345
|
+
} catch {}
|
|
2346
|
+
|
|
2347
|
+
const name = body.name || detected.name;
|
|
2348
|
+
const prUrlBase = detected.repoHost === 'github'
|
|
2349
|
+
? (detected.org && detected.repoName ? `https://github.com/${detected.org}/${detected.repoName}/pull/` : '')
|
|
2350
|
+
: (detected.org && detected.project && detected.repoName
|
|
2351
|
+
? `https://${detected.org}.visualstudio.com/DefaultCollection/${detected.project}/_git/${detected.repoName}/pullrequest/` : '');
|
|
2352
|
+
|
|
2353
|
+
const project = {
|
|
2354
|
+
name, description, localPath: target.replace(/\\/g, '/'),
|
|
2355
|
+
repoHost: detected.repoHost || 'ado', repositoryId: '',
|
|
2356
|
+
adoOrg: detected.org || '', adoProject: detected.project || '',
|
|
2357
|
+
repoName: detected.repoName || name, mainBranch: detected.mainBranch || 'main',
|
|
2358
|
+
prUrlBase,
|
|
2359
|
+
workSources: { pullRequests: { enabled: true, cooldownMinutes: 30 }, workItems: { enabled: true, cooldownMinutes: 0 } }
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
config.projects.push(project);
|
|
2363
|
+
safeWrite(configPath, config);
|
|
2364
|
+
reloadConfig(); // Update in-memory project list immediately
|
|
2365
|
+
|
|
2366
|
+
// Create project-local state files
|
|
2367
|
+
const minionsDir = path.join(target, '.minions');
|
|
2368
|
+
if (!fs.existsSync(minionsDir)) fs.mkdirSync(minionsDir, { recursive: true });
|
|
2369
|
+
const stateFiles = { 'pull-requests.json': '[]', 'work-items.json': '[]' };
|
|
2370
|
+
for (const [f, content] of Object.entries(stateFiles)) {
|
|
2371
|
+
const fp = path.join(minionsDir, f);
|
|
2372
|
+
if (!fs.existsSync(fp)) safeWrite(fp, content);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
return jsonReply(res, 200, { ok: true, name, path: target, detected });
|
|
2376
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// ── Command Center: persistent multi-turn session ──────────────────────────
|
|
2380
|
+
|
|
2381
|
+
// POST /api/command-center/new-session — clear active CC session
|
|
2382
|
+
if (req.method === 'POST' && req.url === '/api/command-center/new-session') {
|
|
2383
|
+
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
2384
|
+
ccInFlight = false; // Reset concurrency guard so a stuck request doesn't block new sessions
|
|
2385
|
+
safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
|
|
2386
|
+
return jsonReply(res, 200, { ok: true });
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// POST /api/command-center — conversational command center with full minions context
|
|
2390
|
+
if (req.method === 'POST' && req.url === '/api/command-center') {
|
|
2391
|
+
try {
|
|
2392
|
+
const body = await readBody(req);
|
|
2393
|
+
if (!body.message) return jsonReply(res, 400, { error: 'message required' });
|
|
2394
|
+
|
|
2395
|
+
// Concurrency guard — only one CC call at a time, with auto-release for stuck requests
|
|
2396
|
+
if (ccInFlight && (Date.now() - ccInFlightSince) < CC_INFLIGHT_TIMEOUT_MS) {
|
|
2397
|
+
return jsonReply(res, 429, { error: 'Command Center is busy — wait for the current request to finish.' });
|
|
2398
|
+
}
|
|
2399
|
+
if (ccInFlight) console.log('[CC] Auto-releasing stuck in-flight guard after timeout');
|
|
2400
|
+
ccInFlight = true;
|
|
2401
|
+
ccInFlightSince = Date.now();
|
|
2402
|
+
|
|
2403
|
+
try {
|
|
2404
|
+
if (body.sessionId && body.sessionId !== ccSession.sessionId) {
|
|
2405
|
+
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
2406
|
+
}
|
|
2407
|
+
const wasResume = !!(body.sessionId && body.sessionId === ccSession.sessionId && ccSessionValid());
|
|
2408
|
+
|
|
2409
|
+
const result = await ccCall(body.message, { store: 'cc' });
|
|
2410
|
+
|
|
2411
|
+
if (result.code !== 0 || !result.text) {
|
|
2412
|
+
const debugInfo = result.code !== 0 ? `(exit code ${result.code})` : '(empty response)';
|
|
2413
|
+
const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
|
|
2414
|
+
console.error(`[CC] LLM failed after retries ${debugInfo}: ${stderrTail}`);
|
|
2415
|
+
const hasSession = !!ccSession.sessionId;
|
|
2416
|
+
const retryHint = hasSession
|
|
2417
|
+
? 'Your session is still active — just send your message again to retry.'
|
|
2418
|
+
: 'Try clicking **New Session** and sending your message again.';
|
|
2419
|
+
return jsonReply(res, 200, {
|
|
2420
|
+
text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`,
|
|
2421
|
+
actions: [], sessionId: ccSession.sessionId
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
return jsonReply(res, 200, { ...parseCCActions(result.text), sessionId: ccSession.sessionId, newSession: !wasResume });
|
|
2426
|
+
} finally {
|
|
2427
|
+
ccInFlight = false;
|
|
2428
|
+
ccInFlightSince = 0;
|
|
2429
|
+
}
|
|
2430
|
+
} catch (e) { ccInFlight = false; return jsonReply(res, 500, { error: e.message }); }
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// POST /api/engine/restart — force-kill engine and restart immediately
|
|
2434
|
+
if (req.method === 'POST' && req.url === '/api/engine/restart') {
|
|
2435
|
+
try {
|
|
2436
|
+
const newPid = restartEngine();
|
|
2437
|
+
return jsonReply(res, 200, { ok: true, pid: newPid });
|
|
2438
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// GET /api/settings — return current engine + claude + routing config
|
|
2442
|
+
if (req.method === 'GET' && req.url === '/api/settings') {
|
|
2443
|
+
try {
|
|
2444
|
+
const config = queries.getConfig();
|
|
2445
|
+
const routing = safeRead(path.join(MINIONS_DIR, 'routing.md')) || '';
|
|
2446
|
+
return jsonReply(res, 200, {
|
|
2447
|
+
engine: config.engine || {},
|
|
2448
|
+
claude: config.claude || {},
|
|
2449
|
+
agents: config.agents || {},
|
|
2450
|
+
routing,
|
|
2451
|
+
});
|
|
2452
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// POST /api/settings — update engine + claude + agent config
|
|
2456
|
+
if (req.method === 'POST' && req.url === '/api/settings') {
|
|
2457
|
+
try {
|
|
2458
|
+
const body = await readBody(req);
|
|
2459
|
+
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
2460
|
+
const config = safeJson(configPath);
|
|
2461
|
+
|
|
2462
|
+
if (body.engine) {
|
|
2463
|
+
// Validate and apply engine settings
|
|
2464
|
+
const e = body.engine;
|
|
2465
|
+
const D = shared.ENGINE_DEFAULTS;
|
|
2466
|
+
if (e.tickInterval !== undefined) config.engine.tickInterval = Math.max(10000, Number(e.tickInterval) || D.tickInterval);
|
|
2467
|
+
if (e.maxConcurrent !== undefined) config.engine.maxConcurrent = Math.max(1, Math.min(10, Number(e.maxConcurrent) || D.maxConcurrent));
|
|
2468
|
+
if (e.inboxConsolidateThreshold !== undefined) config.engine.inboxConsolidateThreshold = Math.max(1, Number(e.inboxConsolidateThreshold) || D.inboxConsolidateThreshold);
|
|
2469
|
+
if (e.agentTimeout !== undefined) config.engine.agentTimeout = Math.max(60000, Number(e.agentTimeout) || D.agentTimeout);
|
|
2470
|
+
if (e.maxTurns !== undefined) config.engine.maxTurns = Math.max(5, Math.min(500, Number(e.maxTurns) || D.maxTurns));
|
|
2471
|
+
if (e.heartbeatTimeout !== undefined) config.engine.heartbeatTimeout = Math.max(60000, Number(e.heartbeatTimeout) || D.heartbeatTimeout);
|
|
2472
|
+
if (e.worktreeCreateTimeout !== undefined) config.engine.worktreeCreateTimeout = Math.max(60000, Number(e.worktreeCreateTimeout) || D.worktreeCreateTimeout);
|
|
2473
|
+
if (e.worktreeCreateRetries !== undefined) config.engine.worktreeCreateRetries = Math.max(0, Math.min(3, Number(e.worktreeCreateRetries) || D.worktreeCreateRetries));
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
if (body.claude) {
|
|
2477
|
+
if (body.claude.allowedTools !== undefined) config.claude.allowedTools = String(body.claude.allowedTools);
|
|
2478
|
+
if (body.claude.outputFormat !== undefined) config.claude.outputFormat = String(body.claude.outputFormat);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
if (body.agents) {
|
|
2482
|
+
for (const [id, updates] of Object.entries(body.agents)) {
|
|
2483
|
+
if (!config.agents[id]) continue;
|
|
2484
|
+
if (updates.role !== undefined) config.agents[id].role = String(updates.role);
|
|
2485
|
+
if (updates.skills !== undefined) config.agents[id].skills = Array.isArray(updates.skills) ? updates.skills : String(updates.skills).split(',').map(s => s.trim()).filter(Boolean);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
safeWrite(configPath, config);
|
|
2490
|
+
return jsonReply(res, 200, { ok: true, message: 'Settings saved. Restart engine for changes to take full effect.' });
|
|
2491
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// POST /api/settings/routing — update routing.md
|
|
2495
|
+
if (req.method === 'POST' && req.url === '/api/settings/routing') {
|
|
2496
|
+
try {
|
|
2497
|
+
const body = await readBody(req);
|
|
2498
|
+
if (!body.content) return jsonReply(res, 400, { error: 'content required' });
|
|
2499
|
+
safeWrite(path.join(MINIONS_DIR, 'routing.md'), body.content);
|
|
2500
|
+
return jsonReply(res, 200, { ok: true });
|
|
2501
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// GET /api/health — lightweight health check for monitoring
|
|
2505
|
+
if (req.method === 'GET' && req.url === '/api/health') {
|
|
2506
|
+
const engine = getEngineState();
|
|
2507
|
+
const agents = getAgents();
|
|
2508
|
+
const health = {
|
|
2509
|
+
status: engine.state === 'running' ? 'healthy' : engine.state === 'paused' ? 'degraded' : 'stopped',
|
|
2510
|
+
engine: { state: engine.state, pid: engine.pid },
|
|
2511
|
+
agents: agents.map(a => ({ id: a.id, name: a.name, status: a.status })),
|
|
2512
|
+
projects: PROJECTS.map(p => ({ name: p.name, reachable: fs.existsSync(p.localPath) })),
|
|
2513
|
+
uptime: process.uptime(),
|
|
2514
|
+
timestamp: new Date().toISOString()
|
|
2515
|
+
};
|
|
2516
|
+
return jsonReply(res, 200, health);
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const agentMatch = req.url.match(/^\/api\/agent\/([\w-]+)$/);
|
|
2520
|
+
if (agentMatch) {
|
|
2521
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2522
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2523
|
+
try {
|
|
2524
|
+
res.end(JSON.stringify(getAgentDetail(agentMatch[1])));
|
|
2525
|
+
} catch (e) {
|
|
2526
|
+
res.statusCode = 500;
|
|
2527
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2528
|
+
}
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
if (req.url === '/api/status') {
|
|
2533
|
+
try {
|
|
2534
|
+
return jsonReply(res, 200, getStatus(), req);
|
|
2535
|
+
} catch (e) {
|
|
2536
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// (duplicate /api/health removed — first handler above is the canonical one)
|
|
2541
|
+
|
|
2542
|
+
// Serve dashboard HTML with gzip + caching
|
|
2543
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
2544
|
+
res.setHeader('ETag', HTML_ETAG);
|
|
2545
|
+
res.setHeader('Cache-Control', 'no-cache'); // revalidate each time, but use 304 if unchanged
|
|
2546
|
+
if (req.headers['if-none-match'] === HTML_ETAG) {
|
|
2547
|
+
res.statusCode = 304;
|
|
2548
|
+
res.end();
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const ae = req.headers['accept-encoding'] || '';
|
|
2552
|
+
if (ae.includes('gzip')) {
|
|
2553
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
2554
|
+
res.end(HTML_GZ);
|
|
2555
|
+
} else {
|
|
2556
|
+
res.end(HTML);
|
|
2557
|
+
}
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
2561
|
+
console.log(`\n Minions Mission Control`);
|
|
2562
|
+
console.log(` -----------------------------------`);
|
|
2563
|
+
console.log(` http://localhost:${PORT}`);
|
|
2564
|
+
console.log(`\n Watching:`);
|
|
2565
|
+
console.log(` Minions dir: ${MINIONS_DIR}`);
|
|
2566
|
+
console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
|
|
2567
|
+
console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
|
|
2568
|
+
|
|
2569
|
+
const { exec } = require('child_process');
|
|
2570
|
+
try {
|
|
2571
|
+
if (process.platform === 'win32') {
|
|
2572
|
+
exec(`start "" "http://localhost:${PORT}"`);
|
|
2573
|
+
} else if (process.platform === 'darwin') {
|
|
2574
|
+
exec(`open http://localhost:${PORT}`);
|
|
2575
|
+
} else {
|
|
2576
|
+
exec(`xdg-open http://localhost:${PORT}`);
|
|
2577
|
+
}
|
|
2578
|
+
} catch (e) {
|
|
2579
|
+
console.log(` Could not auto-open browser: ${e.message}`);
|
|
2580
|
+
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// ─── Engine Watchdog ─────────────────────────────────────────────────────
|
|
2584
|
+
// Every 30s, check if engine PID is alive. If dead but control.json says
|
|
2585
|
+
// running, auto-restart it. Prevents silent engine death.
|
|
2586
|
+
const { execSync } = require('child_process');
|
|
2587
|
+
setInterval(() => {
|
|
2588
|
+
try {
|
|
2589
|
+
const control = getEngineState();
|
|
2590
|
+
if (control.state !== 'running' || !control.pid) return;
|
|
2591
|
+
|
|
2592
|
+
// Check if PID is alive
|
|
2593
|
+
let alive = false;
|
|
2594
|
+
try {
|
|
2595
|
+
if (process.platform === 'win32') {
|
|
2596
|
+
const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', timeout: 3000 });
|
|
2597
|
+
alive = out.includes(String(control.pid));
|
|
2598
|
+
} else {
|
|
2599
|
+
process.kill(control.pid, 0); // signal 0 = check existence
|
|
2600
|
+
alive = true;
|
|
2601
|
+
}
|
|
2602
|
+
} catch { alive = false; }
|
|
2603
|
+
|
|
2604
|
+
if (!alive) {
|
|
2605
|
+
console.log(`[watchdog] Engine PID ${control.pid} is dead — auto-restarting...`);
|
|
2606
|
+
restartEngine();
|
|
2607
|
+
}
|
|
2608
|
+
} catch (e) {
|
|
2609
|
+
console.error(`[watchdog] Error: ${e.message}`);
|
|
2610
|
+
}
|
|
2611
|
+
}, 30000);
|
|
2612
|
+
console.log(` Engine watchdog: active (checks every 30s)`);
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
server.on('error', e => {
|
|
2616
|
+
if (e.code === 'EADDRINUSE') {
|
|
2617
|
+
console.error(`\n Port ${PORT} already in use. Kill the existing process or change PORT.\n`);
|
|
2618
|
+
} else {
|
|
2619
|
+
console.error(e);
|
|
2620
|
+
}
|
|
2621
|
+
process.exit(1);
|
|
2622
|
+
});
|
|
2623
|
+
|